dsh-flash-mem-mon 0.1.5 → 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/README.md CHANGED
@@ -1,38 +1,39 @@
1
- # dsh-flash-mem-mon
2
-
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
-
5
- ## Installation
6
-
7
- ```bash
8
- dsh plugin --profile <profile> add dsh-flash-mem-mon
9
- ```
10
-
11
- Requires `dsh-flash >=1.0.0` (provides `dockFlashAlerts` and `quickControl` services).
12
-
13
- ## Configuration
14
-
15
- All settings are live-editable (volatile) and take effect immediately:
16
-
17
- | Setting | Default | Description |
18
- |---|---|---|
19
- | `memThresholdInfo` | 256 MB | RSS info threshold |
20
- | `memThresholdWarning` | 512 MB | RSS warning threshold |
21
- | `memThresholdError` | 1024 MB | RSS error threshold |
22
- | `memThresholdCritical` | 1536 MB | RSS critical threshold |
23
- | `memPollBase` | 30000 ms | Base polling interval |
24
- | `memPollMin` | 2000 ms | Minimum polling interval |
25
- | `gcThresholdInfo` | 2 /min | Major GC info threshold |
26
- | `gcThresholdWarning` | 5 /min | Major GC warning threshold |
27
- | `gcThresholdError` | 10 /min | Major GC error threshold |
28
-
29
- ## Architecture
30
-
31
- This is a **companion plugin** following the same two-half pattern as `dsh-flash-net-mon`:
32
-
33
- - **Host half** (`src/index.ts` → tsc → `dist/index.js`): Memory trend ring buffer, GC observer, `/memory-trend` HTTP route
34
- - **Client half** (`lib/client.js`): Alert provider, enable toggle, config modal with sparkline, i18n
35
-
36
- ## License
37
-
38
- Apache-2.0
1
+ # dsh-flash-mem-mon
2
+
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
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ dsh plugin --profile <profile> add dsh-flash-mem-mon
9
+ ```
10
+
11
+ Requires `dsh-flash >=1.0.0` (provides `dockFlashAlerts` and `quickControl` services).
12
+
13
+ ## Configuration
14
+
15
+ All settings are live-editable (volatile) and take effect immediately:
16
+
17
+ | Setting | Default | Description |
18
+ |---|---|---|
19
+ | `memThresholdInfo` | 256 MB | RSS info threshold |
20
+ | `memThresholdWarning` | 512 MB | RSS warning threshold |
21
+ | `memThresholdError` | 1024 MB | RSS error threshold |
22
+ | `memThresholdCritical` | 1536 MB | RSS critical threshold |
23
+ | `memPollBase` | 30000 ms | Base polling interval |
24
+ | `memPollMin` | 2000 ms | Minimum polling interval |
25
+ | `gcThresholdInfo` | 2 /min | Major GC info threshold |
26
+ | `gcThresholdWarning` | 5 /min | Major GC warning threshold |
27
+ | `gcThresholdError` | 10 /min | Major GC error threshold |
28
+ | `memGrowthAlertPerMin` | 1 MB/min | RSS growth-rate (linear-regression slope) alert threshold, applied with trend = up |
29
+
30
+ ## Architecture
31
+
32
+ This is a **companion plugin** following the same two-half pattern as `dsh-flash-net-mon`:
33
+
34
+ - **Host half** (`src/index.ts` → tsc → `dist/index.js`): Memory trend ring buffer, GC observer, `/memory-trend` HTTP route
35
+ - **Client half** (`lib/client.js`): Alert provider, enable toggle, config modal with sparkline, i18n via DSH's `locale` service
36
+
37
+ ## License
38
+
39
+ Apache-2.0
package/README.zh-CN.md CHANGED
@@ -1,38 +1,38 @@
1
- # dsh-flash-mem-mon
2
-
3
- dsh-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
4
-
5
- ## 安装
6
-
7
- ```bash
8
- dsh plugin --profile <profile> add dsh-flash-mem-mon
9
- ```
10
-
11
- 需要 `dsh-flash >=1.0.0`(提供 `dockFlashAlerts` 和 `quickControl` 服务)。
12
-
13
- ## 配置
14
-
15
- 所有设置均支持热更新(volatile),修改后立即生效:
16
-
17
- | 设置 | 默认值 | 说明 |
18
- |---|---|---|
19
- | `memThresholdInfo` | 256 MB | RSS 信息阈值 |
20
- | `memThresholdWarning` | 512 MB | RSS 警告阈值 |
21
- | `memThresholdError` | 1024 MB | RSS 错误阈值 |
22
- | `memThresholdCritical` | 1536 MB | RSS 严重阈值 |
23
- | `memPollBase` | 30000 ms | 基础轮询间隔 |
24
- | `memPollMin` | 2000 ms | 最小轮询间隔 |
25
- | `gcThresholdInfo` | 2 /min | Major GC 信息阈值 |
26
- | `gcThresholdWarning` | 5 /min | Major GC 警告阈值 |
27
- | `gcThresholdError` | 10 /min | Major GC 错误阈值 |
28
-
29
- ## 架构
30
-
31
- 本插件为 **伴随插件**,遵循与 `dsh-flash-net-mon` 相同的双半模式:
32
-
33
- - **宿主端** (`src/index.ts` → tsc → `dist/index.js`):内存趋势环形缓冲区、GC 观察器、`/memory-trend` HTTP 路由
34
- - **客户端** (`lib/client.js`):告警提供器、启用开关、带趋势图的配置弹窗、国际化
35
-
36
- ## 许可证
37
-
38
- Apache-2.0
1
+ # dsh-flash-mem-mon
2
+
3
+ dsh-flash 的内存监控提供器 — 通过 `ctx.get('dockFlashAlerts')` 注册主机内存告警提供器(RSS 绝对阈值、RSS 增长率、Major GC 频率),并暴露 `/memory-trend` 路由用于趋势图。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ dsh plugin --profile <profile> add dsh-flash-mem-mon
9
+ ```
10
+
11
+ 需要 `dsh-flash >=1.0.0`(提供 `dockFlashAlerts` 和 `quickControl` 服务)。
12
+
13
+ ## 配置
14
+
15
+ 所有设置均支持热更新(volatile),修改后立即生效:
16
+
17
+ | 设置 | 默认值 | 说明 |
18
+ |---|---|---|
19
+ | `memThresholdInfo` | 256 MB | RSS 信息阈值 |
20
+ | `memThresholdWarning` | 512 MB | RSS 警告阈值 |
21
+ | `memThresholdError` | 1024 MB | RSS 错误阈值 |
22
+ | `memThresholdCritical` | 1536 MB | RSS 严重阈值 |
23
+ | `memPollBase` | 30000 ms | 基础轮询间隔 |
24
+ | `memPollMin` | 2000 ms | 最小轮询间隔 |
25
+ | `gcThresholdInfo` | 2 /min | Major GC 信息阈值 |
26
+ | `gcThresholdWarning` | 5 /min | Major GC 警告阈值 |
27
+ | `gcThresholdError` | 10 /min | Major GC 错误阈值 |
28
+
29
+ ## 架构
30
+
31
+ 本插件为 **伴随插件**,遵循与 `dsh-flash-net-mon` 相同的双半模式:
32
+
33
+ - **宿主端** (`src/index.ts` → tsc → `dist/index.js`):内存趋势环形缓冲区、GC 观察器、`/memory-trend` HTTP 路由
34
+ - **客户端** (`lib/client.js`):告警提供器、启用开关、带趋势图的配置弹窗、复用 DSH 官方 `locale` 服务的国际化
35
+
36
+ ## 许可证
37
+
38
+ Apache-2.0
package/cordis.patch.yml CHANGED
@@ -1,11 +1,11 @@
1
- # dsh-flash-mem-mon bundle layer — registers the HOST half into a profile.
2
- #
3
- # The BROWSER half needs no row here: the module loader auto-discovers the
4
- # client bundle from package.json `exports["./client"]` + `dsh.client` and
5
- # serves it at /plugins/<pkg>/client.js.
6
- #
7
- # PITFALL: `name` is a PACKAGE NAME (resolved via the profile's node_modules),
8
- # NOT a relative path.
9
- - insert:
10
- - id: dsh-flash-mem-mon
11
- name: dsh-flash-mem-mon
1
+ # dsh-flash-mem-mon bundle layer — registers the HOST half into a profile.
2
+ #
3
+ # The BROWSER half needs no row here: the module loader auto-discovers the
4
+ # client bundle from package.json `exports["./client"]` + `dsh.client` and
5
+ # serves it at /plugins/<pkg>/client.js.
6
+ #
7
+ # PITFALL: `name` is a PACKAGE NAME (resolved via the profile's node_modules),
8
+ # NOT a relative path.
9
+ - insert:
10
+ - id: dsh-flash-mem-mon
11
+ name: dsh-flash-mem-mon
package/dist/index.js CHANGED
@@ -1,33 +1,32 @@
1
1
  // Default export only (`export default Schema`); there is no named `Schema`.
