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 +39 -38
- package/README.zh-CN.md +38 -38
- package/cordis.patch.yml +11 -11
- package/dist/index.js +132 -113
- package/dist/pure.js +66 -0
- package/lib/client.js +2433 -2071
- package/package.json +23 -14
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
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
|
|
147
|
-
// RSS linear
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (let i = 0; i < n; i++) {
|
|
153
|
-
sumX += i;
|
|
154
|
-
sumY += tail[i].rss;
|
|
155
|
-
sumXY += i * tail[i].rss;
|
|
156
|
-
sumXX += i * i;
|
|
157
|
-
}
|
|
158
|
-
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
159
|
-
// slope = bytes per sample-index step. Convert to MB/min.
|
|
160
|
-
// Average interval between tail samples ≈ spanSeconds / (n-1).
|
|
161
|
-
const avgIntervalSec = tail.length >= 2 ? spanSeconds / (tail.length - 1) : 30;
|
|
162
|
-
const samplesPerMin = avgIntervalSec > 0 ? 60 / avgIntervalSec : 2;
|
|
163
|
-
rssSlopePerMin = Math.round((slope * samplesPerMin / 1048576) * 100) / 100;
|
|
164
|
-
}
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
307
|
-
return () => {
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
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(() =>
|
|
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
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
const gcSince = since > 0 ? since : 0;
|
|
356
|
-
// Only return full (major) GC events to the client — minor GC is too
|
|
357
|
-
// frequent and clutters the chart. These are already coalesced into
|
|
358
|
-
// one event per real cycle by `_startGcObserver`. Minor GC stats are
|
|
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(
|
|
379
|
+
const majorGcEvents = _memoryTrend.gcEvents(since);
|
|
361
380
|
sendJson(res, 200, {
|
|
362
|
-
samples: _memoryTrend.query(since
|
|
381
|
+
samples: _memoryTrend.query(since),
|
|
363
382
|
gcEvents: majorGcEvents,
|
|
364
383
|
});
|
|
365
384
|
}
|
|
366
385
|
else {
|
|
367
|
-
sendJson(res, 200, _memoryTrend.summary(since
|
|
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
|
+
}
|