dsh-flash-mem-mon 0.1.4 → 0.1.7

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,372 +1,391 @@
1
- // Default export only (`export default Schema`); there is no named `Schema`.
2
- import Schema from '@deepseek-ai/schemastery';
3
- export const name = 'dsh-flash-mem-mon';
4
- // No host-side service dependencies; all services are injected lazily.
5
- export const inject = [];
6
- // ── 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
- }
31
- export const Config = Schema.object({
32
- memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
33
- memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
34
- memThresholdError: Schema.number().default(DEFAULT_MEM_THRESHOLD_ERROR).volatile(),
35
- memThresholdCritical: Schema.number().default(DEFAULT_MEM_THRESHOLD_CRITICAL).volatile(),
36
- memPollBase: Schema.number().default(DEFAULT_MEM_POLL_BASE).volatile(),
37
- memPollMin: Schema.number().default(DEFAULT_MEM_POLL_MIN).volatile(),
38
- gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
39
- gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
40
- gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
41
- });
42
- class MemoryTrendCollector {
43
- _samples = [];
44
- _timer = null;
45
- _cap;
46
- // GC monitoring via PerformanceObserver
47
- _gcObserver = null; // PerformanceObserver | null
48
- _gcEvents = [];
49
- _gcEventsCap = 2000; // ~5 min at worst-case GC frequency
50
- /** Separate cap for major GC events only — they are rare and valuable for diagnostics. */
51
- _gcMajorEvents = [];
52
- _gcMajorEventsCap = 500; // Full GC events are rare; keep up to 500 (~hours)
53
- constructor(cap = 720) {
54
- this._cap = Math.max(10, cap);
55
- }
56
- start(baseInterval = 30_000, minInterval = 5_000) {
57
- this.stop();
58
- // Fire-and-forget: GC observer can start asynchronously; the poll loop
59
- // below begins immediately regardless, since it does not depend on GC.
60
- this._startGcObserver().catch(() => { });
61
- const poll = () => {
62
- const mu = process.memoryUsage();
63
- this._push({
64
- ts: Date.now(),
65
- rss: mu.rss,
66
- heapTotal: mu.heapTotal,
67
- heapUsed: mu.heapUsed,
68
- external: mu.external,
69
- arrayBuffers: mu.arrayBuffers,
70
- });
71
- // Adaptive: higher heap usage → shorter interval (sample more densely).
72
- const ratio = mu.heapUsed / mu.heapTotal;
73
- const next = Math.max(minInterval, Math.round(baseInterval * Math.pow(1 - Math.min(ratio, 1), 2) + minInterval));
74
- this._timer = setTimeout(poll, next);
75
- // Unref so the timer never keeps the process alive on its own.
76
- if (this._timer && typeof this._timer === 'object' && 'unref' in this._timer) {
77
- this._timer.unref();
78
- }
79
- };
80
- // First sample immediately.
81
- poll();
82
- }
83
- stop() {
84
- if (this._timer !== null) {
85
- clearTimeout(this._timer);
86
- this._timer = null;
87
- }
88
- this._stopGcObserver();
89
- }
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)
97
- return this._samples.slice();
98
- return this._samples.filter(s => s.ts >= since);
99
- }
100
- /** Return Full (major) GC events for chart rendering.
101
- * Pass since=0 to return ALL stored events (for full-mode fetches).
102
- * Each returned event is one real full-GC cycle (phases already coalesced
103
- * in `_startGcObserver`) and is normalized to kind=2 so the client can
104
- * always treat `gcEvents` as "Full GC events". */
105
- gcEvents(since) {
106
- if (this._gcMajorEvents.length === 0)
107
- return [];
108
- const cutoff = since ?? (Date.now() - 5 * 60 * 1000);
109
- return this._gcMajorEvents
110
- .filter(e => e.ts >= cutoff)
111
- .map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
112
- .sort((a, b) => a.ts - b.ts);
113
- }
114
- summary(since) {
115
- const data = this.query(since);
116
- if (data.length === 0) {
117
- return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanSeconds: 0, rssSlopePerMin: 0, gc: null };
118
- }
119
- const current = data[data.length - 1];
120
- let peak = { heapUsed: 0, rss: 0, ts: 0 };
121
- for (const s of data) {
122
- if (s.heapUsed > peak.heapUsed)
123
- peak = { heapUsed: s.heapUsed, rss: s.rss, ts: s.ts };
124
- }
125
- // Simple linear regression on heapUsed over the last 20 samples (or fewer).
126
- const tail = data.slice(-20);
127
- let trend = 'stable';
128
- if (tail.length >= 3) {
129
- const n = tail.length;
130
- let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
131
- for (let i = 0; i < n; i++) {
132
- sumX += i;
133
- sumY += tail[i].heapUsed;
134
- sumXY += i * tail[i].heapUsed;
135
- sumXX += i * i;
136
- }
137
- const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
138
- // Threshold: ±0.5% of current heapUsed per sample-index step.
139
- const threshold = current.heapUsed * 0.005;
140
- if (slope > threshold)
141
- trend = 'up';
142
- else if (slope < -threshold)
143
- trend = 'down';
144
- }
145
- 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
- }
165
- // GC stats over the last ~5 minutes.
166
- const gc = this._gcSummary(5 * 60 * 1000);
167
- return { current, peak, trend, sampleCount: data.length, heapRatio, spanSeconds, rssSlopePerMin, gc };
168
- }
169
- /** Aggregate GC events in the last `windowMs` milliseconds. */
170
- _gcSummary(windowMs) {
171
- if (this._gcEvents.length === 0 && this._gcMajorEvents.length === 0)
172
- return null;
173
- const cutoff = Date.now() - windowMs;
174
- // Major (Full) count comes from the coalesced major-GC buffer so each
175
- // real cycle counts once (not once per phase entry).
176
- const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
177
- const majorCount = majorRecent.length;
178
- // 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).
180
- let minorCount = 0, gcPauseMs = 0;
181
- for (const e of this._gcEvents) {
182
- if (e.ts < cutoff)
183
- continue;
184
- gcPauseMs += e.duration;
185
- if ((e.kind & 1) !== 0)
186
- minorCount++;
187
- }
188
- if (majorCount === 0 && minorCount === 0 && gcPauseMs === 0)
189
- return null;
190
- const windowMin = windowMs / 60000;
191
- return {
192
- minorCount,
193
- majorCount,
194
- majorPerMin: Math.round((majorCount / windowMin) * 100) / 100,
195
- gcPauseMs: Math.round(gcPauseMs * 100) / 100,
196
- gcPausePerMin: Math.round((gcPauseMs / windowMin) * 100) / 100,
197
- };
198
- }
199
- async _startGcObserver() {
200
- try {
201
- // Dynamic import — perf_hooks may not be available in all environments.
202
- // MUST use import(), not require(): this half is ESM, so require() throws
203
- // ReferenceError into the catch — silently disabling GC monitoring forever.
204
- const { PerformanceObserver } = await import('perf_hooks');
205
- this._gcObserver = new PerformanceObserver((list) => {
206
- for (const entry of list.getEntries()) {
207
- // GC entry kinds form a bitmask (see isFullGc): 1=Scavenge(minor),
208
- // 2/4/8 = MarkSweepCompact/IncrementalMarking/ProcessWeakCallbacks.
209
- // A single real Full GC is usually reported as several phase entries
210
- // (e.g. kind 4 + kind 8) within a few ms, so we coalesce them into
211
- // one Full GC event below.
212
- const kind = entry.kind ?? 0;
213
- const event = { ts: Date.now(), kind, duration: entry.duration };
214
- this._gcEvents.push(event);
215
- // Also store full (major) GC events in a separate long-lived buffer,
216
- // because they are rare and the general ring buffer may evict them.
217
- if (isFullGc(kind)) {
218
- const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
219
- if (last && event.ts - last.ts < 500) {
220
- // Same full-GC cycle (phase entries arriving back-to-back):
221
- // merge into the existing event, keeping the worst-case pause.
222
- last.duration = Math.max(last.duration, event.duration);
223
- last.ts = event.ts;
224
- }
225
- else {
226
- this._gcMajorEvents.push(event);
227
- while (this._gcMajorEvents.length > this._gcMajorEventsCap)
228
- this._gcMajorEvents.shift();
229
- }
230
- }
231
- }
232
- // Cap the ring buffer.
233
- while (this._gcEvents.length > this._gcEventsCap)
234
- this._gcEvents.shift();
235
- });
236
- this._gcObserver.observe({ type: 'gc', buffered: true });
237
- }
238
- catch (_) {
239
- // GC observation not available — gc will remain null in summaries.
240
- this._gcObserver = null;
241
- }
242
- }
243
- _stopGcObserver() {
244
- if (this._gcObserver) {
245
- try {
246
- this._gcObserver.disconnect();
247
- }
248
- catch (_) { }
249
- this._gcObserver = null;
250
- }
251
- this._gcEvents = [];
252
- this._gcMajorEvents = [];
253
- }
254
- _push(s) {
255
- this._samples.push(s);
256
- if (this._samples.length > this._cap)
257
- this._samples.shift();
258
- }
259
- }
260
- /** Module-level singleton — one collector per process. */
261
- let _memoryTrend = null;
262
- // ── Private HTTP helpers ────────────────────────────────────────────────
263
- /** Send a JSON response with no-store cache control. */
264
- function sendJson(res, status, payload) {
265
- res.statusCode = status;
266
- res.setHeader('content-type', 'application/json; charset=utf-8');
267
- res.setHeader('cache-control', 'no-store');
268
- res.end(JSON.stringify(payload));
269
- }
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
- // ── apply() ─────────────────────────────────────────────────────────────
294
- export function apply(ctx, config) {
295
- // ── Settings namespace registration ──────────────────────────────────
296
- ctx.inject(['settings'], (settingsCtx) => {
297
- settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
298
- });
299
- // ── 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);
305
- ctx.effect(() => {
306
- const collector = _memoryTrend;
307
- return () => {
308
- collector?.stop();
309
- _memoryTrend = null;
310
- };
311
- });
312
- // ── Volatile-update handler for poll intervals ────────────────────────
313
- // Restart the collector when memPollBase or memPollMin change.
314
- ctx.on('loader/volatile-update', (paths) => {
315
- const memPaths = ['memPollBase', 'memPollMin'];
316
- if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
317
- 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
- }
322
- });
323
- // ── 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.
326
- ctx.inject(['webServer'], (wsCtx) => {
327
- // GET /plugins/dsh-flash-mem-mon/memory-trend
328
- // Returns memory trend data for the client's config popup.
329
- // `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
330
- // `mode=full` returns the raw samples for rendering a chart.
331
- // Optional `since` (unix ms) limits the range.
332
- wsCtx.effect(() => wsCtx.webServer.register({
333
- kind: 'exact',
334
- path: '/plugins/dsh-flash-mem-mon/memory-trend',
335
- handler: async (req, res) => {
336
- if (req.method !== 'GET') {
337
- res.statusCode = 405;
338
- res.setHeader('allow', 'GET');
339
- res.end();
340
- return;
341
- }
342
- if (!_memoryTrend) {
343
- sendJson(res, 200, { samples: [], summary: null });
344
- return;
345
- }
346
- const u = new URL(req.url || '/', 'http://localhost');
347
- const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
348
- const mode = u.searchParams.get('mode') || 'summary';
349
- 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
359
- // still available via mode=summary (majorPerMin, gcPausePerMin).
360
- const majorGcEvents = _memoryTrend.gcEvents(gcSince);
361
- sendJson(res, 200, {
362
- samples: _memoryTrend.query(since || undefined),
363
- gcEvents: majorGcEvents,
364
- });
365
- }
366
- else {
367
- sendJson(res, 200, _memoryTrend.summary(since || undefined));
368
- }
369
- },
370
- }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
371
- });
372
- }
1
+ // Default export only (`export default Schema`); there is no named `Schema`.
2
+ import Schema from '@deepseek-ai/schemastery';
3
+ import { DEFAULT_ALERT_CONFIG, isFullGc, rssSlopePerMin } from './pure.js';
4
+ export const name = 'dsh-flash-mem-mon';
5
+ // No host-side service dependencies; all services are injected lazily.
6
+ export const inject = [];
7
+ // ── Memory alert thresholds (RSS MB) and GC thresholds (major GC/min) ──
8
+ // All values come from the single canonical DEFAULT_ALERT_CONFIG in
9
+ // ./pure.js. The browser client half mirrors these (see lib/client.js
10
+ // _MEM_ALERT_DEFAULTS); test/defaults-drift.test.ts guarantees they never
11
+ // drift from this source of truth.
12
+ const DEFAULT_MEM_THRESHOLD_INFO = DEFAULT_ALERT_CONFIG.memThresholdInfo;
13
+ const DEFAULT_MEM_THRESHOLD_WARNING = DEFAULT_ALERT_CONFIG.memThresholdWarning;
14
+ const DEFAULT_MEM_THRESHOLD_ERROR = DEFAULT_ALERT_CONFIG.memThresholdError;
15
+ const DEFAULT_MEM_THRESHOLD_CRITICAL = DEFAULT_ALERT_CONFIG.memThresholdCritical;
16
+ const DEFAULT_MEM_POLL_BASE = DEFAULT_ALERT_CONFIG.memPollBase;
17
+ const DEFAULT_MEM_POLL_MIN = DEFAULT_ALERT_CONFIG.memPollMin;
18
+ const DEFAULT_GC_THRESHOLD_INFO = DEFAULT_ALERT_CONFIG.gcThresholdInfo;
19
+ const DEFAULT_GC_THRESHOLD_WARNING = DEFAULT_ALERT_CONFIG.gcThresholdWarning;
20
+ const DEFAULT_GC_THRESHOLD_ERROR = DEFAULT_ALERT_CONFIG.gcThresholdError;
21
+ const DEFAULT_MEM_GROWTH_ALERT_PER_MIN = DEFAULT_ALERT_CONFIG.memGrowthAlertPerMin;
22
+ /**
23
+ * Sentinel passed to `query()` / `gcEvents()` as the `since` lower bound to
24
+ * mean "no lower bound — return ALL stored records". These methods treat any
25
+ * `since <= 0` (0 or undefined) as "all", and a positive value as a unix-ms
26
+ * timestamp to filter from. Keeping one explicit convention prevents callers
27
+ * from mixing up `0` vs `undefined` (both mean "all").
28
+ */
29
+ const SINCE_ALL = 0;
30
+ export const Config = Schema.object({
31
+ memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
32
+ memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
33
+ memThresholdError: Schema.number().default(DEFAULT_MEM_THRESHOLD_ERROR).volatile(),
34
+ memThresholdCritical: Schema.number().default(DEFAULT_MEM_THRESHOLD_CRITICAL).volatile(),
35
+ memPollBase: Schema.number().default(DEFAULT_MEM_POLL_BASE).volatile(),
36
+ memPollMin: Schema.number().default(DEFAULT_MEM_POLL_MIN).volatile(),
37
+ gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
38
+ gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
39
+ gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
40
+ memGrowthAlertPerMin: Schema.number().default(DEFAULT_MEM_GROWTH_ALERT_PER_MIN).volatile(),
41
+ });
42
+ class MemoryTrendCollector {
43
+ _samples = [];
44
+ // The poll loop is self-scheduling via setInterval-like recursion with an
45
+ // ADAPTIVE delay (higher heap usage → shorter interval), so each cycle holds
46
+ // a fresh setTimeout handle rather than one fixed-interval setInterval.
47
+ _timer = null;
48
+ /** Monotonic loop generation. Bumped every start()/stop(); a poll() tick
49
+ * whose captured generation no longer matches the current one is a stale
50
+ * callback from a superseded loop and must NOT re-schedule itself. This
51
+ * closes the start/stop restart race where a queued pre-restart tick could
52
+ * otherwise clobber the new loop's timer handle and leak a second loop. */
53
+ _generation = 0;
54
+ _cap;
55
+ // GC monitoring via PerformanceObserver
56
+ _gcObserver = null; // PerformanceObserver | null
57
+ _gcEvents = [];
58
+ _gcEventsCap = 2000; // ~5 min at worst-case GC frequency
59
+ /** Separate cap for major GC events only — they are rare and valuable for diagnostics. */
60
+ _gcMajorEvents = [];
61
+ _gcMajorEventsCap = 500; // Full GC events are rare; keep up to 500 (~hours)
62
+ constructor(cap = 720) {
63
+ this._cap = Math.max(10, cap);
64
+ }
65
+ start(baseInterval = 30_000, minInterval = 5_000) {
66
+ this.stop();
67
+ const gen = ++this._generation;
68
+ // Fire-and-forget: GC observer can start asynchronously; the poll loop
69
+ // below begins immediately regardless, since it does not depend on GC.
70
+ this._startGcObserver().catch(() => { });
71
+ const poll = () => {
72
+ // Bail if this tick belongs to a superseded loop (a restart has bumped
73
+ // the generation since we were scheduled). The stopped loop then winds
74
+ // down on its own instead of fighting the fresh loop for this._timer.
75
+ if (gen !== this._generation)
76
+ return;
77
+ const mu = process.memoryUsage();
78
+ this._push({
79
+ ts: Date.now(),
80
+ rss: mu.rss,
81
+ heapTotal: mu.heapTotal,
82
+ heapUsed: mu.heapUsed,
83
+ external: mu.external,
84
+ arrayBuffers: mu.arrayBuffers,
85
+ });
86
+ // Adaptive: higher heap usage → shorter interval (sample more densely).
87
+ const ratio = mu.heapUsed / mu.heapTotal;
88
+ const next = Math.max(minInterval, Math.round(baseInterval * Math.pow(1 - Math.min(ratio, 1), 2) + minInterval));
89
+ this._timer = setTimeout(poll, next);
90
+ // Unref so the timer never keeps the process alive on its own.
91
+ if (this._timer && typeof this._timer === 'object' && 'unref' in this._timer) {
92
+ this._timer.unref();
93
+ }
94
+ };
95
+ // First sample immediately.
96
+ poll();
97
+ }
98
+ stop() {
99
+ // Bump the generation BEFORE clearing so any in-flight/stale poll tick that
100
+ // is already queued (past clearTimeout's reach) recognises itself as stale
101
+ // and refuses to re-schedule, never resurrecting a stopped loop.
102
+ this._generation++;
103
+ if (this._timer !== null) {
104
+ clearTimeout(this._timer);
105
+ this._timer = null;
106
+ }
107
+ this._stopGcObserver();
108
+ }
109
+ /** Memory samples at or after `since` (unix ms). A falsy/absent `since`
110
+ * (`SINCE_ALL` / undefined) means "return all stored samples". */
111
+ query(since = SINCE_ALL) {
112
+ if (since <= 0)
113
+ return this._samples.slice();
114
+ return this._samples.filter(s => s.ts >= since);
115
+ }
116
+ /** Return Full (major) GC events for chart rendering.
117
+ * Each returned event is one real full-GC cycle (phases already coalesced
118
+ * in `_startGcObserver`) and is normalized to kind=2 so the client can
119
+ * always treat `gcEvents` as "Full GC events". A falsy/absent `since`
120
+ * (`SINCE_ALL` / undefined) means "return all stored events". */
121
+ gcEvents(since = SINCE_ALL) {
122
+ if (this._gcMajorEvents.length === 0)
123
+ return [];
124
+ const events = since > 0
125
+ ? this._gcMajorEvents.filter(e => e.ts >= since)
126
+ : this._gcMajorEvents;
127
+ return events
128
+ .map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
129
+ .sort((a, b) => a.ts - b.ts);
130
+ }
131
+ summary(since) {
132
+ const data = this.query(since);
133
+ if (data.length === 0) {
134
+ return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanMs: 0, rssSlopePerMin: 0, gc: null };
135
+ }
136
+ const current = data[data.length - 1];
137
+ let peak = { heapUsed: 0, rss: 0, ts: 0 };
138
+ for (const s of data) {
139
+ if (s.heapUsed > peak.heapUsed)
140
+ peak = { heapUsed: s.heapUsed, rss: s.rss, ts: s.ts };
141
+ }
142
+ // Simple linear regression on heapUsed over the last 20 samples (or fewer).
143
+ const tail = data.slice(-20);
144
+ let trend = 'stable';
145
+ if (tail.length >= 3) {
146
+ const n = tail.length;
147
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
148
+ for (let i = 0; i < n; i++) {
149
+ sumX += i;
150
+ sumY += tail[i].heapUsed;
151
+ sumXY += i * tail[i].heapUsed;
152
+ sumXX += i * i;
153
+ }
154
+ const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
155
+ // Threshold: ±0.5% of current heapUsed per sample-index step.
156
+ const threshold = current.heapUsed * 0.005;
157
+ if (slope > threshold)
158
+ trend = 'up';
159
+ else if (slope < -threshold)
160
+ trend = 'down';
161
+ }
162
+ const heapRatio = current.heapTotal > 0 ? current.heapUsed / current.heapTotal : 0;
163
+ const spanMs = data.length >= 2 ? data[data.length - 1].ts - data[0].ts : 0;
164
+ // RSS linear-regression slope per minute (MB/min); positive = growing.
165
+ // Delegated to the pure helper (src/pure.ts) so the same logic is unit-
166
+ // tested. The average interval comes from the tail's own time span — see
167
+ // rssSlopePerMin for why using the whole buffer span would under-report.
168
+ const rssSlope = rssSlopePerMin(data);
169
+ // GC stats over the last ~5 minutes.
170
+ const gc = this._gcSummary(5 * 60 * 1000);
171
+ return { current, peak, trend, sampleCount: data.length, heapRatio, spanMs, rssSlopePerMin: rssSlope, gc };
172
+ }
173
+ /** Aggregate GC events in the last `windowMs` milliseconds. */
174
+ _gcSummary(windowMs) {
175
+ if (this._gcEvents.length === 0 && this._gcMajorEvents.length === 0)
176
+ return null;
177
+ const cutoff = Date.now() - windowMs;
178
+ // Major (Full) count comes from the coalesced major-GC buffer so each
179
+ // real cycle counts once (not once per phase entry).
180
+ const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
181
+ const majorCount = majorRecent.length;
182
+ // Minor count + total pause come from the general ring buffer (which holds
183
+ // EVERY PerformanceObserver entry). A minor cycle is a Scavenge (kind bit 1
184
+ // set). NOTE: gcPauseMs is the SUM of every phase-entry duration in the
185
+ // window — one real Full GC is reported as multiple phases (kind 4 + 8), and
186
+ // each contributes its own duration, so gcPauseMs is the TOTAL stop-the-world
187
+ // pause across all cycles (minor + full), NOT per-cycle. majorCount, by
188
+ // contrast, comes from the separate coalesced buffer and counts each real
189
+ // Full GC cycle ONCE. The two buffers intentionally have different semantics:
190
+ // majorCount = distinct full collections; gcPauseMs = aggregate pauses.
191
+ let minorCount = 0, gcPauseMs = 0;
192
+ for (const e of this._gcEvents) {
193
+ if (e.ts < cutoff)
194
+ continue;
195
+ gcPauseMs += e.duration;
196
+ if ((e.kind & 1) !== 0)
197
+ minorCount++;
198
+ }
199
+ if (majorCount === 0 && minorCount === 0 && gcPauseMs === 0)
200
+ return null;
201
+ const windowMin = windowMs / 60000;
202
+ return {
203
+ minorCount,
204
+ majorCount,
205
+ majorPerMin: Math.round((majorCount / windowMin) * 100) / 100,
206
+ gcPauseMs: Math.round(gcPauseMs * 100) / 100,
207
+ gcPausePerMin: Math.round((gcPauseMs / windowMin) * 100) / 100,
208
+ };
209
+ }
210
+ async _startGcObserver() {
211
+ try {
212
+ // Dynamic import — perf_hooks may not be available in all environments.
213
+ // MUST use import(), not require(): this half is ESM, so require() throws
214
+ // ReferenceError into the catch — silently disabling GC monitoring forever.
215
+ const { PerformanceObserver } = await import('perf_hooks');
216
+ this._gcObserver = new PerformanceObserver((list) => {
217
+ for (const entry of list.getEntries()) {
218
+ // GC entry kinds form a bitmask (see isFullGc): 1=Scavenge(minor),
219
+ // 2/4/8 = MarkSweepCompact/IncrementalMarking/ProcessWeakCallbacks.
220
+ // A single real Full GC is usually reported as several phase entries
221
+ // (e.g. kind 4 + kind 8) within a few ms, so we coalesce them into
222
+ // one Full GC event below.
223
+ const kind = entry.kind ?? 0;
224
+ const event = { ts: Date.now(), kind, duration: entry.duration };
225
+ this._gcEvents.push(event);
226
+ // Also store full (major) GC events in a separate long-lived buffer,
227
+ // because they are rare and the general ring buffer may evict them.
228
+ if (isFullGc(kind)) {
229
+ const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
230
+ if (last && event.ts - last.ts < 500) {
231
+ // Same full-GC cycle (phase entries arriving back-to-back):
232
+ // merge into the existing event, keeping the worst-case pause.
233
+ last.duration = Math.max(last.duration, event.duration);
234
+ last.ts = event.ts;
235
+ }
236
+ else {
237
+ this._gcMajorEvents.push(event);
238
+ while (this._gcMajorEvents.length > this._gcMajorEventsCap)
239
+ this._gcMajorEvents.shift();
240
+ }
241
+ }
242
+ }
243
+ // Cap the ring buffer.
244
+ while (this._gcEvents.length > this._gcEventsCap)
245
+ this._gcEvents.shift();
246
+ });
247
+ this._gcObserver.observe({ type: 'gc', buffered: true });
248
+ }
249
+ catch (_) {
250
+ // GC observation not available — gc will remain null in summaries.
251
+ this._gcObserver = null;
252
+ }
253
+ }
254
+ _stopGcObserver() {
255
+ if (this._gcObserver) {
256
+ try {
257
+ this._gcObserver.disconnect();
258
+ }
259
+ catch (_) { }
260
+ this._gcObserver = null;
261
+ }
262
+ this._gcEvents = [];
263
+ this._gcMajorEvents = [];
264
+ }
265
+ _push(s) {
266
+ this._samples.push(s);
267
+ if (this._samples.length > this._cap)
268
+ this._samples.shift();
269
+ }
270
+ }
271
+ // ── Process-scoped singleton with an exclusive active owner ─────────────
272
+ //
273
+ // A single MemoryTrendCollector is shared across the whole process so all
274
+ // consumers see one coherent ring buffer (each sample carries its own ts).
275
+ // However, Cordis may apply() this plugin more than once (multiple profiles,
276
+ // hot reload) and dispose() them in any order. To avoid two collectors racing
277
+ // on the same module state — and to stop one stray dispose() from killing a
278
+ // collector another apply() is still using — ownership is explicit:
279
+ //
280
+ // _memoryTrend : the live collector (or null)
281
+ // _ownerCtx : the Context that currently owns it (or null)
282
+ //
283
+ // startTrend()/stopTrend() are idempotent and fail softly when the caller is
284
+ // not the current owner, so overlapping apply/dispose cycles stay safe.
285
+ let _memoryTrend = null;
286
+ let _ownerCtx = null;
287
+ /** Adopt the process-wide collector for `ctx`; no-op if it is already owned. */
288
+ function startTrend(ctx, config) {
289
+ if (_ownerCtx && _ownerCtx !== ctx)
290
+ return; // someone else owns it
291
+ if (!_memoryTrend)
292
+ _memoryTrend = new MemoryTrendCollector();
293
+ _ownerCtx = ctx;
294
+ _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
295
+ }
296
+ function restartTrend(config) {
297
+ if (!_memoryTrend || !_ownerCtx)
298
+ return;
299
+ _memoryTrend.stop();
300
+ _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
301
+ }
302
+ /** Relinquish ownership of `ctx`. Only the active owner may stop the shared
303
+ * collector; other ctx instances (already superseded) must not touch it. */
304
+ function stopTrend(ctx) {
305
+ if (_ownerCtx !== ctx)
306
+ return; // not the owner → leave the running collector alone
307
+ _ownerCtx = null;
308
+ _memoryTrend?.stop();
309
+ }
310
+ // ── Private HTTP helpers ────────────────────────────────────────────────
311
+ /** Send a JSON response with no-store cache control. */
312
+ function sendJson(res, status, payload) {
313
+ res.statusCode = status;
314
+ res.setHeader('content-type', 'application/json; charset=utf-8');
315
+ res.setHeader('cache-control', 'no-store');
316
+ res.end(JSON.stringify(payload));
317
+ }
318
+ // ── apply() ─────────────────────────────────────────────────────────────
319
+ export function apply(ctx, config) {
320
+ // ── Settings namespace registration ──────────────────────────────────
321
+ ctx.inject(['settings'], (settingsCtx) => {
322
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
323
+ });
324
+ // ── Memory trend collector bootstrap ──────────────────────────────────
325
+ // One bounded ring buffer per process, owned exclusively by the active ctx.
326
+ // startTrend/stopTrend are idempotent and owner-guarded, so overlapping
327
+ // apply/dispose cycles never double-start or kill a still-live collector.
328
+ startTrend(ctx, config);
329
+ ctx.effect(() => {
330
+ const owner = ctx;
331
+ return () => { stopTrend(owner); };
332
+ }, 'dsh-flash-mem-mon: memory-trend collector lifecycle');
333
+ // ── Volatile-update handler for poll intervals ────────────────────────
334
+ // Restart the collector when memPollBase or memPollMin change.
335
+ ctx.on('loader/volatile-update', (paths) => {
336
+ const memPaths = ['memPollBase', 'memPollMin'];
337
+ if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
338
+ return;
339
+ restartTrend(config);
340
+ });
341
+ // ── HTTP API route for client-side trend data ────────────────────────
342
+ // Uses a structural HostWebServer type instead of `any`. cordis's inject()
343
+ // callback is statically typed as Context (the real webServer augmentation
344
+ // lives in @deepseek-ai/dsh-host-webserver, not a compile-time dep here), so
345
+ // the injected service is read through one narrow cast inside the callback —
346
+ // when inject(keys) fires, the service is guaranteed present — then used
347
+ // under the strong HostWebServer type for the whole register/effect block.
348
+ ctx.inject(['webServer'], (wsCtx) => {
349
+ const ws = wsCtx.webServer;
350
+ // GET /plugins/dsh-flash-mem-mon/memory-trend
351
+ // Returns memory trend data for the client's config popup.
352
+ // `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
353
+ // `mode=full` returns the raw samples for rendering a chart.
354
+ // Optional `since` (unix ms) limits the range.
355
+ wsCtx.effect(() => ws.register({
356
+ kind: 'exact',
357
+ path: '/plugins/dsh-flash-mem-mon/memory-trend',
358
+ handler: async (req, res) => {
359
+ if (req.method !== 'GET') {
360
+ res.statusCode = 405;
361
+ res.setHeader('allow', 'GET');
362
+ res.end();
363
+ return;
364
+ }
365
+ if (!_memoryTrend) {
366
+ sendJson(res, 200, { samples: [], summary: null });
367
+ return;
368
+ }
369
+ const u = new URL(req.url || '/', 'http://localhost');
370
+ const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
371
+ const mode = u.searchParams.get('mode') || 'summary';
372
+ if (mode === 'full') {
373
+ // `since` is already >= 0 here; 0 means "entire range" (SINCE_ALL),
374
+ // so both `query` and `gcEvents` share the same bounds convention.
375
+ // Only full (major) GC events are sent — minor GC is too frequent
376
+ // and clutters the chart. These are already coalesced into one
377
+ // event per real cycle by `_startGcObserver`. Minor GC stats are
378
+ // still available via mode=summary (majorPerMin, gcPausePerMin).
379
+ const majorGcEvents = _memoryTrend.gcEvents(since);
380
+ sendJson(res, 200, {
381
+ samples: _memoryTrend.query(since),
382
+ gcEvents: majorGcEvents,
383
+ });
384
+ }
385
+ else {
386
+ sendJson(res, 200, _memoryTrend.summary(since));
387
+ }
388
+ },
389
+ }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
390
+ });
391
+ }