2
2
  import Schema from '@deepseek-ai/schemastery';
3
+ import { DEFAULT_ALERT_CONFIG, isFullGc, rssSlopePerMin } from './pure.js';
3
4
  export const name = 'dsh-flash-mem-mon';
4
5
  // No host-side service dependencies; all services are injected lazily.
5
6
  export const inject = [];
6
7
  // ── 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
- }
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;
31
30
  export const Config = Schema.object({
32
31
  memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
33
32
  memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
@@ -38,10 +37,20 @@ export const Config = Schema.object({
38
37
  gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
39
38
  gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
40
39
  gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
40
+ memGrowthAlertPerMin: Schema.number().default(DEFAULT_MEM_GROWTH_ALERT_PER_MIN).volatile(),
41
41
  });
42
42
  class MemoryTrendCollector {
43
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.
44
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;
45
54
  _cap;
46
55
  // GC monitoring via PerformanceObserver
47
56
  _gcObserver = null; // PerformanceObserver | null
@@ -55,10 +64,16 @@ class MemoryTrendCollector {
55
64
  }
56
65
  start(baseInterval = 30_000, minInterval = 5_000) {
57
66
  this.stop();
67
+ const gen = ++this._generation;
58
68
  // Fire-and-forget: GC observer can start asynchronously; the poll loop
59
69
  // below begins immediately regardless, since it does not depend on GC.
60
70
  this._startGcObserver().catch(() => { });
61
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;
62
77
  const mu = process.memoryUsage();
63
78
  this._push({
64
79
  ts: Date.now(),
@@ -81,40 +96,42 @@ class MemoryTrendCollector {
81
96
  poll();
82
97
  }
83
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++;
84
103
  if (this._timer !== null) {
85
104
  clearTimeout(this._timer);
86
105
  this._timer = null;
87
106
  }
88
107
  this._stopGcObserver();
89
108
  }
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)
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)
97
113
  return this._samples.slice();
98
114
  return this._samples.filter(s => s.ts >= since);
99
115
  }
100
116
  /** Return Full (major) GC events for chart rendering.
101
- * Pass since=0 to return ALL stored events (for full-mode fetches).
102
117
  * Each returned event is one real full-GC cycle (phases already coalesced
103
118
  * in `_startGcObserver`) and is normalized to kind=2 so the client can
104
- * always treat `gcEvents` as "Full GC events". */
105
- gcEvents(since) {
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) {
106
122
  if (this._gcMajorEvents.length === 0)
107
123
  return [];
108
- const cutoff = since ?? (Date.now() - 5 * 60 * 1000);
109
- return this._gcMajorEvents
110
- .filter(e => e.ts >= cutoff)
124
+ const events = since > 0
125
+ ? this._gcMajorEvents.filter(e => e.ts >= since)
126
+ : this._gcMajorEvents;
127
+ return events
111
128
  .map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
112
129
  .sort((a, b) => a.ts - b.ts);
113
130
  }
114
131
  summary(since) {
115
132
  const data = this.query(since);
116
133
  if (data.length === 0) {
117
- return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanSeconds: 0, rssSlopePerMin: 0, gc: null };
134
+ return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanMs: 0, rssSlopePerMin: 0, gc: null };
118
135
  }
119
136
  const current = data[data.length - 1];
120
137
  let peak = { heapUsed: 0, rss: 0, ts: 0 };
@@ -143,28 +160,15 @@ class MemoryTrendCollector {
143
160
  trend = 'down';
144
161
  }
145
162
  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
- }
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);
165
169
  // GC stats over the last ~5 minutes.
