@platformatic/wattpm-pprof-capture 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +204 -42
- package/lib/errors.js +6 -0
- package/package.json +4 -3
- package/test/fixtures/runtime-test/service/plugin.js +38 -0
- package/test/watt-pprof-capture.test.js +333 -2
package/index.js
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
1
|
import { time, heap } from '@datadog/pprof'
|
|
2
|
-
import {
|
|
2
|
+
import { performance } from 'node:perf_hooks'
|
|
3
|
+
import { NoProfileAvailableError, NotEnoughELUError, ProfilingAlreadyStartedError, ProfilingNotStartedError } from './lib/errors.js'
|
|
3
4
|
|
|
4
5
|
const kITC = Symbol.for('plt.runtime.itc')
|
|
5
6
|
|
|
7
|
+
// Track ELU globally (shared across all profiler types)
|
|
8
|
+
let lastELU = null
|
|
9
|
+
let previousELU = performance.eventLoopUtilization()
|
|
10
|
+
|
|
11
|
+
// Start continuous ELU tracking immediately
|
|
12
|
+
const eluUpdateInterval = setInterval(() => {
|
|
13
|
+
lastELU = performance.eventLoopUtilization(previousELU)
|
|
14
|
+
previousELU = performance.eventLoopUtilization()
|
|
15
|
+
|
|
16
|
+
for (const type of ['cpu', 'heap']) {
|
|
17
|
+
const state = profilingState[type]
|
|
18
|
+
startIfOverThreshold(type, state)
|
|
19
|
+
}
|
|
20
|
+
}, 1000)
|
|
21
|
+
eluUpdateInterval.unref()
|
|
22
|
+
|
|
6
23
|
// Track profiling state separately for each type
|
|
7
24
|
const profilingState = {
|
|
8
25
|
cpu: {
|
|
9
26
|
isCapturing: false,
|
|
10
27
|
latestProfile: null,
|
|
11
|
-
captureInterval: null
|
|
28
|
+
captureInterval: null,
|
|
29
|
+
durationMillis: null,
|
|
30
|
+
eluThreshold: null,
|
|
31
|
+
options: null,
|
|
32
|
+
profilerStarted: false,
|
|
33
|
+
clearProfileTimeout: null
|
|
12
34
|
},
|
|
13
35
|
heap: {
|
|
14
36
|
isCapturing: false,
|
|
15
37
|
latestProfile: null,
|
|
16
|
-
captureInterval: null
|
|
38
|
+
captureInterval: null,
|
|
39
|
+
durationMillis: null,
|
|
40
|
+
eluThreshold: null,
|
|
41
|
+
options: null,
|
|
42
|
+
profilerStarted: false,
|
|
43
|
+
clearProfileTimeout: null
|
|
17
44
|
}
|
|
18
45
|
}
|
|
19
46
|
|
|
@@ -25,6 +52,7 @@ const registerInterval = setInterval(() => {
|
|
|
25
52
|
globalThis[kITC].handle('getLastProfile', getLastProfile)
|
|
26
53
|
globalThis[kITC].handle('startProfiling', startProfiling)
|
|
27
54
|
globalThis[kITC].handle('stopProfiling', stopProfiling)
|
|
55
|
+
globalThis[kITC].handle('getProfilingState', getProfilingState)
|
|
28
56
|
clearInterval(registerInterval)
|
|
29
57
|
}
|
|
30
58
|
}, 10)
|
|
@@ -33,31 +61,52 @@ function getProfiler (type) {
|
|
|
33
61
|
return type === 'heap' ? heap : time
|
|
34
62
|
}
|
|
35
63
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
const state = profilingState[type]
|
|
64
|
+
function scheduleLastProfileCleanup (state) {
|
|
65
|
+
unscheduleLastProfileCleanup(state)
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
state.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
state.
|
|
67
|
+
// Set up timer to clear profile after rotation duration
|
|
68
|
+
if (state.options?.durationMillis) {
|
|
69
|
+
state.clearProfileTimeout = setTimeout(() => {
|
|
70
|
+
state.latestProfile = undefined
|
|
71
|
+
state.clearProfileTimeout = null
|
|
72
|
+
}, state.options.durationMillis)
|
|
73
|
+
state.clearProfileTimeout.unref()
|
|
46
74
|
}
|
|
47
75
|
}
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
function unscheduleLastProfileCleanup (state) {
|
|
78
|
+
// Clear any pending profile clear timeout
|
|
79
|
+
if (state.clearProfileTimeout) {
|
|
80
|
+
clearTimeout(state.clearProfileTimeout)
|
|
81
|
+
state.clearProfileTimeout = null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
52
84
|
|
|
53
|
-
|
|
54
|
-
|
|
85
|
+
function scheduleProfileRotation (type, state, options) {
|
|
86
|
+
unscheduleProfileRotation(state)
|
|
87
|
+
|
|
88
|
+
// Set up profile window rotation if durationMillis is provided
|
|
89
|
+
if (options.durationMillis) {
|
|
90
|
+
state.captureInterval = setInterval(() => rotateProfile(type), options.durationMillis)
|
|
91
|
+
state.captureInterval.unref()
|
|
55
92
|
}
|
|
56
|
-
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unscheduleProfileRotation (state) {
|
|
96
|
+
if (!state.captureInterval) return
|
|
97
|
+
clearInterval(state.captureInterval)
|
|
98
|
+
state.captureInterval = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function startProfiler (type, state, options) {
|
|
102
|
+
if (state.profilerStarted) return
|
|
57
103
|
|
|
58
104
|
const profiler = getProfiler(type)
|
|
59
105
|
|
|
60
|
-
|
|
106
|
+
unscheduleLastProfileCleanup(state)
|
|
107
|
+
scheduleProfileRotation(type, state, options)
|
|
108
|
+
state.profilerStarted = true
|
|
109
|
+
|
|
61
110
|
if (type === 'heap') {
|
|
62
111
|
// Heap profiler takes intervalBytes and stackDepth as positional arguments
|
|
63
112
|
// Default: 512KB interval, 64 stack depth
|
|
@@ -66,17 +115,109 @@ export function startProfiling (options = {}) {
|
|
|
66
115
|
profiler.start(intervalBytes, stackDepth)
|
|
67
116
|
} else {
|
|
68
117
|
// CPU time profiler takes options object
|
|
118
|
+
options.intervalMicros ??= 33333
|
|
69
119
|
profiler.start(options)
|
|
70
120
|
}
|
|
121
|
+
}
|
|
71
122
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
123
|
+
function stopProfiler (type, state) {
|
|
124
|
+
if (!state.profilerStarted) return
|
|
125
|
+
|
|
126
|
+
const profiler = getProfiler(type)
|
|
127
|
+
|
|
128
|
+
scheduleLastProfileCleanup(state)
|
|
129
|
+
unscheduleProfileRotation(state)
|
|
130
|
+
state.profilerStarted = false
|
|
131
|
+
|
|
132
|
+
if (type === 'heap') {
|
|
133
|
+
// Get the profile before stopping
|
|
134
|
+
state.latestProfile = profiler.profile()
|
|
135
|
+
profiler.stop()
|
|
136
|
+
} else {
|
|
137
|
+
// CPU time profiler returns the profile when stopping
|
|
138
|
+
state.latestProfile = profiler.stop()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isAboveThreshold (state) {
|
|
143
|
+
return lastELU != null && lastELU.utilization > state.eluThreshold
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isBelowStopThreshold (state) {
|
|
147
|
+
// Use hysteresis: stop at threshold - 0.1 to prevent rapid toggling
|
|
148
|
+
const stopThreshold = state.eluThreshold - 0.1
|
|
149
|
+
return lastELU != null && lastELU.utilization < stopThreshold
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function rotateProfile (type) {
|
|
153
|
+
const state = profilingState[type]
|
|
154
|
+
const wasRunning = state.profilerStarted
|
|
155
|
+
|
|
156
|
+
stopProfiler(type, state)
|
|
157
|
+
maybeStartProfiler(type, state, wasRunning)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function maybeStartProfiler (type, state, wasRunning) {
|
|
161
|
+
// Check if we should start profiling based on current ELU (updated by global interval)
|
|
162
|
+
if (state.eluThreshold != null) {
|
|
163
|
+
startIfOverThreshold(type, state, wasRunning)
|
|
164
|
+
} else if (state.isCapturing) {
|
|
165
|
+
// No threshold, always start profiling
|
|
166
|
+
startProfiler(type, state, state.options)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function startIfOverThreshold (type, state, wasRunning = state.profilerStarted) {
|
|
171
|
+
// Only check if profiling is active and has an ELU threshold
|
|
172
|
+
if (!state.isCapturing || state.eluThreshold == null) {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const currentELU = lastELU?.utilization
|
|
177
|
+
|
|
178
|
+
// Hysteresis logic:
|
|
179
|
+
// - Start if ELU > threshold
|
|
180
|
+
// - Stop if ELU < threshold - 0.1
|
|
181
|
+
// - Between thresholds: maintain current state
|
|
182
|
+
const shouldRun = wasRunning
|
|
183
|
+
// Was running: only stop if ELU drops below stop threshold
|
|
184
|
+
? !isBelowStopThreshold(state)
|
|
185
|
+
// Was not running: only start if ELU rises above start threshold
|
|
186
|
+
: isAboveThreshold(state)
|
|
187
|
+
|
|
188
|
+
if (shouldRun) {
|
|
189
|
+
// ELU is high enough, start/restart profiling
|
|
190
|
+
if (!wasRunning && !state.profilerStarted && globalThis.platformatic?.logger) {
|
|
191
|
+
globalThis.platformatic.logger.debug(
|
|
192
|
+
{ type, eluThreshold: state.eluThreshold, currentELU },
|
|
193
|
+
'Starting profiler due to ELU threshold exceeded'
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
startProfiler(type, state, state.options)
|
|
197
|
+
} else if (!shouldRun && wasRunning && globalThis.platformatic?.logger && state.eluThreshold != null) {
|
|
198
|
+
// Log when deciding not to restart after stopping (only in rotation context)
|
|
199
|
+
globalThis.platformatic.logger.debug(
|
|
200
|
+
{ type, eluThreshold: state.eluThreshold, currentELU },
|
|
201
|
+
'Pausing profiler due to ELU below threshold'
|
|
202
|
+
)
|
|
77
203
|
}
|
|
78
204
|
}
|
|
79
205
|
|
|
206
|
+
export function startProfiling (options = {}) {
|
|
207
|
+
const type = options.type || 'cpu'
|
|
208
|
+
const state = profilingState[type]
|
|
209
|
+
|
|
210
|
+
if (state.isCapturing) {
|
|
211
|
+
throw new ProfilingAlreadyStartedError()
|
|
212
|
+
}
|
|
213
|
+
state.isCapturing = true
|
|
214
|
+
state.options = options
|
|
215
|
+
state.eluThreshold = options.eluThreshold
|
|
216
|
+
state.durationMillis = options.durationMillis
|
|
217
|
+
|
|
218
|
+
maybeStartProfiler(type, state)
|
|
219
|
+
}
|
|
220
|
+
|
|
80
221
|
export function stopProfiling (options = {}) {
|
|
81
222
|
const type = options.type || 'cpu'
|
|
82
223
|
const state = profilingState[type]
|
|
@@ -86,22 +227,20 @@ export function stopProfiling (options = {}) {
|
|
|
86
227
|
}
|
|
87
228
|
state.isCapturing = false
|
|
88
229
|
|
|
89
|
-
|
|
90
|
-
state.captureInterval = null
|
|
230
|
+
stopProfiler(type, state)
|
|
91
231
|
|
|
92
|
-
|
|
232
|
+
// Clean up state
|
|
233
|
+
state.eluThreshold = null
|
|
234
|
+
state.durationMillis = null
|
|
235
|
+
state.options = null
|
|
93
236
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
state.latestProfile
|
|
98
|
-
profiler.stop()
|
|
237
|
+
// Return the latest profile if available, otherwise return an empty profile
|
|
238
|
+
// (e.g., when profiler never started due to ELU threshold not being exceeded)
|
|
239
|
+
if (state.latestProfile) {
|
|
240
|
+
return state.latestProfile.encode()
|
|
99
241
|
} else {
|
|
100
|
-
|
|
101
|
-
state.latestProfile = profiler.stop()
|
|
242
|
+
return new Uint8Array(0)
|
|
102
243
|
}
|
|
103
|
-
|
|
104
|
-
return state.latestProfile.encode()
|
|
105
244
|
}
|
|
106
245
|
|
|
107
246
|
export function getLastProfile (options = {}) {
|
|
@@ -113,17 +252,40 @@ export function getLastProfile (options = {}) {
|
|
|
113
252
|
throw new ProfilingNotStartedError()
|
|
114
253
|
}
|
|
115
254
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// For heap profiler, always get the current profile
|
|
255
|
+
// For heap profiler, always get the current profile (if actually profiling)
|
|
119
256
|
// For CPU profiler, use the cached profile if available
|
|
120
|
-
if (type === 'heap') {
|
|
257
|
+
if (type === 'heap' && state.profilerStarted) {
|
|
258
|
+
const profiler = getProfiler(type)
|
|
121
259
|
state.latestProfile = profiler.profile()
|
|
122
|
-
}
|
|
123
|
-
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check if we have a profile
|
|
263
|
+
if (state.latestProfile == null) {
|
|
264
|
+
// No profile available
|
|
265
|
+
if (state.profilerStarted) {
|
|
266
|
+
// Profiler is running but no profile yet (waiting for first rotation)
|
|
267
|
+
throw new NoProfileAvailableError()
|
|
268
|
+
} else {
|
|
269
|
+
// Profiler is not running (paused due to low ELU or never started)
|
|
270
|
+
throw new NotEnoughELUError()
|
|
271
|
+
}
|
|
124
272
|
}
|
|
125
273
|
|
|
126
274
|
return state.latestProfile.encode()
|
|
127
275
|
}
|
|
128
276
|
|
|
277
|
+
export function getProfilingState (options = {}) {
|
|
278
|
+
const type = options.type || 'cpu'
|
|
279
|
+
const state = profilingState[type]
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
isCapturing: state.isCapturing,
|
|
283
|
+
hasProfile: state.latestProfile != null,
|
|
284
|
+
isProfilerRunning: state.profilerStarted,
|
|
285
|
+
isPausedBelowThreshold: state.eluThreshold != null && !state.profilerStarted,
|
|
286
|
+
lastELU: lastELU?.utilization,
|
|
287
|
+
eluThreshold: state.eluThreshold
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
129
291
|
export * as errors from './lib/errors.js'
|
package/lib/errors.js
CHANGED
|
@@ -19,3 +19,9 @@ export const NoProfileAvailableError = createError(
|
|
|
19
19
|
'No profile available - wait for profiling to complete or trigger manual capture',
|
|
20
20
|
400
|
|
21
21
|
)
|
|
22
|
+
|
|
23
|
+
export const NotEnoughELUError = createError(
|
|
24
|
+
`${ERROR_PREFIX}_NOT_ENOUGH_ELU`,
|
|
25
|
+
'No profile available - event loop utilization has been below threshold for too long',
|
|
26
|
+
400
|
|
27
|
+
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/wattpm-pprof-capture",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "pprof profiling capture for wattpm",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
"undici": "^6.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
+
"atomic-sleep": "^1.0.0",
|
|
23
24
|
"eslint": "9",
|
|
24
25
|
"neostandard": "^0.12.0",
|
|
25
|
-
"@platformatic/
|
|
26
|
-
"@platformatic/
|
|
26
|
+
"@platformatic/service": "3.12.0",
|
|
27
|
+
"@platformatic/foundation": "3.12.0"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=22.19.0"
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const atomicSleep = require('atomic-sleep')
|
|
4
|
+
|
|
5
|
+
function sleep (ms) {
|
|
6
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
module.exports = async function (app) {
|
|
10
|
+
let cpuIntensiveInterval = null
|
|
11
|
+
|
|
4
12
|
// Simple hello world service
|
|
5
13
|
app.get('/', async () => {
|
|
6
14
|
return { message: 'Hello World' }
|
|
@@ -10,4 +18,34 @@ module.exports = async function (app) {
|
|
|
10
18
|
app.get('/health', async () => {
|
|
11
19
|
return { status: 'ok' }
|
|
12
20
|
})
|
|
21
|
+
|
|
22
|
+
// CPU intensive endpoint to simulate high ELU
|
|
23
|
+
app.post('/cpu-intensive/start', async () => {
|
|
24
|
+
if (cpuIntensiveInterval) {
|
|
25
|
+
return { message: 'Already running' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
cpuIntensiveInterval = setInterval(async () => {
|
|
29
|
+
atomicSleep(900) // Blocks the event loop for 900ms
|
|
30
|
+
await sleep(100) // Allows async operations
|
|
31
|
+
}, 1000)
|
|
32
|
+
|
|
33
|
+
return { message: 'CPU intensive task started' }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
app.post('/cpu-intensive/stop', async () => {
|
|
37
|
+
if (cpuIntensiveInterval) {
|
|
38
|
+
clearInterval(cpuIntensiveInterval)
|
|
39
|
+
cpuIntensiveInterval = null
|
|
40
|
+
return { message: 'CPU intensive task stopped' }
|
|
41
|
+
}
|
|
42
|
+
return { message: 'Not running' }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Clean up on close
|
|
46
|
+
app.addHook('onClose', async () => {
|
|
47
|
+
if (cpuIntensiveInterval) {
|
|
48
|
+
clearInterval(cpuIntensiveInterval)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
13
51
|
}
|
|
@@ -19,6 +19,27 @@ async function createApp (t) {
|
|
|
19
19
|
return { app, url }
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Helper to wait for a condition to be true
|
|
23
|
+
async function waitForCondition (checkFn, timeoutMs = 5000, pollMs = 100) {
|
|
24
|
+
const startTime = Date.now()
|
|
25
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
26
|
+
if (await checkFn()) {
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
30
|
+
}
|
|
31
|
+
throw new Error('Timeout waiting for condition')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Helper to compare Uint8Arrays
|
|
35
|
+
function arraysEqual (a, b) {
|
|
36
|
+
if (a.length !== b.length) return false
|
|
37
|
+
for (let i = 0; i < a.length; i++) {
|
|
38
|
+
if (a[i] !== b[i]) return false
|
|
39
|
+
}
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
test('basic service functionality should work with wattpm-pprof-capture', async t => {
|
|
23
44
|
const { url } = await createApp(t)
|
|
24
45
|
|
|
@@ -48,7 +69,7 @@ test('getLastProfile should throw error when profiling not started', async t =>
|
|
|
48
69
|
})
|
|
49
70
|
|
|
50
71
|
test('error types should be distinguishable throughout lifecycle', async t => {
|
|
51
|
-
const { app } = await createApp(t)
|
|
72
|
+
const { app, url } = await createApp(t)
|
|
52
73
|
|
|
53
74
|
// Test ProfilingNotStartedError before starting
|
|
54
75
|
await assert.rejects(
|
|
@@ -57,9 +78,37 @@ test('error types should be distinguishable throughout lifecycle', async t => {
|
|
|
57
78
|
'Should throw ProfilingNotStartedError (not NoProfileAvailableError)'
|
|
58
79
|
)
|
|
59
80
|
|
|
60
|
-
//
|
|
81
|
+
// Test NotEnoughELUError when profiling with high threshold (no CPU load)
|
|
82
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 2.0, durationMillis: 200 })
|
|
83
|
+
|
|
84
|
+
// Wait for profiler to be paused below threshold
|
|
85
|
+
await waitForCondition(async () => {
|
|
86
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
87
|
+
return state.isPausedBelowThreshold && !state.isProfilerRunning
|
|
88
|
+
}, 2000)
|
|
89
|
+
|
|
90
|
+
// getLastProfile should throw NotEnoughELUError
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
() => app.sendCommandToApplication('service', 'getLastProfile'),
|
|
93
|
+
{ code: 'PLT_PPROF_NOT_ENOUGH_ELU' },
|
|
94
|
+
'Should throw NotEnoughELUError when ELU threshold not exceeded'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// Stop profiling
|
|
98
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
99
|
+
|
|
100
|
+
// Start CPU intensive task to generate load for profiler
|
|
101
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
102
|
+
|
|
103
|
+
// Start profiling with profile rotation (no threshold)
|
|
61
104
|
await app.sendCommandToApplication('service', 'startProfiling', { durationMillis: 500 })
|
|
62
105
|
|
|
106
|
+
// Wait for profiler to actually start running
|
|
107
|
+
await waitForCondition(async () => {
|
|
108
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
109
|
+
return state.isProfilerRunning && !state.hasProfile
|
|
110
|
+
}, 1000)
|
|
111
|
+
|
|
63
112
|
// Test NoProfileAvailableError (before any rotation happens)
|
|
64
113
|
await assert.rejects(
|
|
65
114
|
() => app.sendCommandToApplication('service', 'getLastProfile'),
|
|
@@ -80,6 +129,9 @@ test('error types should be distinguishable throughout lifecycle', async t => {
|
|
|
80
129
|
assert.ok(stopResult instanceof Uint8Array, 'stopProfiling should return final profile')
|
|
81
130
|
assert.ok(stopResult.length > 0, 'Final profile should have content')
|
|
82
131
|
|
|
132
|
+
// Stop CPU intensive task
|
|
133
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
134
|
+
|
|
83
135
|
// Test ProfilingNotStartedError after stopping
|
|
84
136
|
await assert.rejects(
|
|
85
137
|
() => app.sendCommandToApplication('service', 'getLastProfile'),
|
|
@@ -316,3 +368,282 @@ test('CPU and heap profiling are independent', async t => {
|
|
|
316
368
|
const heapProfile = await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
|
|
317
369
|
assert.ok(heapProfile instanceof Uint8Array, 'Heap profile should work')
|
|
318
370
|
})
|
|
371
|
+
|
|
372
|
+
test('profiling with eluThreshold should not start when below threshold', async t => {
|
|
373
|
+
const { app } = await createApp(t)
|
|
374
|
+
|
|
375
|
+
// Start profiling with a very high threshold (above 1.0, which is impossible)
|
|
376
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 2.0, durationMillis: 200 })
|
|
377
|
+
|
|
378
|
+
// Wait a bit - profiler should not have started
|
|
379
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
380
|
+
|
|
381
|
+
// getLastProfile should throw NotEnoughELUError since ELU threshold not exceeded
|
|
382
|
+
await assert.rejects(
|
|
383
|
+
() => app.sendCommandToApplication('service', 'getLastProfile'),
|
|
384
|
+
{ code: 'PLT_PPROF_NOT_ENOUGH_ELU' },
|
|
385
|
+
'Should throw NotEnoughELUError when ELU threshold not exceeded'
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// Stop profiling
|
|
389
|
+
const profile = await app.sendCommandToApplication('service', 'stopProfiling')
|
|
390
|
+
assert.ok(profile instanceof Uint8Array, 'Should return Uint8Array')
|
|
391
|
+
// Profile will be empty or have minimal content since profiler never actually ran
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('profiling with eluThreshold should start when utilization exceeds threshold', async t => {
|
|
395
|
+
const { app, url } = await createApp(t)
|
|
396
|
+
|
|
397
|
+
// Start profiling with a low threshold
|
|
398
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5, durationMillis: 500 })
|
|
399
|
+
|
|
400
|
+
// Start CPU intensive task to increase ELU
|
|
401
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
402
|
+
|
|
403
|
+
// Wait for profiler to actually start running
|
|
404
|
+
await waitForCondition(async () => {
|
|
405
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
406
|
+
return state.isProfilerRunning
|
|
407
|
+
}, 3000)
|
|
408
|
+
|
|
409
|
+
// Wait for a profile to be captured
|
|
410
|
+
await waitForCondition(async () => {
|
|
411
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
412
|
+
return state.hasProfile
|
|
413
|
+
}, 2000)
|
|
414
|
+
|
|
415
|
+
// Profile should be available now
|
|
416
|
+
const profile = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
417
|
+
assert.ok(profile instanceof Uint8Array, 'Should get profile after threshold exceeded')
|
|
418
|
+
assert.ok(profile.length > 0, 'Profile should have content')
|
|
419
|
+
|
|
420
|
+
// Stop CPU intensive task
|
|
421
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
422
|
+
|
|
423
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('profiling with eluThreshold should work with heap profiling', async t => {
|
|
427
|
+
const { app } = await createApp(t)
|
|
428
|
+
|
|
429
|
+
// Start heap profiling with a very high threshold
|
|
430
|
+
await app.sendCommandToApplication('service', 'startProfiling', { type: 'heap', eluThreshold: 2.0 })
|
|
431
|
+
|
|
432
|
+
// Wait a bit - profiler should not have started due to low ELU
|
|
433
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
434
|
+
|
|
435
|
+
// getLastProfile should throw NotEnoughELUError since ELU threshold not exceeded
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
() => app.sendCommandToApplication('service', 'getLastProfile', { type: 'heap' }),
|
|
438
|
+
{ code: 'PLT_PPROF_NOT_ENOUGH_ELU' },
|
|
439
|
+
'Should throw NotEnoughELUError when heap profiler ELU threshold not exceeded'
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
// Stop profiling
|
|
443
|
+
const profile = await app.sendCommandToApplication('service', 'stopProfiling', { type: 'heap' })
|
|
444
|
+
assert.ok(profile instanceof Uint8Array, 'Should return Uint8Array')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('eluThreshold profiling should allow double start error', async t => {
|
|
448
|
+
const { app } = await createApp(t)
|
|
449
|
+
|
|
450
|
+
// Start profiling with eluThreshold
|
|
451
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5 })
|
|
452
|
+
|
|
453
|
+
// Try to start again - should throw ProfilingAlreadyStartedError
|
|
454
|
+
await assert.rejects(
|
|
455
|
+
() => app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5 }),
|
|
456
|
+
{ code: 'PLT_PPROF_PROFILING_ALREADY_STARTED' },
|
|
457
|
+
'Should throw ProfilingAlreadyStartedError'
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('profiling with eluThreshold should not start when always below threshold', async t => {
|
|
464
|
+
const { app } = await createApp(t)
|
|
465
|
+
|
|
466
|
+
// Start profiling with very high threshold (impossible to reach without CPU task)
|
|
467
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 1.5, durationMillis: 300 })
|
|
468
|
+
|
|
469
|
+
// Wait for state to show we're paused below threshold
|
|
470
|
+
await waitForCondition(async () => {
|
|
471
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
472
|
+
return state.isPausedBelowThreshold && !state.isProfilerRunning
|
|
473
|
+
}, 2000)
|
|
474
|
+
|
|
475
|
+
// Stop profiling - should return empty profile since profiler never started
|
|
476
|
+
const profileBeforeThreshold = await app.sendCommandToApplication('service', 'stopProfiling')
|
|
477
|
+
assert.ok(profileBeforeThreshold instanceof Uint8Array, 'Should return Uint8Array')
|
|
478
|
+
assert.ok(profileBeforeThreshold.length === 0, 'Profile should be empty since profiler never started')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('profiling with eluThreshold should start when threshold is reached', async t => {
|
|
482
|
+
const { app, url } = await createApp(t)
|
|
483
|
+
|
|
484
|
+
// Start profiling while ELU is low
|
|
485
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5, durationMillis: 300 })
|
|
486
|
+
|
|
487
|
+
// Start CPU intensive task to raise ELU above threshold
|
|
488
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
489
|
+
|
|
490
|
+
// Wait for profiler to actually start running
|
|
491
|
+
await waitForCondition(async () => {
|
|
492
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
493
|
+
return state.isProfilerRunning
|
|
494
|
+
}, 3000)
|
|
495
|
+
|
|
496
|
+
// Wait for a profile to be captured
|
|
497
|
+
await waitForCondition(async () => {
|
|
498
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
499
|
+
return state.hasProfile
|
|
500
|
+
}, 2000)
|
|
501
|
+
|
|
502
|
+
// Now profile should be available
|
|
503
|
+
const profileAfterThreshold = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
504
|
+
assert.ok(profileAfterThreshold instanceof Uint8Array, 'Should get profile after threshold exceeded')
|
|
505
|
+
assert.ok(profileAfterThreshold.length > 0, 'Profile should have content after threshold exceeded')
|
|
506
|
+
|
|
507
|
+
// Clean up
|
|
508
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
509
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('profiling with eluThreshold should pause during rotation when below threshold', async t => {
|
|
513
|
+
const { app, url } = await createApp(t)
|
|
514
|
+
|
|
515
|
+
// Start CPU intensive task first
|
|
516
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
517
|
+
|
|
518
|
+
// Wait for ELU to rise above threshold
|
|
519
|
+
await waitForCondition(async () => {
|
|
520
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
521
|
+
return state.lastELU != null && state.lastELU > 0.5
|
|
522
|
+
}, 3000)
|
|
523
|
+
|
|
524
|
+
// Start profiling with threshold and rotation interval
|
|
525
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5, durationMillis: 500 })
|
|
526
|
+
|
|
527
|
+
// Wait for profiler to start
|
|
528
|
+
await waitForCondition(async () => {
|
|
529
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
530
|
+
return state.isProfilerRunning
|
|
531
|
+
}, 2000)
|
|
532
|
+
|
|
533
|
+
// Wait for a profile to be captured
|
|
534
|
+
await waitForCondition(async () => {
|
|
535
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
536
|
+
return state.hasProfile
|
|
537
|
+
}, 2000)
|
|
538
|
+
|
|
539
|
+
// Get first profile - should have content
|
|
540
|
+
const profile1 = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
541
|
+
assert.ok(profile1 instanceof Uint8Array, 'First profile should be available')
|
|
542
|
+
assert.ok(profile1.length > 0, 'First profile should have content')
|
|
543
|
+
|
|
544
|
+
// Stop CPU intensive task - ELU should drop below stop threshold (0.4)
|
|
545
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
546
|
+
|
|
547
|
+
// Wait for profiler to detect low ELU and pause
|
|
548
|
+
// This waits for both ELU to drop AND for the next rotation to detect it
|
|
549
|
+
// ELU drops slowly as the rolling average window moves past the high-ELU period
|
|
550
|
+
await waitForCondition(async () => {
|
|
551
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
552
|
+
return !state.isProfilerRunning && state.isPausedBelowThreshold
|
|
553
|
+
}, 15000)
|
|
554
|
+
|
|
555
|
+
// Verify profiler has paused
|
|
556
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
557
|
+
assert.ok(!state.isProfilerRunning, 'Profiler should have stopped running')
|
|
558
|
+
assert.ok(state.isPausedBelowThreshold, 'Should be paused below threshold')
|
|
559
|
+
|
|
560
|
+
// Clean up
|
|
561
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('profiling with eluThreshold should start immediately when already above threshold', async t => {
|
|
565
|
+
const { app, url } = await createApp(t)
|
|
566
|
+
|
|
567
|
+
// Start CPU intensive task BEFORE starting profiling
|
|
568
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
569
|
+
|
|
570
|
+
// Wait for ELU to actually rise above threshold
|
|
571
|
+
await waitForCondition(async () => {
|
|
572
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
573
|
+
return state.lastELU != null && state.lastELU > 0.5
|
|
574
|
+
}, 5000)
|
|
575
|
+
|
|
576
|
+
// Now start profiling - should start immediately since ELU is already high
|
|
577
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5, durationMillis: 300 })
|
|
578
|
+
|
|
579
|
+
// Profiler should start immediately without being paused
|
|
580
|
+
const stateAfterStart = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
581
|
+
assert.ok(stateAfterStart.isProfilerRunning, 'Profiler should start immediately when ELU is already high')
|
|
582
|
+
assert.ok(!stateAfterStart.isPausedBelowThreshold, 'Should not be paused below threshold')
|
|
583
|
+
|
|
584
|
+
// Wait for a profile to be captured
|
|
585
|
+
await waitForCondition(async () => {
|
|
586
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
587
|
+
return state.hasProfile
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
// Profile should be available after first rotation
|
|
591
|
+
const profile = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
592
|
+
assert.ok(profile instanceof Uint8Array, 'Profile should be available after rotation')
|
|
593
|
+
assert.ok(profile.length > 0, 'Profile should have content')
|
|
594
|
+
|
|
595
|
+
// Clean up
|
|
596
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
597
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('profiling with eluThreshold should continue rotating while above threshold', async t => {
|
|
601
|
+
const { app, url } = await createApp(t)
|
|
602
|
+
|
|
603
|
+
// Start CPU intensive task
|
|
604
|
+
await request(`${url}/cpu-intensive/start`, { method: 'POST' })
|
|
605
|
+
|
|
606
|
+
// Wait for ELU to rise above threshold
|
|
607
|
+
await waitForCondition(async () => {
|
|
608
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
609
|
+
return state.lastELU != null && state.lastELU > 0.5
|
|
610
|
+
}, 3000)
|
|
611
|
+
|
|
612
|
+
// Start profiling with rotation interval
|
|
613
|
+
await app.sendCommandToApplication('service', 'startProfiling', { eluThreshold: 0.5, durationMillis: 400 })
|
|
614
|
+
|
|
615
|
+
// Wait for profiler to start
|
|
616
|
+
await waitForCondition(async () => {
|
|
617
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
618
|
+
return state.isProfilerRunning
|
|
619
|
+
}, 2000)
|
|
620
|
+
|
|
621
|
+
// Wait for first profile
|
|
622
|
+
await waitForCondition(async () => {
|
|
623
|
+
const state = await app.sendCommandToApplication('service', 'getProfilingState')
|
|
624
|
+
return state.hasProfile
|
|
625
|
+
}, 2000)
|
|
626
|
+
|
|
627
|
+
// Get first profile
|
|
628
|
+
const profile1 = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
629
|
+
assert.ok(profile1 instanceof Uint8Array, 'First profile should be available')
|
|
630
|
+
assert.ok(profile1.length > 0, 'First profile should have content')
|
|
631
|
+
|
|
632
|
+
// Wait for second rotation to capture a new profile
|
|
633
|
+
await waitForCondition(async () => {
|
|
634
|
+
const profile = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
635
|
+
return !arraysEqual(profile, profile1)
|
|
636
|
+
}, 2000)
|
|
637
|
+
|
|
638
|
+
// Get second profile
|
|
639
|
+
const profile2 = await app.sendCommandToApplication('service', 'getLastProfile')
|
|
640
|
+
assert.ok(profile2 instanceof Uint8Array, 'Second profile should be available')
|
|
641
|
+
assert.ok(profile2.length > 0, 'Second profile should have content')
|
|
642
|
+
|
|
643
|
+
// Profiles should be different (captured at different times)
|
|
644
|
+
assert.ok(!arraysEqual(profile1, profile2), 'Profiles should be different after rotation')
|
|
645
|
+
|
|
646
|
+
// Clean up
|
|
647
|
+
await request(`${url}/cpu-intensive/stop`, { method: 'POST' })
|
|
648
|
+
await app.sendCommandToApplication('service', 'stopProfiling')
|
|
649
|
+
})
|