@platformatic/wattpm-pprof-capture 3.11.0 → 3.13.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 CHANGED
@@ -1,19 +1,46 @@
1
1
  import { time, heap } from '@datadog/pprof'
2
- import { NoProfileAvailableError, ProfilingAlreadyStartedError, ProfilingNotStartedError } from './lib/errors.js'
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 rotateProfile (type) {
37
- const profiler = getProfiler(type)
38
- const state = profilingState[type]
64
+ function scheduleLastProfileCleanup (state) {
65
+ unscheduleLastProfileCleanup(state)
39
66
 
40
- if (type === 'heap') {
41
- // Heap profiler needs to call profile() to get the current profile
42
- state.latestProfile = profiler.profile()
43
- } else {
44
- // CPU time profiler: `true` immediately restarts profiling after stopping
45
- state.latestProfile = profiler.stop(true)
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
- export function startProfiling (options = {}) {
50
- const type = options.type || 'cpu'
51
- const state = profilingState[type]
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
- if (state.isCapturing) {
54
- throw new ProfilingAlreadyStartedError()
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
- state.isCapturing = true
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
- // Heap profiler has different API than time profiler
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
- // Set up profile window rotation if durationMillis is provided
73
- const timeout = options.durationMillis
74
- if (timeout) {
75
- state.captureInterval = setInterval(() => rotateProfile(type), timeout)
76
- state.captureInterval.unref()
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
- clearInterval(state.captureInterval)
90
- state.captureInterval = null
230
+ stopProfiler(type, state)
91
231
 
92
- const profiler = getProfiler(type)
232
+ // Clean up state
233
+ state.eluThreshold = null
234
+ state.durationMillis = null
235
+ state.options = null
93
236
 
94
- // Heap and CPU profilers have different stop APIs
95
- if (type === 'heap') {
96
- // Get the profile before stopping
97
- state.latestProfile = profiler.profile()
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
- // CPU time profiler returns the profile when stopping
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
- const profiler = getProfiler(type)
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
- } else if (state.latestProfile == null) {
123
- throw new NoProfileAvailableError()
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.11.0",
3
+ "version": "3.13.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/foundation": "3.11.0",
26
- "@platformatic/service": "3.11.0"
26
+ "@platformatic/service": "3.13.0",
27
+ "@platformatic/foundation": "3.13.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
- // Start profiling with profile rotation
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
+ })