@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument -- test file, mock typing */
|
|
2
|
+
/**
|
|
3
|
+
* Stream diagnostic — measures quality, latency, and fanout.
|
|
4
|
+
* Run: FRIGATE_HOST=192.168.1.128 npx vitest run server/backend/src/__tests__/streaming-diagnostic.test.ts --reporter verbose
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// E2E diagnostic test with heavy broker usage — file-level disable for cascading unsafe rules
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, afterAll } from 'vitest'
|
|
11
|
+
import { StreamBrokerManager, FfmpegDecoderProvider } from '@camstack/addon-pipeline/stream-broker'
|
|
12
|
+
import type { EncodedPacket, FrameHandle, IStreamBroker } from '@camstack/types'
|
|
13
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
14
|
+
|
|
15
|
+
const FRIGATE = process.env.FRIGATE_HOST ?? ''
|
|
16
|
+
const STREAMS = [
|
|
17
|
+
{ id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main`, codec: 'h264' },
|
|
18
|
+
{ id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main`, codec: 'h264' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const mockLogger: IScopedLogger = {
|
|
22
|
+
debug: () => {},
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: console.warn,
|
|
25
|
+
error: console.error,
|
|
26
|
+
child: () => mockLogger,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mockLoggingService = {
|
|
30
|
+
createLogger: () => mockLogger,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function wait(ms: number) {
|
|
34
|
+
return new Promise(r => setTimeout(r, ms))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function waitForFrames(count: number, frames: unknown[], timeoutMs: number): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const start = Date.now()
|
|
40
|
+
const iv = setInterval(() => {
|
|
41
|
+
if (frames.length >= count) { clearInterval(iv); resolve() }
|
|
42
|
+
if (Date.now() - start > timeoutMs) { clearInterval(iv); reject(new Error(`Timeout: got ${frames.length}/${count} frames`)) }
|
|
43
|
+
}, 50)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Poll-based replacement for the removed `IStreamBroker.onDecodedFrame`
|
|
49
|
+
* callback (Phase 5 / D9 — decoded frames now travel as shm `FrameHandle`s).
|
|
50
|
+
* Opens a frame-handle subscription, drains `pullFrameHandles` on a 50ms
|
|
51
|
+
* timer, and invokes `onHandle` once per decoded frame. The returned
|
|
52
|
+
* function tears the subscription down. `byteLength` stands in for the old
|
|
53
|
+
* `DecodedFrame.data.length`; `pts` for `DecodedFrame.timestamp`.
|
|
54
|
+
*/
|
|
55
|
+
async function subscribeDecodedFrames(
|
|
56
|
+
broker: IStreamBroker,
|
|
57
|
+
maxFps: number,
|
|
58
|
+
onHandle: (handle: FrameHandle) => void,
|
|
59
|
+
): Promise<() => Promise<void>> {
|
|
60
|
+
const { subscriptionId } = await broker.subscribeFrameHandles({ format: 'rgb', maxFps })
|
|
61
|
+
const iv = setInterval(() => {
|
|
62
|
+
for (const handle of broker.pullFrameHandles(subscriptionId, 8)) {
|
|
63
|
+
onHandle(handle)
|
|
64
|
+
}
|
|
65
|
+
}, 50)
|
|
66
|
+
return async () => {
|
|
67
|
+
clearInterval(iv)
|
|
68
|
+
await broker.unsubscribeFrameHandles(subscriptionId)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe.skipIf(!FRIGATE)('Stream Diagnostic (requires Frigate)', () => {
|
|
73
|
+
const manager = new StreamBrokerManager([new FfmpegDecoderProvider()], mockLogger)
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await manager.destroyAll()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('measures first-frame latency per stream', async () => {
|
|
80
|
+
console.log('\n--- First-Frame Latency ---')
|
|
81
|
+
|
|
82
|
+
for (const s of STREAMS) {
|
|
83
|
+
const broker = await manager.createBroker(`latency-${s.id}`, {
|
|
84
|
+
type: 'rtsp', url: s.url, videoCodec: s.codec,
|
|
85
|
+
})
|
|
86
|
+
const startMs = Date.now()
|
|
87
|
+
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: s.codec })
|
|
88
|
+
|
|
89
|
+
const result = await new Promise<{ latencyMs: number; sizeKB: number }>((resolve, reject) => {
|
|
90
|
+
const timeout = setTimeout(() => reject(new Error('No frame in 15s')), 15000)
|
|
91
|
+
let unsub: (() => Promise<void>) | undefined
|
|
92
|
+
subscribeDecodedFrames(broker, 10, (handle: FrameHandle) => {
|
|
93
|
+
clearTimeout(timeout)
|
|
94
|
+
void unsub?.()
|
|
95
|
+
resolve({ latencyMs: Date.now() - startMs, sizeKB: +(handle.byteLength / 1024).toFixed(1) })
|
|
96
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
console.log(` ${s.id}: ${result.latencyMs}ms, frame=${result.sizeKB}KB`)
|
|
100
|
+
expect(result.latencyMs).toBeLessThan(15000)
|
|
101
|
+
expect(result.sizeKB).toBeGreaterThan(1) // at least 1KB JPEG
|
|
102
|
+
|
|
103
|
+
await broker.stop()
|
|
104
|
+
await manager.destroyBroker(`latency-${s.id}`)
|
|
105
|
+
}
|
|
106
|
+
}, 30000)
|
|
107
|
+
|
|
108
|
+
it('measures frame quality over 5 seconds', async () => {
|
|
109
|
+
console.log('\n--- Frame Quality (5s @ 5fps) ---')
|
|
110
|
+
|
|
111
|
+
const broker = await manager.createBroker('quality', {
|
|
112
|
+
type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264',
|
|
113
|
+
})
|
|
114
|
+
await (broker as any).start({ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264' })
|
|
115
|
+
|
|
116
|
+
const frames: { sizeKB: number; ts: number }[] = []
|
|
117
|
+
|
|
118
|
+
// Wait for first frame before measuring
|
|
119
|
+
await new Promise<void>((resolve, reject) => {
|
|
120
|
+
const timeout = setTimeout(() => reject(new Error('No initial frame')), 10000)
|
|
121
|
+
let unsub: (() => Promise<void>) | undefined
|
|
122
|
+
subscribeDecodedFrames(broker, 5, () => {
|
|
123
|
+
clearTimeout(timeout)
|
|
124
|
+
void unsub?.()
|
|
125
|
+
resolve()
|
|
126
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Now collect for 5 seconds
|
|
130
|
+
const unsub = await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
|
|
131
|
+
frames.push({ sizeKB: +(handle.byteLength / 1024).toFixed(1), ts: handle.pts })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await wait(5000)
|
|
135
|
+
await unsub()
|
|
136
|
+
|
|
137
|
+
const sizes = frames.map(f => f.sizeKB)
|
|
138
|
+
const avgSize = +(sizes.reduce((a, b) => a + b, 0) / sizes.length).toFixed(1)
|
|
139
|
+
const minSize = Math.min(...sizes)
|
|
140
|
+
const maxSize = Math.max(...sizes)
|
|
141
|
+
|
|
142
|
+
// Frame-to-frame gaps
|
|
143
|
+
const gaps: number[] = []
|
|
144
|
+
for (let i = 1; i < frames.length; i++) {
|
|
145
|
+
gaps.push(frames[i]!.ts - frames[i - 1]!.ts)
|
|
146
|
+
}
|
|
147
|
+
const avgGap = gaps.length > 0 ? Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length) : 0
|
|
148
|
+
const maxGap = gaps.length > 0 ? Math.max(...gaps) : 0
|
|
149
|
+
|
|
150
|
+
console.log(` Frames: ${frames.length} (expected ~25 @ 5fps)`)
|
|
151
|
+
console.log(` JPEG size: avg=${avgSize}KB, min=${minSize}KB, max=${maxSize}KB`)
|
|
152
|
+
console.log(` Frame gap: avg=${avgGap}ms, max=${maxGap}ms (expected ~200ms @ 5fps)`)
|
|
153
|
+
console.log(` All valid: ${frames.every(f => f.sizeKB > 0.5) ? 'YES' : 'NO'}`)
|
|
154
|
+
|
|
155
|
+
expect(frames.length).toBeGreaterThan(10)
|
|
156
|
+
expect(avgSize).toBeGreaterThan(1) // > 1KB avg = real JPEG content
|
|
157
|
+
expect(avgGap).toBeLessThan(500) // not more than 500ms avg gap at 5fps
|
|
158
|
+
|
|
159
|
+
await broker.stop()
|
|
160
|
+
await manager.destroyBroker('quality')
|
|
161
|
+
}, 30000)
|
|
162
|
+
|
|
163
|
+
it('measures fanout: encoded + 2 decoded subscribers', async () => {
|
|
164
|
+
console.log('\n--- Fanout (3s, encoded + 2 decoded) ---')
|
|
165
|
+
|
|
166
|
+
const broker = await manager.createBroker('fanout', {
|
|
167
|
+
type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264',
|
|
168
|
+
})
|
|
169
|
+
await (broker as any).start({ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264' })
|
|
170
|
+
|
|
171
|
+
let encodedCount = 0
|
|
172
|
+
let encodedBytes = 0
|
|
173
|
+
let keyframes = 0
|
|
174
|
+
let decodedA = 0
|
|
175
|
+
let decodedB = 0
|
|
176
|
+
|
|
177
|
+
// Wait for stream to start
|
|
178
|
+
await new Promise<void>((resolve, reject) => {
|
|
179
|
+
const timeout = setTimeout(() => reject(new Error('No frame')), 10000)
|
|
180
|
+
let unsub: (() => Promise<void>) | undefined
|
|
181
|
+
subscribeDecodedFrames(broker, 10, () => {
|
|
182
|
+
clearTimeout(timeout)
|
|
183
|
+
void unsub?.()
|
|
184
|
+
resolve()
|
|
185
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Subscribe all 3
|
|
189
|
+
const unsubE = broker.onEncodedData((pkt: EncodedPacket) => {
|
|
190
|
+
encodedCount++
|
|
191
|
+
encodedBytes += pkt.data.length
|
|
192
|
+
if (pkt.keyframe) keyframes++
|
|
193
|
+
})
|
|
194
|
+
// Phase 5 / D9: the broker no longer throttles per-subscriber fps — the
|
|
195
|
+
// shm frame plane keeps one decoder session per format (here both
|
|
196
|
+
// subscribers share the `rgb` ring) and latest-wins ring reads drop
|
|
197
|
+
// frames implicitly. Both subscribers therefore see the same cadence;
|
|
198
|
+
// the old per-subscriber fps-isolation guarantee is gone.
|
|
199
|
+
const unsubD1 = await subscribeDecodedFrames(broker, 5, () => { decodedA++ })
|
|
200
|
+
const unsubD2 = await subscribeDecodedFrames(broker, 5, () => { decodedB++ })
|
|
201
|
+
|
|
202
|
+
await wait(3000)
|
|
203
|
+
|
|
204
|
+
// Capture stats BEFORE unsubscribing
|
|
205
|
+
const stats = broker.getStats()
|
|
206
|
+
|
|
207
|
+
unsubE()
|
|
208
|
+
await unsubD1()
|
|
209
|
+
await unsubD2()
|
|
210
|
+
|
|
211
|
+
console.log(` Encoded packets: ${encodedCount} (${(encodedBytes / 1024).toFixed(0)}KB, ${keyframes} keyframes)`)
|
|
212
|
+
console.log(` Decoded subscriber A: ${decodedA} frames`)
|
|
213
|
+
console.log(` Decoded subscriber B: ${decodedB} frames`)
|
|
214
|
+
console.log(` Broker stats: inputFps=${stats.inputFps}, decodeFps=${stats.decodeFps}, uptime=${stats.uptimeMs}ms`)
|
|
215
|
+
|
|
216
|
+
// Assertions
|
|
217
|
+
expect(encodedCount).toBeGreaterThan(0)
|
|
218
|
+
expect(decodedA).toBeGreaterThan(0)
|
|
219
|
+
expect(decodedB).toBeGreaterThan(0)
|
|
220
|
+
expect(stats.decodeFps).toBeGreaterThan(0) // plane's aggregate decode rate
|
|
221
|
+
expect(keyframes).toBeGreaterThan(0) // at least 1 keyframe in 3s
|
|
222
|
+
|
|
223
|
+
await broker.stop()
|
|
224
|
+
await manager.destroyBroker('fanout')
|
|
225
|
+
}, 30000)
|
|
226
|
+
|
|
227
|
+
it('measures concurrent broker isolation (2 cameras)', async () => {
|
|
228
|
+
console.log('\n--- Concurrent Brokers (2 cameras, 3s @ 3fps) ---')
|
|
229
|
+
|
|
230
|
+
const results: Record<string, { frames: number; sizes: number[] }> = {}
|
|
231
|
+
const brokerIds: string[] = []
|
|
232
|
+
const collectUnsubs: Array<() => Promise<void>> = []
|
|
233
|
+
|
|
234
|
+
for (const s of STREAMS) {
|
|
235
|
+
const brokerId = `concurrent-${s.id}`
|
|
236
|
+
brokerIds.push(brokerId)
|
|
237
|
+
const broker = await manager.createBroker(brokerId, {
|
|
238
|
+
type: 'rtsp', url: s.url, videoCodec: s.codec,
|
|
239
|
+
})
|
|
240
|
+
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: s.codec })
|
|
241
|
+
results[s.id] = { frames: 0, sizes: [] }
|
|
242
|
+
|
|
243
|
+
// Wait for first frame
|
|
244
|
+
await new Promise<void>((resolve, reject) => {
|
|
245
|
+
const timeout = setTimeout(() => reject(new Error(`No frame from ${s.id}`)), 10000)
|
|
246
|
+
let unsub: (() => Promise<void>) | undefined
|
|
247
|
+
subscribeDecodedFrames(broker, 5, () => {
|
|
248
|
+
clearTimeout(timeout)
|
|
249
|
+
void unsub?.()
|
|
250
|
+
resolve()
|
|
251
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
collectUnsubs.push(await subscribeDecodedFrames(broker, 3, (handle: FrameHandle) => {
|
|
255
|
+
results[s.id]!.frames++
|
|
256
|
+
results[s.id]!.sizes.push(handle.byteLength)
|
|
257
|
+
}))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await wait(3000)
|
|
261
|
+
for (const unsub of collectUnsubs) await unsub()
|
|
262
|
+
|
|
263
|
+
for (const s of STREAMS) {
|
|
264
|
+
const r = results[s.id]!
|
|
265
|
+
const avgKB = r.sizes.length > 0 ? +(r.sizes.reduce((a, b) => a + b, 0) / r.sizes.length / 1024).toFixed(1) : 0
|
|
266
|
+
console.log(` ${s.id}: ${r.frames} frames, avg=${avgKB}KB`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const allProducing = Object.values(results).every(r => r.frames > 0)
|
|
270
|
+
console.log(` All producing: ${allProducing ? 'YES' : 'FAIL'}`)
|
|
271
|
+
|
|
272
|
+
expect(allProducing).toBe(true)
|
|
273
|
+
|
|
274
|
+
for (const id of brokerIds) {
|
|
275
|
+
const b = manager.getBroker(id)
|
|
276
|
+
if (b) { await b.stop() }
|
|
277
|
+
await manager.destroyBroker(id)
|
|
278
|
+
}
|
|
279
|
+
}, 30000)
|
|
280
|
+
|
|
281
|
+
it('stress test: 3 cameras x 3 subscribers each at different FPS', async () => {
|
|
282
|
+
console.log('\n--- Stress: 3 cameras x 3 subs (5s) ---')
|
|
283
|
+
|
|
284
|
+
const cameras = [
|
|
285
|
+
{ id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main` },
|
|
286
|
+
{ id: 'ingresso_sub', url: `rtsp://${FRIGATE}:8554/ingresso_sub` },
|
|
287
|
+
{ id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main` },
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
// Phase 5 / D9: the broker no longer throttles per-subscriber fps. This
|
|
291
|
+
// stays a load test — 3 concurrent frame-handle subscribers per camera —
|
|
292
|
+
// but the obsolete per-subscriber fps-isolation assertion is dropped.
|
|
293
|
+
const subscriberCount = 3
|
|
294
|
+
const results: Record<string, Record<number, { frames: number; sizes: number[] }>> = {}
|
|
295
|
+
const unsubs: Array<() => Promise<void>> = []
|
|
296
|
+
const brokerIds: string[] = []
|
|
297
|
+
|
|
298
|
+
// Start all 3 cameras
|
|
299
|
+
for (const cam of cameras) {
|
|
300
|
+
const brokerId = `stress-${cam.id}`
|
|
301
|
+
brokerIds.push(brokerId)
|
|
302
|
+
results[cam.id] = {}
|
|
303
|
+
|
|
304
|
+
const broker = await manager.createBroker(brokerId, {
|
|
305
|
+
type: 'rtsp', url: cam.url, videoCodec: 'h264',
|
|
306
|
+
})
|
|
307
|
+
await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
|
|
308
|
+
|
|
309
|
+
// Wait for first frame (shared decoder warmup)
|
|
310
|
+
await new Promise<void>((resolve, reject) => {
|
|
311
|
+
const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id} in 10s`)), 10000)
|
|
312
|
+
let unsub: (() => Promise<void>) | undefined
|
|
313
|
+
subscribeDecodedFrames(broker, 10, () => {
|
|
314
|
+
clearTimeout(timeout)
|
|
315
|
+
void unsub?.()
|
|
316
|
+
resolve()
|
|
317
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Subscribe N concurrent frame-handle readers
|
|
321
|
+
for (let sub = 0; sub < subscriberCount; sub++) {
|
|
322
|
+
results[cam.id]![sub] = { frames: 0, sizes: [] }
|
|
323
|
+
unsubs.push(await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
|
|
324
|
+
results[cam.id]![sub]!.frames++
|
|
325
|
+
results[cam.id]![sub]!.sizes.push(handle.byteLength)
|
|
326
|
+
}))
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Let it run for 5 seconds
|
|
331
|
+
await wait(5000)
|
|
332
|
+
|
|
333
|
+
// Unsubscribe all
|
|
334
|
+
for (const unsub of unsubs) await unsub()
|
|
335
|
+
|
|
336
|
+
// Print results
|
|
337
|
+
console.log(' Camera | sub0 | sub1 | sub2 | Avg KB')
|
|
338
|
+
console.log(' -------------------|-------|-------|-------|-------')
|
|
339
|
+
let allOk = true
|
|
340
|
+
for (const cam of cameras) {
|
|
341
|
+
const r = results[cam.id]!
|
|
342
|
+
const allSizes = [...r[0]!.sizes, ...r[1]!.sizes, ...r[2]!.sizes]
|
|
343
|
+
const avgKB = allSizes.length > 0 ? +(allSizes.reduce((a, b) => a + b, 0) / allSizes.length / 1024).toFixed(0) : 0
|
|
344
|
+
const line = ` ${cam.id.padEnd(19)}| ${String(r[0]!.frames).padStart(5)} | ${String(r[1]!.frames).padStart(5)} | ${String(r[2]!.frames).padStart(5)} | ${avgKB}`
|
|
345
|
+
console.log(line)
|
|
346
|
+
|
|
347
|
+
// Every concurrent subscriber must produce frames.
|
|
348
|
+
for (let sub = 0; sub < subscriberCount; sub++) {
|
|
349
|
+
if (r[sub]!.frames === 0) allOk = false
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Verify totals
|
|
354
|
+
const totalFrames = Object.values(results).reduce((sum, cam) =>
|
|
355
|
+
sum + Object.values(cam).reduce((s, sub) => s + sub.frames, 0), 0)
|
|
356
|
+
const totalSubs = cameras.length * subscriberCount // 9
|
|
357
|
+
console.log(`\n Total: ${totalFrames} frames across ${totalSubs} subscribers`)
|
|
358
|
+
console.log(` All subs producing: ${allOk ? 'YES' : 'FAIL'}`)
|
|
359
|
+
|
|
360
|
+
// Assertions — every concurrent subscriber on every camera gets frames.
|
|
361
|
+
for (const cam of cameras) {
|
|
362
|
+
for (let sub = 0; sub < subscriberCount; sub++) {
|
|
363
|
+
expect(results[cam.id]![sub]!.frames, `${cam.id} sub${sub} should produce frames`).toBeGreaterThan(0)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Cleanup
|
|
368
|
+
for (const id of brokerIds) {
|
|
369
|
+
const b = manager.getBroker(id)
|
|
370
|
+
if (b) await b.stop()
|
|
371
|
+
await manager.destroyBroker(id)
|
|
372
|
+
}
|
|
373
|
+
}, 60000)
|
|
374
|
+
|
|
375
|
+
it('benchmarks resource usage per stream (CPU, memory, bandwidth)', async () => {
|
|
376
|
+
console.log('\n--- Resource Benchmark (3 cameras, 5s each) ---')
|
|
377
|
+
|
|
378
|
+
const cameras = [
|
|
379
|
+
{ id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main`, label: 'ingresso main (HD)' },
|
|
380
|
+
{ id: 'ingresso_sub', url: `rtsp://${FRIGATE}:8554/ingresso_sub`, label: 'ingresso sub (SD)' },
|
|
381
|
+
{ id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main`, label: 'salone main (HD)' },
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
console.log(' Stream | Enc BW | Dec FPS | Dec KB/f | CPU % | RSS MB | Heap MB')
|
|
385
|
+
console.log(' --------------------|-----------|---------|----------|--------|--------|--------')
|
|
386
|
+
|
|
387
|
+
for (const cam of cameras) {
|
|
388
|
+
const brokerId = `bench-${cam.id}`
|
|
389
|
+
const broker = await manager.createBroker(brokerId, {
|
|
390
|
+
type: 'rtsp', url: cam.url, videoCodec: 'h264',
|
|
391
|
+
})
|
|
392
|
+
await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
|
|
393
|
+
|
|
394
|
+
// Wait for first frame
|
|
395
|
+
await new Promise<void>((resolve, reject) => {
|
|
396
|
+
const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id}`)), 10000)
|
|
397
|
+
let unsub: (() => Promise<void>) | undefined
|
|
398
|
+
subscribeDecodedFrames(broker, 10, () => {
|
|
399
|
+
clearTimeout(timeout)
|
|
400
|
+
void unsub?.()
|
|
401
|
+
resolve()
|
|
402
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
// Measure baseline
|
|
406
|
+
const memBefore = process.memoryUsage()
|
|
407
|
+
const cpuBefore = process.cpuUsage()
|
|
408
|
+
let encodedBytes = 0
|
|
409
|
+
let encodedPackets = 0
|
|
410
|
+
const decodedSizes: number[] = []
|
|
411
|
+
|
|
412
|
+
const unsubE = broker.onEncodedData((pkt: EncodedPacket) => {
|
|
413
|
+
encodedBytes += pkt.data.length
|
|
414
|
+
encodedPackets++
|
|
415
|
+
})
|
|
416
|
+
const unsubD = await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
|
|
417
|
+
decodedSizes.push(handle.byteLength)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// Run for 5 seconds
|
|
421
|
+
await wait(5000)
|
|
422
|
+
|
|
423
|
+
const cpuAfter = process.cpuUsage(cpuBefore)
|
|
424
|
+
const memAfter = process.memoryUsage()
|
|
425
|
+
|
|
426
|
+
unsubE()
|
|
427
|
+
await unsubD()
|
|
428
|
+
|
|
429
|
+
// Calculate metrics
|
|
430
|
+
const durationS = 5
|
|
431
|
+
const encBwKBs = (encodedBytes / 1024 / durationS).toFixed(0)
|
|
432
|
+
const decFps = (decodedSizes.length / durationS).toFixed(1)
|
|
433
|
+
const avgDecKB = decodedSizes.length > 0
|
|
434
|
+
? (decodedSizes.reduce((a, b) => a + b, 0) / decodedSizes.length / 1024).toFixed(0)
|
|
435
|
+
: '0'
|
|
436
|
+
// CPU: user + system microseconds → percentage of 5s wall time
|
|
437
|
+
const cpuTotalUs = cpuAfter.user + cpuAfter.system
|
|
438
|
+
const cpuPct = (cpuTotalUs / (durationS * 1_000_000) * 100).toFixed(1)
|
|
439
|
+
const rssDeltaMB = ((memAfter.rss - memBefore.rss) / 1024 / 1024).toFixed(1)
|
|
440
|
+
const heapMB = (memAfter.heapUsed / 1024 / 1024).toFixed(1)
|
|
441
|
+
|
|
442
|
+
const label = cam.label.padEnd(20)
|
|
443
|
+
console.log(` ${label}| ${encBwKBs.padStart(5)} KB/s | ${decFps.padStart(7)} | ${avgDecKB.padStart(6)} KB | ${cpuPct.padStart(5)}% | ${rssDeltaMB.padStart(6)} | ${heapMB.padStart(7)}`)
|
|
444
|
+
|
|
445
|
+
await broker.stop()
|
|
446
|
+
await manager.destroyBroker(brokerId)
|
|
447
|
+
|
|
448
|
+
// Small pause between cameras for GC
|
|
449
|
+
await wait(500)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Now test all 3 simultaneously
|
|
453
|
+
console.log(' --------------------|-----------|---------|----------|--------|--------|--------')
|
|
454
|
+
console.log(' All 3 concurrent:')
|
|
455
|
+
|
|
456
|
+
const memBaseline = process.memoryUsage()
|
|
457
|
+
const cpuBaseline = process.cpuUsage()
|
|
458
|
+
const allBrokers: string[] = []
|
|
459
|
+
const allUnsubs: Array<() => void | Promise<void>> = []
|
|
460
|
+
let totalEncBytes = 0
|
|
461
|
+
let totalDecFrames = 0
|
|
462
|
+
|
|
463
|
+
for (const cam of cameras) {
|
|
464
|
+
const brokerId = `bench-all-${cam.id}`
|
|
465
|
+
allBrokers.push(brokerId)
|
|
466
|
+
const broker = await manager.createBroker(brokerId, {
|
|
467
|
+
type: 'rtsp', url: cam.url, videoCodec: 'h264',
|
|
468
|
+
})
|
|
469
|
+
await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
|
|
470
|
+
|
|
471
|
+
await new Promise<void>((resolve, reject) => {
|
|
472
|
+
const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id}`)), 10000)
|
|
473
|
+
let unsub: (() => Promise<void>) | undefined
|
|
474
|
+
subscribeDecodedFrames(broker, 10, () => {
|
|
475
|
+
clearTimeout(timeout)
|
|
476
|
+
void unsub?.()
|
|
477
|
+
resolve()
|
|
478
|
+
}).then((fn) => { unsub = fn }).catch(reject)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
allUnsubs.push(broker.onEncodedData((pkt: EncodedPacket) => { totalEncBytes += pkt.data.length }))
|
|
482
|
+
allUnsubs.push(await subscribeDecodedFrames(broker, 5, () => { totalDecFrames++ }))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await wait(5000)
|
|
486
|
+
|
|
487
|
+
const cpuAll = process.cpuUsage(cpuBaseline)
|
|
488
|
+
const memAll = process.memoryUsage()
|
|
489
|
+
|
|
490
|
+
for (const unsub of allUnsubs) await unsub()
|
|
491
|
+
|
|
492
|
+
const allCpuPct = ((cpuAll.user + cpuAll.system) / (5 * 1_000_000) * 100).toFixed(1)
|
|
493
|
+
const allRssMB = ((memAll.rss - memBaseline.rss) / 1024 / 1024).toFixed(1)
|
|
494
|
+
const allHeapMB = (memAll.heapUsed / 1024 / 1024).toFixed(1)
|
|
495
|
+
const allEncKBs = (totalEncBytes / 1024 / 5).toFixed(0)
|
|
496
|
+
|
|
497
|
+
console.log(` Encoded BW: ${allEncKBs} KB/s total`)
|
|
498
|
+
console.log(` Decoded: ${totalDecFrames} frames total (${(totalDecFrames / 5).toFixed(1)} fps combined)`)
|
|
499
|
+
console.log(` CPU: ${allCpuPct}%`)
|
|
500
|
+
console.log(` RSS delta: ${allRssMB} MB`)
|
|
501
|
+
console.log(` Heap: ${allHeapMB} MB`)
|
|
502
|
+
|
|
503
|
+
for (const id of allBrokers) {
|
|
504
|
+
const b = manager.getBroker(id)
|
|
505
|
+
if (b) await b.stop()
|
|
506
|
+
await manager.destroyBroker(id)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Basic sanity — we should be able to handle 3 streams
|
|
510
|
+
expect(totalDecFrames).toBeGreaterThan(10)
|
|
511
|
+
}, 90000)
|
|
512
|
+
})
|