166
170
  const gc = this._gcSummary(5 * 60 * 1000);
167
- return { current, peak, trend, sampleCount: data.length, heapRatio, spanSeconds, rssSlopePerMin, gc };
171
+ return { current, peak, trend, sampleCount: data.length, heapRatio, spanMs, rssSlopePerMin: rssSlope, gc };
168
172
  }
169
173
  /** Aggregate GC events in the last `windowMs` milliseconds. */
170
174
  _gcSummary(windowMs) {
@@ -176,7 +180,14 @@ class MemoryTrendCollector {
176
180
  const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
177
181
  const majorCount = majorRecent.length;
178
182
  // 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).
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.
180
191
  let minorCount = 0, gcPauseMs = 0;
181
192
  for (const e of this._gcEvents) {
182
193
  if (e.ts < cutoff)
@@ -257,8 +268,45 @@ class MemoryTrendCollector {
257
268
  this._samples.shift();
258
269
  }
259
270
  }
260
- /** Module-level singleton — one collector per process. */
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.
261
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
+ }
262
310
  // ── Private HTTP helpers ────────────────────────────────────────────────
263
311
  /** Send a JSON response with no-store cache control. */
264
312
  function sendJson(res, status, payload) {
@@ -267,29 +315,6 @@ function sendJson(res, status, payload) {
267
315
  res.setHeader('cache-control', 'no-store');
268
316
  res.end(JSON.stringify(payload));
269
317
  }
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
318
  // ── apply() ─────────────────────────────────────────────────────────────
294
319
  export function apply(ctx, config) {
295
320
  // ── Settings namespace registration ──────────────────────────────────
@@ -297,39 +322,37 @@ export function apply(ctx, config) {
297
322
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
298
323
  });
299
324
  // ── 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);
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);
305
329
  ctx.effect(() => {
306
- const collector = _memoryTrend;
307
- return () => {
308
- collector?.stop();
309
- _memoryTrend = null;
310
- };
311
- });
330
+ const owner = ctx;
331
+ return () => { stopTrend(owner); };
332
+ }, 'dsh-flash-mem-mon: memory-trend collector lifecycle');
312
333
  // ── Volatile-update handler for poll intervals ────────────────────────
