dsh-flash-mem-mon 0.1.4 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-flash-mem-mon
2
2
 
3
- Memory monitor provider for [dock-flash](https://github.com/tcgbp/dock-flash) — registers a host-memory-alert provider (RSS absolute thresholds, RSS growth rate, Major GC frequency) via `ctx.get('dockFlashAlerts')` and exposes `/memory-trend` for trend sparklines.
3
+ Memory monitor provider for [dsh-flash](https://github.com/tcgbp/dsh-flash) — registers a host-memory-alert provider (RSS absolute thresholds, RSS growth rate, Major GC frequency) via `ctx.get('dockFlashAlerts')` and exposes `/memory-trend` for trend sparklines.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,7 +8,7 @@ Memory monitor provider for [dock-flash](https://github.com/tcgbp/dock-flash)
8
8
  dsh plugin --profile <profile> add dsh-flash-mem-mon
9
9
  ```
10
10
 
11
- Requires `dock-flash >=1.6.0` (provides `dockFlashAlerts` and `quickControl` services).
11
+ Requires `dsh-flash >=1.0.0` (provides `dockFlashAlerts` and `quickControl` services).
12
12
 
13
13
  ## Configuration
14
14
 
package/README.zh-CN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-flash-mem-mon
2
2
 
3
- dock-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
3
+ dsh-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
4
4
 
5
5
  ## 安装
6
6
 
@@ -8,7 +8,7 @@ dock-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注
8
8
  dsh plugin --profile <profile> add dsh-flash-mem-mon
9
9
  ```
10
10
 
11
- 需要 `dock-flash >=1.6.0`(提供 `dockFlashAlerts` 和 `quickControl` 服务)。
11
+ 需要 `dsh-flash >=1.0.0`(提供 `dockFlashAlerts` 和 `quickControl` 服务)。
12
12
 
13
13
  ## 配置
14
14
 
package/dist/index.js CHANGED
@@ -1,372 +1,372 @@
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
+ 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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-flash-mem-mon",
3
- "version": "0.1.4",
4
- "description": "Memory monitor provider for dock-flash — registers a host-memory-alert provider (RSS absolute, RSS growth rate, Major GC frequency) via ctx.get('dockFlashAlerts') and exposes /memory-trend for trend sparklines.",
3
+ "version": "0.1.5",
4
+ "description": "Memory monitor provider for the `dsh-flash` Quick Control panel — registers a host-memory-alert provider (RSS absolute, RSS growth rate, Major GC frequency) via ctx.get('dockFlashAlerts') and exposes /memory-trend for trend sparklines.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "inject": [
23
23
  "@deepseek-ai/dsh-client-runtime",
24
24
  "@deepseek-ai/dsh-api-remotes",
25
- "dock-flash",
25
+ "dsh-flash",
26
26
  "dock-base"
27
27
  ],
28
28
  "platform": "web"
@@ -36,7 +36,7 @@
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": ">=4.0.0-rc.1 <5.0.0-0 || >=4.0.1-0 <5.0.0-0",
38
38
  "dock-base": ">=0.1.2-0 <1.0.0-0 || >=0.2.0-0 <1.0.0-0",
39
- "dock-flash": ">=1.6.0-0 <2.0.0-0 || >=2.0.0-0 <3.0.0-0"
39
+ "dsh-flash": ">=1.0.0-0 <2.0.0-0"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "dock-base": { "optional": true }