313
334
  // Restart the collector when memPollBase or memPollMin change.
314
335
  ctx.on('loader/volatile-update', (paths) => {
315
336
  const memPaths = ['memPollBase', 'memPollMin'];
316
337
  if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
317
338
  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
- }
339
+ restartTrend(config);
322
340
  });
323
341
  // ── 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.
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.
326
348
  ctx.inject(['webServer'], (wsCtx) => {
349
+ const ws = wsCtx.webServer;
327
350
  // GET /plugins/dsh-flash-mem-mon/memory-trend
328
351
  // Returns memory trend data for the client's config popup.
329
352
  // `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
330
353
  // `mode=full` returns the raw samples for rendering a chart.
331
354
  // Optional `since` (unix ms) limits the range.
332
- wsCtx.effect(() => wsCtx.webServer.register({
355
+ wsCtx.effect(() => ws.register({
333
356
  kind: 'exact',
334
357
  path: '/plugins/dsh-flash-mem-mon/memory-trend',
335
358
  handler: async (req, res) => {
@@ -347,24 +370,20 @@ export function apply(ctx, config) {
347
370
  const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
348
371
  const mode = u.searchParams.get('mode') || 'summary';
349
372
  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
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
359
378
  // still available via mode=summary (majorPerMin, gcPausePerMin).
360
- const majorGcEvents = _memoryTrend.gcEvents(gcSince);
379
+ const majorGcEvents = _memoryTrend.gcEvents(since);
361
380
  sendJson(res, 200, {
362
- samples: _memoryTrend.query(since || undefined),
381
+ samples: _memoryTrend.query(since),
363
382
  gcEvents: majorGcEvents,
364
383
  });
365
384
  }
366
385
  else {
367
- sendJson(res, 200, _memoryTrend.summary(since || undefined));
386
+ sendJson(res, 200, _memoryTrend.summary(since));
368
387
  }
369
388
  },
370
389
  }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
package/dist/pure.js ADDED
@@ -0,0 +1,66 @@
1
+ // Pure, dependency-free helpers extracted from index.ts so they can be unit
2
+ // tested without pulling in cordis / node HTTP / GC-entry types. Everything
3
+ // here is a pure function of its inputs.
4
+ export const DEFAULT_ALERT_CONFIG = {
5
+ memThresholdInfo: 256,
6
+ memThresholdWarning: 512,
7
+ memThresholdError: 1024,
8
+ memThresholdCritical: 1536,
9
+ memPollBase: 30000,
10
+ memPollMin: 2000,
11
+ gcThresholdInfo: 2,
12
+ gcThresholdWarning: 5,
13
+ gcThresholdError: 10,
14
+ memGrowthAlertPerMin: 1,
15
+ };
16
+ /**
17
+ * Classify a V8 GC `kind` bitmask as a full (major) cycle.
18
+ *
19
+ * V8 GC entry `kind` is a bitmask:
20
+ * 1 << 0 = Scavenge (minor / young-gen)
21
+ * 1 << 1 = MarkSweepCompact (major / full)
22
+ * 1 << 2 = IncrementalMarking
23
+ * 1 << 3 = ProcessWeakCallbacks
24
+ *
25
+ * In practice modern V8/Node reports a full MarkSweepCompact cycle through the
26
+ * IncrementalMarking (4) and ProcessWeakCallbacks (8) phases — the standalone
27
+ * "major" bit (2) is almost never emitted on its own. Classifying Full GC by
28
+ * `kind === 2` alone therefore misses (nearly) every real full collection.
29
+ * The robust test: a Full GC is any NON-scavenge cycle, i.e. the Scavenge bit
30
+ * (1) is NOT set. This captures 2, 4, 6, 8, 10, 12, 14 while excluding minor
31
+ * (1) scavenges.
32
+ */
33
+ export function isFullGc(kind) {
34
+ return (kind & 1) === 0 && kind > 0;
35
+ }
36
+ /**
37
+ * Linear-regression slope of `rss` over the last `tailCount` samples, expressed
38
+ * in MB/min. Returns 0 when there are not enough samples (fewer than 3) or the
39
+ * tail time span is not positive.
40
+ *
41
+ * The average sample interval MUST be derived from the tail's OWN first/last
42
+ * timestamps, NOT from the whole buffer span (see index.ts summary()). Using a
43
+ * larger span inflates the interval, deflates samplesPerMin, and under-reports
44
+ * MB/min — a monotonic-leak false negative.
45
+ */
46
+ export function rssSlopePerMin(samples, tailCount = 20) {
47
+ const tail = samples.slice(-tailCount);
48
+ if (tail.length < 3)
49
+ return 0;
50
+ const n = tail.length;
51
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
52
+ for (let i = 0; i < n; i++) {
53
+ sumX += i;
54
+ sumY += tail[i].rss;
55
+ sumXY += i * tail[i].rss;
56
+ sumXX += i * i;
57
+ }
58
+ // slope = bytes per sample-index step.
59
+ const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
60
+ const tailSpanMs = Math.max(tail[tail.length - 1].ts - tail[0].ts, 0);
61
+ if (tailSpanMs <= 0)
62
+ return 0;
63
+ const avgIntervalMs = tailSpanMs / (n - 1);
64
+ const samplesPerMinute = 60000 / avgIntervalMs;
65
+ return Math.round((slope * samplesPerMinute / 1048576) * 100) / 100;
66
+ }