mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
more performance sloptesting
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# `/spectate` CPU Performance Summary
|
||||
|
||||
This is the `/spectate` companion to the `/` performance writeups. The testing target
|
||||
was the deployed page:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/spectate
|
||||
```
|
||||
|
||||
The most useful artifact for this pass is:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
That run used:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
## High-Level Result
|
||||
|
||||
`/spectate` is CPU-heavy for a different reason than `/`.
|
||||
|
||||
The `/` page's biggest confirmed issue is global input listener churn. `/spectate` does
|
||||
not show that same dominant pattern. The fresh `/spectate` audit had 379 event listeners,
|
||||
mostly normal media/image listeners, and no giant add/remove loop.
|
||||
|
||||
The `/spectate` hot path is instead a live-display fan-out problem:
|
||||
|
||||
- several rover media players are mounted at once
|
||||
- each rover card renders live HUD telemetry and a top-down SVG map
|
||||
- camera/snapshot frames create new blobs and `img.src` values
|
||||
- the route subscribes to broad session/log/chat state while also rendering the whole
|
||||
spectator layout
|
||||
- the page maintains several WebRTC players at the same time
|
||||
|
||||
## Key Measurements
|
||||
|
||||
Fresh `/spectate` runtime audit, 30s, mobile viewport, 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
TaskDuration: 28.48s over a 30.9s sample
|
||||
ScriptDuration: 12.11s
|
||||
Long tasks: 81
|
||||
Worst long task: 375ms
|
||||
Average frame gap: 113.9ms
|
||||
p95 frame gap: 233.3ms
|
||||
p99 frame gap: 300.0ms
|
||||
Nodes: 7,059
|
||||
Layout objects: 6,150
|
||||
RTCPeerConnections: 6
|
||||
LayoutCount: 243
|
||||
RecalcStyleCount: 248
|
||||
```
|
||||
|
||||
The 8x CPU-throttled profile from the earlier route comparison was worse:
|
||||
|
||||
```txt
|
||||
Task delta: 49.13s over a 52.85s sample
|
||||
Script delta: 19.58s
|
||||
Layout delta: 516
|
||||
Recalc delta: 522
|
||||
Node delta: +3,815
|
||||
```
|
||||
|
||||
That profile also put `Blob`, `decodeString`, and `setAttribute` in the CPU stack, which
|
||||
matches the runtime evidence for camera blobs, socket payload parsing, and SVG/image
|
||||
attribute churn.
|
||||
|
||||
## Socket And DOM Pressure
|
||||
|
||||
The fresh audit captured these live events during the 30s window:
|
||||
|
||||
```txt
|
||||
sensorFrame: 1,354 events, 2,531,090 bytes
|
||||
log:entry: 634 events, 150,179 bytes
|
||||
unlabeled binary: 126 events, 11,048,477 bytes
|
||||
roomCamera:frame: 117 events
|
||||
session:sync: 54 events, 914,000 bytes
|
||||
commandAck: 68 events
|
||||
roverHostStats: 17 events
|
||||
roverSnapshot:frame: 7 events
|
||||
```
|
||||
|
||||
The most important DOM mutations were:
|
||||
|
||||
```txt
|
||||
path d: 2,688 mutations
|
||||
path fill: 2,682 mutations
|
||||
img.h-full.w-full src: 117 mutations
|
||||
span.h-2.w-2 class: 117 mutations
|
||||
```
|
||||
|
||||
The `path` mutations point strongly at the per-rover `TopDownMap` SVG overlays. The
|
||||
`img.src` and blink class mutations point at room camera snapshot rendering.
|
||||
|
||||
## Ordered Issue List
|
||||
|
||||
1. [Telemetry fan-out and always-on HUD maps](./spectate-issues/001-telemetry-fanout-hud-maps.md)
|
||||
2. [Multi-rover WebRTC media fan-out](./spectate-issues/002-multi-rover-webrtc-media-fanout.md)
|
||||
3. [Room camera and snapshot object URL churn](./spectate-issues/003-room-camera-snapshot-object-url-churn.md)
|
||||
4. [Broad spectator session rerenders](./spectate-issues/004-broad-spectator-session-rerenders.md)
|
||||
5. [Shared log/session stream pressure](./spectate-issues/005-shared-log-session-stream-pressure.md)
|
||||
6. [Spectator role handshake retries](./spectate-issues/006-spectator-role-handshake-retries.md)
|
||||
|
||||
## Upstream Dependency Map
|
||||
|
||||
Fixing the `/` backlog first is a good plan.
|
||||
|
||||
Likely improved by `/` fixes:
|
||||
|
||||
- telemetry visual throttling
|
||||
- log batching/gating
|
||||
- chat/nickname churn
|
||||
- session selector/store improvements
|
||||
- generic timer cleanup
|
||||
|
||||
Not likely to be fixed by `/` alone:
|
||||
|
||||
- several simultaneous WHEP video/audio players
|
||||
- per-rover spectator HUD maps
|
||||
- room camera snapshot blob/object URL churn
|
||||
- spectator role/subscribe handshake behavior
|
||||
- mobile policy for how many live feeds should be active at once
|
||||
|
||||
## Suggested Re-Test
|
||||
|
||||
After each `/spectate` fix, run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
The most useful numbers to compare:
|
||||
|
||||
- `TaskDuration`
|
||||
- `ScriptDuration`
|
||||
- long task count and worst duration
|
||||
- frame average/p95/p99
|
||||
- `RTCPeerConnections`
|
||||
- `mutationTargets` for `path d`, `path fill`, and `img src`
|
||||
- `webSocketEvents` for `sensorFrame`, `log:entry`, `session:sync`, and binary frame volume
|
||||
|
||||
@@ -0,0 +1,858 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/spectate",
|
||||
"runDir": "perf/results/2026-06-12T04-55-16-888Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 29302.703615,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 379,
|
||||
"LayoutObjects": 6150,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 7059,
|
||||
"Resources": 40,
|
||||
"ContextLifecycleStateObservers": 21,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 6,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 0,
|
||||
"ArrayBufferContents": 28,
|
||||
"LayoutCount": 243,
|
||||
"RecalcStyleCount": 248,
|
||||
"LayoutDuration": 0.840302,
|
||||
"RecalcStyleDuration": 1.699961,
|
||||
"DevToolsCommandDuration": 0.000155,
|
||||
"ScriptDuration": 12.114687,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 28.48339,
|
||||
"TaskOtherDuration": 13.828285,
|
||||
"ThreadTime": 30.190528,
|
||||
"ProcessTime": 39.47,
|
||||
"JSHeapUsedSize": 22447504,
|
||||
"JSHeapTotalSize": 42467328,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 29272.696156,
|
||||
"NavigationStart": 29272.010802
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 30914.699999999255,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:img:error",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:img:load",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:abort",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplaythrough",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:durationchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:emptied",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:encrypted",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadeddata",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadedmetadata",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadstart",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:playing",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:progress",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ratechange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:resize",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:seeked",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:seeking",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:suspend",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:timeupdate",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:volumechange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:abort",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:canplaythrough",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:durationchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:emptied",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:encrypted",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:loadeddata",
|
||||
"count": 3
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "timeout:1200",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 14,
|
||||
"intervals": 18,
|
||||
"timeoutFires": 7,
|
||||
"intervalFires": 112,
|
||||
"webSocketMessageCount": 1000,
|
||||
"webSocketBytes": 4207474,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 1354,
|
||||
"bytes": 2531090
|
||||
},
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 634,
|
||||
"bytes": 150179
|
||||
},
|
||||
{
|
||||
"event": "",
|
||||
"count": 126,
|
||||
"bytes": 11048477
|
||||
},
|
||||
{
|
||||
"event": "roomCamera:frame",
|
||||
"count": 117,
|
||||
"bytes": 10413
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 68,
|
||||
"bytes": 6412
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 54,
|
||||
"bytes": 914000
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 17,
|
||||
"bytes": 13058
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:frame",
|
||||
"count": 7,
|
||||
"bytes": 682
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 5,
|
||||
"bytes": 5
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 4,
|
||||
"bytes": 139
|
||||
},
|
||||
{
|
||||
"event": "roomCamera:status",
|
||||
"count": 4,
|
||||
"bytes": 284
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:status",
|
||||
"count": 3,
|
||||
"bytes": 238
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 2,
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 2,
|
||||
"bytes": 330
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"3a5",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"lE",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 1,
|
||||
"bytes": 27
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 1,
|
||||
"bytes": 5485
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 1,
|
||||
"bytes": 77415
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 1,
|
||||
"bytes": 520
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 1,
|
||||
"bytes": 44530
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 1,
|
||||
"bytes": 1627
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 1,
|
||||
"bytes": 262
|
||||
},
|
||||
{
|
||||
"event": "kinect:colorFrame",
|
||||
"count": 1,
|
||||
"bytes": 169
|
||||
},
|
||||
{
|
||||
"event": "430[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "431[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "432[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
},
|
||||
{
|
||||
"event": "433[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "434[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "435[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "WALL-E",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "437[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "438[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "Freaky",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "4310[{\"url\":",
|
||||
"count": 1,
|
||||
"bytes": 136
|
||||
},
|
||||
{
|
||||
"event": "4311[{\"url\":",
|
||||
"count": 1,
|
||||
"bytes": 148
|
||||
},
|
||||
{
|
||||
"event": "Bweeble",
|
||||
"count": 1,
|
||||
"bytes": 42
|
||||
},
|
||||
{
|
||||
"event": "carpet",
|
||||
"count": 1,
|
||||
"bytes": 68
|
||||
},
|
||||
{
|
||||
"event": "4314[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "4315[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4316[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4317[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 195
|
||||
},
|
||||
{
|
||||
"event": "4318[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4319[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 5868,
|
||||
"added": 440,
|
||||
"removed": 440,
|
||||
"attrs": 4921,
|
||||
"text": 67
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:d",
|
||||
"count": 2688
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:fill",
|
||||
"count": 2682
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 164
|
||||
},
|
||||
{
|
||||
"key": "img.h-full.w-full:attributes:src",
|
||||
"count": 117
|
||||
},
|
||||
{
|
||||
"key": "span.h-2.w-2:attributes:class",
|
||||
"count": 117
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 102
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 102
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:stroke",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 11
|
||||
},
|
||||
{
|
||||
"key": "span.rounded.px-1.5:attributes:class",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"key": "article.surface.px-0.5:attributes:class",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"key": "li.surface.flex:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:playsinline",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:autoplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:disableremoteplayback",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:autoplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:disableremoteplayback",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.panel-section.flex:attributes:style",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "span.rounded.px-1:attributes:class",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:value",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 13575,
|
||||
"records": 119,
|
||||
"added": 57,
|
||||
"removed": 58,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18089,
|
||||
"records": 117,
|
||||
"added": 52,
|
||||
"removed": 53,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 27878,
|
||||
"records": 113,
|
||||
"added": 52,
|
||||
"removed": 52,
|
||||
"attrs": 8,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 22882,
|
||||
"records": 102,
|
||||
"added": 49,
|
||||
"removed": 49,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9538,
|
||||
"records": 100,
|
||||
"added": 48,
|
||||
"removed": 48,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 14782,
|
||||
"records": 84,
|
||||
"added": 37,
|
||||
"removed": 35,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15222,
|
||||
"records": 83,
|
||||
"added": 39,
|
||||
"removed": 39,
|
||||
"attrs": 5,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9811,
|
||||
"records": 68,
|
||||
"added": 28,
|
||||
"removed": 28,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13733,
|
||||
"records": 36,
|
||||
"added": 13,
|
||||
"removed": 13,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 19439,
|
||||
"records": 26,
|
||||
"added": 10,
|
||||
"removed": 10,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 23034,
|
||||
"records": 18,
|
||||
"added": 3,
|
||||
"removed": 3,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 23113,
|
||||
"records": 18,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 28037,
|
||||
"records": 18,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 10169,
|
||||
"records": 16,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 8,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18258,
|
||||
"records": 16,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 8,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13903,
|
||||
"records": 15,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 7,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 14108,
|
||||
"records": 15,
|
||||
"added": 2,
|
||||
"removed": 2,
|
||||
"attrs": 10,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 16376,
|
||||
"records": 15,
|
||||
"added": 2,
|
||||
"removed": 2,
|
||||
"attrs": 11,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 11700,
|
||||
"records": 14,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 14,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13012,
|
||||
"records": 14,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 13,
|
||||
"text": 1
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 264,
|
||||
"average": 113.8840909090909,
|
||||
"p95": 233.29999999999927,
|
||||
"p99": 300
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 81,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 311.5999999977648,
|
||||
"duration": 375,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1978.699999999255,
|
||||
"duration": 299,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1254.5999999977648,
|
||||
"duration": 229,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1772,
|
||||
"duration": 206,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1485.3999999985099,
|
||||
"duration": 170,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2646.800000000745,
|
||||
"duration": 165,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2507.5,
|
||||
"duration": 139,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 693.8000000007451,
|
||||
"duration": 125,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3026.800000000745,
|
||||
"duration": 119,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4148.39999999851,
|
||||
"duration": 113,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3461.099999997765,
|
||||
"duration": 109,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9812.89999999851,
|
||||
"duration": 106,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 14821.39999999851,
|
||||
"duration": 100,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3145.800000000745,
|
||||
"duration": 97,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4447.39999999851,
|
||||
"duration": 96,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9551,
|
||||
"duration": 96,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9939.300000000745,
|
||||
"duration": 95,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 18100.89999999851,
|
||||
"duration": 92,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 23114.89999999851,
|
||||
"duration": 92,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 18302.800000000745,
|
||||
"duration": 88,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 MiB |
@@ -0,0 +1,219 @@
|
||||
# Issue 001: Telemetry Fan-Out And Always-On HUD Maps
|
||||
|
||||
## Summary
|
||||
|
||||
`/spectate` renders multiple rover cards at once, and every rover card includes a live HUD
|
||||
with an always-on top-down SVG map. During the 30s throttled audit, the page received
|
||||
1,354 `sensorFrame` events and produced more than 5,300 SVG `path` attribute mutations.
|
||||
|
||||
This is the strongest measured `/spectate` CPU problem.
|
||||
|
||||
## Severity
|
||||
|
||||
High.
|
||||
|
||||
This is high-frequency, always-on work. It scales with the number of visible rovers and
|
||||
hits exactly the devices we care about: mobile browsers and weaker computers.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
Medium.
|
||||
|
||||
The `/` telemetry issue will likely help if it introduces a central throttled visual
|
||||
telemetry path. However, `/spectate` has a unique multiplier: it renders several rover
|
||||
HUDs and several `TopDownMap` instances at the same time. Even after upstream telemetry
|
||||
throttling, `/spectate` probably still needs a policy for how many maps update and how
|
||||
often.
|
||||
|
||||
Related upstream issue:
|
||||
|
||||
```txt
|
||||
perf/issues/004-sensor-telemetry-render-frequency.md
|
||||
```
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/components/SpectateVideo/index.jsx`
|
||||
- `webui/src/components/HudOverlays/HudOverlay/index.jsx`
|
||||
- `webui/src/components/HudOverlays/HudOverlay/HudMapOverlay.jsx`
|
||||
- `webui/src/components/TopDownMap/TopDownMapContent.jsx`
|
||||
- `webui/src/components/TopDownMap/visuals.jsx`
|
||||
- `webui/src/context/TelemetryContext.jsx`
|
||||
- `webui/src/components/RoverMediaPlayer/index.jsx`
|
||||
- `webui/src/components/HudOverlays/OvercurrentOverlay/index.jsx`
|
||||
- `webui/src/components/HudOverlays/LowBatteryOverlay/index.jsx`
|
||||
- `webui/src/components/HudOverlays/VerticalBatteryOverlay/index.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
30s sample, mobile viewport, 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
sensorFrame events: 1,354
|
||||
sensorFrame bytes: 2,531,090
|
||||
path d mutations: 2,688
|
||||
path fill mutations: 2,682
|
||||
Long tasks: 81
|
||||
Worst long task: 375ms
|
||||
Average frame gap: 113.9ms
|
||||
p95 frame gap: 233.3ms
|
||||
```
|
||||
|
||||
The path mutation pattern is the important clue. Normal text/status updates do not mutate
|
||||
thousands of SVG path `d` and `fill` attributes. `TopDownMap` does.
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`SpectateVideo` mounts a full media and overlay stack for each rover:
|
||||
|
||||
```txt
|
||||
webui/src/components/SpectateVideo/index.jsx:17
|
||||
RoverMediaPlayer
|
||||
|
||||
webui/src/components/SpectateVideo/index.jsx:26
|
||||
HudOverlay variant="spectator"
|
||||
|
||||
webui/src/components/SpectateVideo/index.jsx:33
|
||||
OvercurrentOverlay
|
||||
|
||||
webui/src/components/SpectateVideo/index.jsx:35
|
||||
VerticalBatteryOverlay
|
||||
```
|
||||
|
||||
`HudOverlay` subscribes to telemetry:
|
||||
|
||||
```txt
|
||||
webui/src/components/HudOverlays/HudOverlay/index.jsx:27
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
```
|
||||
|
||||
For spectator mode, it forces the top-down map on:
|
||||
|
||||
```txt
|
||||
webui/src/components/HudOverlays/HudOverlay/index.jsx:50
|
||||
variant === 'spectator' ? true : ...
|
||||
```
|
||||
|
||||
Then `HudMapOverlay` renders:
|
||||
|
||||
```txt
|
||||
webui/src/components/HudOverlays/HudOverlay/HudMapOverlay.jsx:35
|
||||
<TopDownMap sensors={sensors} size={240} overlay />
|
||||
```
|
||||
|
||||
There is also duplicated telemetry subscription pressure. `RoverMediaPlayer` subscribes to
|
||||
telemetry for audio ducking and media state, while `HudOverlay`, battery overlays, and
|
||||
warning overlays can subscribe for the same rover.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The raw socket message rate is not automatically a problem. The expensive part is waking
|
||||
multiple React subscribers, recomputing visual sensor geometry, and mutating SVG
|
||||
attributes for every visual update.
|
||||
|
||||
On `/`, there is generally one primary driver view. On `/spectate`, the same sensor stream
|
||||
can be multiplied across several rover cards.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Add A Throttled Visual Telemetry Channel
|
||||
|
||||
This is the most reusable upstream-friendly fix.
|
||||
|
||||
Keep `TelemetryContext` storing the latest frame immediately, but notify visual consumers
|
||||
at a lower cadence:
|
||||
|
||||
```txt
|
||||
useTelemetryFrameRaw(roverId) high-frequency, only where truly needed
|
||||
useTelemetryFrameVisual(roverId) throttled display data
|
||||
```
|
||||
|
||||
Use visual telemetry for:
|
||||
|
||||
- `TopDownMap`
|
||||
- `HudOverlay`
|
||||
- `SpectatorTelemetryOverlay`
|
||||
- battery overlays
|
||||
- warning overlays where 100-200ms delay is acceptable
|
||||
|
||||
Suggested rates:
|
||||
|
||||
```txt
|
||||
desktop visual telemetry: 10-15Hz
|
||||
mobile visual telemetry: 5-10Hz
|
||||
```
|
||||
|
||||
### Option B: Pass One Telemetry Frame Through `SpectateVideo`
|
||||
|
||||
Right now, each overlay can subscribe independently. Instead, let `SpectateVideo` subscribe
|
||||
once per rover and pass `sensors` down:
|
||||
|
||||
```jsx
|
||||
const frame = useTelemetryFrameVisual(roverId);
|
||||
const sensors = frame?.sensors ?? null;
|
||||
|
||||
<RoverMediaPlayer roverId={roverId} sensors={sensors} />
|
||||
<HudOverlay roverId={roverId} sensors={sensors} />
|
||||
<OvercurrentOverlay roverId={roverId} sensors={sensors} />
|
||||
<LowBatteryOverlay roverId={roverId} sensors={sensors} />
|
||||
<VerticalBatteryOverlay roverId={roverId} sensors={sensors} />
|
||||
```
|
||||
|
||||
This may require small prop additions to the warning/battery overlays.
|
||||
|
||||
### Option C: Make Spectator HUD Maps Adaptive
|
||||
|
||||
Spectator maps are currently always on. For low-end/mobile:
|
||||
|
||||
- hide maps by default on portrait mobile
|
||||
- update maps at a lower cadence than text warnings
|
||||
- only animate the focused/first rover's map
|
||||
- show static/minimal maps for secondary rovers
|
||||
- make map visibility a spectator setting
|
||||
|
||||
### Option D: Memoize TopDownMap Geometry
|
||||
|
||||
This is useful after throttling. Avoid recalculating path data when the sensor fields that
|
||||
drive a particular path did not change.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Do this after the `/` telemetry issue if that work touches `TelemetryContext`.
|
||||
|
||||
Best first implementation:
|
||||
|
||||
1. Add `useTelemetryFrameVisual(roverId)` with a 100-150ms visual notification cadence.
|
||||
2. Switch `HudOverlay` and `TopDownMap` consumers to visual telemetry.
|
||||
3. In `SpectateVideo`, subscribe once and pass sensors down where practical.
|
||||
4. Add a spectator/mobile map policy so only the most important maps update live.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `path d` and `path fill` mutations should drop sharply.
|
||||
- `ScriptDuration` should drop.
|
||||
- Long task count should drop.
|
||||
- Frame p95/p99 should improve.
|
||||
- `sensorFrame` socket count may stay the same if only rendering is throttled.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not throttle control/safety logic that needs immediate data.
|
||||
- Keep battery/overcurrent warnings responsive enough to be trusted.
|
||||
- If map updates become too slow, operators may perceive the HUD as stale. A 5-10Hz map is
|
||||
usually enough for spectators, but verify by watching the live page.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Issue 002: Multi-Rover WebRTC Media Fan-Out
|
||||
|
||||
## Summary
|
||||
|
||||
`/spectate` mounts several `RoverMediaPlayer` instances at once. In the fresh runtime
|
||||
audit, Chromium reported 6 active `RTCPeerConnections`. That is a large baseline for
|
||||
low-end clients, especially while the page is also handling telemetry, snapshots, logs,
|
||||
chat, and SVG HUD maps.
|
||||
|
||||
## Severity
|
||||
|
||||
High.
|
||||
|
||||
This may not show up as React mutations, but it consumes CPU in browser media pipelines,
|
||||
WebRTC negotiation, decoding, audio handling, and event dispatch. It also scales with the
|
||||
number of rovers.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
Low to medium.
|
||||
|
||||
General `RoverMediaPlayer` improvements can help both `/` and `/spectate`, but the core
|
||||
problem is spectate-specific: `/spectate` intentionally displays multiple rover media
|
||||
players at once. The fix needs a spectator policy for active feeds.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/components/SpectateVideo/index.jsx`
|
||||
- `webui/src/components/RoverMediaPlayer/index.jsx`
|
||||
- `webui/src/hooks/useVideoRequests.js`
|
||||
- `webui/src/lib/whepPlayer.js`
|
||||
- `webui/src/spectate/SpectatorApp/components/RoverRow.jsx`
|
||||
- server-side WHEP/video request handling, if feed quality/rate selection is added
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
30s sample:
|
||||
|
||||
```txt
|
||||
RTCPeerConnections: 6
|
||||
JSEventListeners: 379
|
||||
```
|
||||
|
||||
The listener list is dominated by normal media listeners:
|
||||
|
||||
```txt
|
||||
audio canplay/ended/error/pause/play/stalled/waiting: 9 each
|
||||
video media events: 3 each
|
||||
```
|
||||
|
||||
That pattern fits several rover media players, likely with video and dedicated audio
|
||||
connections.
|
||||
|
||||
The same audit also saw:
|
||||
|
||||
```txt
|
||||
TaskDuration: 28.48s
|
||||
ScriptDuration: 12.11s
|
||||
Long tasks: 81
|
||||
```
|
||||
|
||||
The earlier 8x throttled `/spectate` profile saw:
|
||||
|
||||
```txt
|
||||
Task delta: 49.13s
|
||||
Script delta: 19.58s
|
||||
```
|
||||
|
||||
## Current Code Path
|
||||
|
||||
Each rover card mounts:
|
||||
|
||||
```txt
|
||||
webui/src/components/SpectateVideo/index.jsx:17
|
||||
<RoverMediaPlayer roverId={roverId} />
|
||||
```
|
||||
|
||||
`RoverMediaPlayer` automatically creates video and audio request entries:
|
||||
|
||||
```txt
|
||||
webui/src/components/RoverMediaPlayer/index.jsx
|
||||
autoEntries = [
|
||||
video rover entry,
|
||||
optional dedicated audio entry,
|
||||
]
|
||||
```
|
||||
|
||||
It then requests sources through `useVideoRequests`, starts WHEP players, maintains
|
||||
restart timers, maintains unmute/audio retry timers, and optionally falls back to
|
||||
snapshots when no WHEP URL exists.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Six peer connections on a powerful desktop may feel fine. On weak laptops and mobile
|
||||
devices, simultaneous WebRTC decode plus React UI churn can easily saturate the main
|
||||
thread or media threads. The visible symptom is not necessarily one obvious function in
|
||||
the CPU profile; it is degraded frame cadence, long tasks, and high browser-level media
|
||||
work.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Cap Active Live Feeds On Mobile
|
||||
|
||||
For mobile/low-end layouts:
|
||||
|
||||
- one primary live WHEP feed
|
||||
- secondary rover cards use snapshots or lower-rate previews
|
||||
- promote a secondary rover to live only when selected/visible/focused
|
||||
|
||||
This is the highest-impact spectator-specific design change.
|
||||
|
||||
### Option B: Pause Offscreen Feeds
|
||||
|
||||
Use `IntersectionObserver` around each `SpectateVideo` or `RoverSpectatorCard`:
|
||||
|
||||
- start WHEP when the card is visible
|
||||
- stop WHEP when the card is far offscreen
|
||||
- keep a small grace period to avoid flapping while scrolling
|
||||
|
||||
This matters especially because portrait `/spectate` can scroll.
|
||||
|
||||
### Option C: Disable Dedicated Audio For Non-Focused Rovers
|
||||
|
||||
Dedicated audio is useful, but not necessarily for every rover at the same time.
|
||||
|
||||
Possible policy:
|
||||
|
||||
- audio enabled only for the selected/primary rover
|
||||
- audio disabled for muted secondary rovers
|
||||
- audio WHEP starts only after user interaction
|
||||
|
||||
This should reduce peer connections and audio retry timers.
|
||||
|
||||
### Option D: Request Lower Quality For Spectator Secondary Feeds
|
||||
|
||||
If the server/media stack supports it, add spectator quality tiers:
|
||||
|
||||
```txt
|
||||
primary: normal FPS/resolution
|
||||
secondary: low FPS/resolution or snapshot-only
|
||||
mobile: lower default bitrate/resolution
|
||||
```
|
||||
|
||||
### Option E: Stop Retry Loops When Hidden
|
||||
|
||||
When document visibility is hidden or a card is offscreen:
|
||||
|
||||
- stop restart timers
|
||||
- stop audio retry intervals
|
||||
- avoid requesting fresh WHEP URLs
|
||||
|
||||
## Recommended Path
|
||||
|
||||
After the HUD/telemetry issue, add a spectator feed activity policy:
|
||||
|
||||
1. Define "primary" versus "secondary" rover cards.
|
||||
2. Keep only primary feeds fully live on mobile.
|
||||
3. Use `IntersectionObserver` to avoid live playback for offscreen cards.
|
||||
4. Disable secondary audio by default.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `RTCPeerConnections` should drop below 6 on mobile.
|
||||
- Media listener counts should drop.
|
||||
- `TaskDuration` should drop.
|
||||
- Frame p95/p99 should improve.
|
||||
- User-visible video should still start reliably for the primary rover.
|
||||
|
||||
## Risks
|
||||
|
||||
- Spectators may expect all feeds live all the time on desktop.
|
||||
- Switching feeds can introduce startup delay if WHEP is torn down too aggressively.
|
||||
- Audio policy needs to avoid surprising users. Make the focused rover's audio behavior
|
||||
clear and consistent.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Issue 003: Room Camera And Snapshot Object URL Churn
|
||||
|
||||
## Summary
|
||||
|
||||
Room camera and rover snapshot frames are delivered as binary payloads. The client creates
|
||||
a new `Blob`, creates a new object URL, revokes the previous URL, and updates React state
|
||||
for every frame. In the 30s `/spectate` audit, this caused 117 `img.src` mutations and
|
||||
11MB of binary socket payloads.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
This is not the biggest measured CPU source, but it is steady work and very visible on
|
||||
low-end devices because JPEG decoding, Blob allocation, object URL churn, image source
|
||||
updates, and React state updates all happen together.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
Low.
|
||||
|
||||
This is mostly `/spectate` and camera-panel specific. It will not be fixed by the `/`
|
||||
input, control context, or log work. It may benefit indirectly if broad rerenders are
|
||||
reduced, but the frame handling itself needs targeted changes.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/hooks/useRoomCameraSnapshots.js`
|
||||
- `webui/src/hooks/useRoverSnapshots.js`
|
||||
- `webui/src/components/RoomCameraPanel/index.jsx`
|
||||
- `webui/src/components/RoomCameraFeed/index.jsx`
|
||||
- `server/src/services/roomCameraService/socketGateway.js`
|
||||
- `server/src/services/roverSnapshotService/socketGateway.js`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
30s sample:
|
||||
|
||||
```txt
|
||||
unlabeled binary socket events: 126
|
||||
unlabeled binary bytes: 11,048,477
|
||||
roomCamera:frame events: 117
|
||||
roverSnapshot:frame events: 7
|
||||
img src mutations: 117
|
||||
blink class mutations: 117
|
||||
```
|
||||
|
||||
The 8x throttled profile also had these CPU entries:
|
||||
|
||||
```txt
|
||||
Blob
|
||||
decodeString
|
||||
setAttribute
|
||||
```
|
||||
|
||||
Those line up with binary socket parsing, Blob/object URL creation, and `img.src` updates.
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`useRoomCameraSnapshots` handles each frame:
|
||||
|
||||
```txt
|
||||
webui/src/hooks/useRoomCameraSnapshots.js:78
|
||||
const blob = new Blob([buffer], { type: 'image/jpeg' });
|
||||
|
||||
webui/src/hooks/useRoomCameraSnapshots.js:79
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
webui/src/hooks/useRoomCameraSnapshots.js:82
|
||||
URL.revokeObjectURL(prevUrl);
|
||||
|
||||
webui/src/hooks/useRoomCameraSnapshots.js:85
|
||||
setFeeds(...)
|
||||
```
|
||||
|
||||
`useRoverSnapshots` has the same pattern.
|
||||
|
||||
`RoomCameraFeed` writes that object URL into an image:
|
||||
|
||||
```txt
|
||||
webui/src/components/RoomCameraFeed/index.jsx:24
|
||||
<img src={feed.objectUrl} ... />
|
||||
```
|
||||
|
||||
It also toggles blink state for each frame:
|
||||
|
||||
```txt
|
||||
webui/src/components/RoomCameraFeed/index.jsx:12
|
||||
setBlink((prev) => !prev);
|
||||
```
|
||||
|
||||
Server-side frame rates:
|
||||
|
||||
```txt
|
||||
server/src/services/roomCameraService/socketGateway.js:11
|
||||
STREAM_INTERVAL_MS = 1000
|
||||
|
||||
server/src/services/roverSnapshotService/socketGateway.js:11
|
||||
STREAM_INTERVAL_MS = 333
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The page is doing memory allocation and DOM/image decode work on every camera frame. At
|
||||
one room camera per second this can be acceptable, but `/spectate` is already busy. When
|
||||
combined with live WebRTC and telemetry maps, this becomes another steady CPU drain.
|
||||
|
||||
The blink dot is also doing one React state update and class mutation per frame. It is
|
||||
small, but it is pure extra work.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Drop Frames When The Client Is Behind
|
||||
|
||||
Keep only the latest pending frame per camera. If the previous object URL has not been
|
||||
painted or decoded yet, replace the pending buffer instead of forcing every frame through
|
||||
React.
|
||||
|
||||
### Option B: Throttle Display Updates On Mobile
|
||||
|
||||
Render room camera frames at a lower client-side display rate:
|
||||
|
||||
```txt
|
||||
desktop: 1fps as today, or configured
|
||||
mobile: 0.2-0.5fps for secondary room cameras
|
||||
```
|
||||
|
||||
The server can still emit at its existing rate for other clients, but this client can
|
||||
choose not to render every frame.
|
||||
|
||||
### Option C: Lazy Mount Or Collapse Room Cameras On Mobile
|
||||
|
||||
On portrait mobile, `SecondaryRow` can defer `RoomCameraPanel` until:
|
||||
|
||||
- the user scrolls near it
|
||||
- the user opens the panel
|
||||
- the page is idle after initial rover feeds have settled
|
||||
|
||||
### Option D: Remove Per-Frame React Blink State
|
||||
|
||||
Replace the blink toggle with CSS animation or a timestamp updated less often.
|
||||
|
||||
For example:
|
||||
|
||||
- keep status text
|
||||
- use a CSS pulse class while status is `playing`
|
||||
- avoid toggling React state on every frame
|
||||
|
||||
### Option E: Stabilize Room Camera Source Lists
|
||||
|
||||
`RoomCameraPanel` currently passes a newly created array of objects into the snapshot hook:
|
||||
|
||||
```txt
|
||||
webui/src/components/RoomCameraPanel/index.jsx:37
|
||||
useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })))
|
||||
```
|
||||
|
||||
The hook ultimately keys by IDs, so this is not the main issue, but it still creates extra
|
||||
memo/effect churn on rerender. Prefer a memoized array of IDs:
|
||||
|
||||
```jsx
|
||||
const cameraIds = useMemo(() => cameras.map((camera) => camera.id), [cameras]);
|
||||
const feedMap = useRoomCameraSnapshots(cameraIds);
|
||||
```
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Start with the cheap fixes:
|
||||
|
||||
1. Remove the per-frame blink state.
|
||||
2. Memoize camera IDs in `RoomCameraPanel`.
|
||||
3. Add client-side render throttling/drop-latest behavior in `useRoomCameraSnapshots`.
|
||||
4. Consider lazy mounting/collapsing room cameras in mobile spectator layout.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `img.h-full.w-full:attributes:src` mutations should drop if display is throttled.
|
||||
- `span.h-2.w-2:attributes:class` mutations should disappear if blink state is removed.
|
||||
- `ScriptDuration` and long tasks should improve modestly.
|
||||
- Binary socket bytes may stay the same unless server subscription/rate is changed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Reducing room camera update rate can make the room view feel stale.
|
||||
- Object URL lifecycle must stay correct. Do not leak old URLs.
|
||||
- If lazy mounting unsubscribes too aggressively, first frame display may feel delayed.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Issue 004: Broad Spectator Session Rerenders
|
||||
|
||||
## Summary
|
||||
|
||||
`SpectatorContent` reads the whole session object with `useSession()`. That means broad
|
||||
session changes can rerender the top-level spectator layout, including the sidebar, rover
|
||||
row, secondary row, chat, logs, room cameras, and overlays.
|
||||
|
||||
The audit saw 54 `session:sync` events in 30s, carrying about 914KB, while the DOM also
|
||||
showed repeated added/removed-node bursts.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
This is likely a multiplier. It may not be the single hottest path, but it can cause
|
||||
otherwise independent components to rerender together.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
Medium to high.
|
||||
|
||||
If the `/` backlog changes `SessionContext` to provide better selectors, better structural
|
||||
sharing, batched log/session updates, or more stable action references, this issue may be
|
||||
partly fixed upstream. However, `SpectatorContent` itself still needs to stop reading the
|
||||
whole session object.
|
||||
|
||||
Related upstream issues:
|
||||
|
||||
```txt
|
||||
perf/issues/003-high-volume-log-stream.md
|
||||
perf/issues/006-chat-and-nickname-churn.md
|
||||
perf/issues/007-timers-and-polling.md
|
||||
```
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
|
||||
- `webui/src/hooks/useSpectatorMode.js`
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `webui/src/spectate/SpectatorApp/components/RoverRow.jsx`
|
||||
- `webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx`
|
||||
- sidebar components mounted by `SpectatorContent`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
30s sample:
|
||||
|
||||
```txt
|
||||
session:sync events: 54
|
||||
session:sync bytes: 914,000
|
||||
DOM mutation records: 5,868
|
||||
DOM nodes added: 440
|
||||
DOM nodes removed: 440
|
||||
```
|
||||
|
||||
Largest mutation bursts:
|
||||
|
||||
```txt
|
||||
119 records, 57 added, 58 removed
|
||||
117 records, 52 added, 53 removed
|
||||
113 records, 52 added, 52 removed
|
||||
102 records, 49 added, 49 removed
|
||||
100 records, 48 added, 48 removed
|
||||
```
|
||||
|
||||
Those bursts suggest periodic component/list updates, not just single text changes.
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`SpectatorContent` subscribes to the whole session:
|
||||
|
||||
```txt
|
||||
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:22
|
||||
const { session } = useSession();
|
||||
```
|
||||
|
||||
It then derives:
|
||||
|
||||
```txt
|
||||
inLockdown = session?.mode === 'lockdown'
|
||||
roster = session?.roster ?? []
|
||||
```
|
||||
|
||||
But because it reads the whole session object, changes to logs, users, active drivers,
|
||||
room cameras, replay state, chat-related session fields, or any other session branch can
|
||||
potentially re-render the whole spectator layout.
|
||||
|
||||
`useSpectatorMode` also reads the whole session:
|
||||
|
||||
```txt
|
||||
webui/src/hooks/useSpectatorMode.js:7
|
||||
const { session, setRole, subscribeAll, connected } = useSession();
|
||||
```
|
||||
|
||||
It only needs `mode`, `role`, `connected`, and actions.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
`/spectate` is already rendering expensive children. A broad rerender at the top makes
|
||||
other optimizations less effective because unrelated live data can still wake the route.
|
||||
|
||||
For example:
|
||||
|
||||
- a log entry should not rerender the rover media grid
|
||||
- a chat composer state change should not rerender room camera feeds
|
||||
- a session sync should not recreate layout props unless the selected fields changed
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Replace Whole Session Reads With Selectors
|
||||
|
||||
In `SpectatorContent`, use precise selectors:
|
||||
|
||||
```jsx
|
||||
const mode = useSessionSelector((state) => state.session?.mode ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? EMPTY_ROSTER);
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```jsx
|
||||
const inLockdown = mode === 'lockdown';
|
||||
```
|
||||
|
||||
Make sure selector outputs are stable. If `roster` is rebuilt on every `session:sync`,
|
||||
this still rerenders. The store may need structural sharing upstream.
|
||||
|
||||
### Option B: Split Spectator Layout Into Memoized Regions
|
||||
|
||||
Split the page into:
|
||||
|
||||
- spectator sidebar
|
||||
- rover grid
|
||||
- secondary/camera row
|
||||
- global overlays
|
||||
|
||||
Then each region subscribes only to what it needs.
|
||||
|
||||
### Option C: Narrow `useSpectatorMode`
|
||||
|
||||
Replace:
|
||||
|
||||
```jsx
|
||||
const { session, setRole, subscribeAll, connected } = useSession();
|
||||
```
|
||||
|
||||
with selectors/actions:
|
||||
|
||||
```jsx
|
||||
const mode = useSessionSelector((state) => state.session?.mode ?? null);
|
||||
const role = useSessionSelector((state) => state.session?.role ?? null);
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const { setRole, subscribeAll } = useSessionActions();
|
||||
```
|
||||
|
||||
### Option D: Batch Or Diff `session:sync`
|
||||
|
||||
This may belong to the `/` backlog. If incoming sync payloads replace large object
|
||||
branches each time, selector users will still rerender. Preserve references for unchanged
|
||||
branches.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Do this after the `/` session/log work if that work changes `SessionContext`.
|
||||
|
||||
Local `/spectate` changes:
|
||||
|
||||
1. Remove whole-session `useSession()` from `SpectatorContent`.
|
||||
2. Remove whole-session `useSession()` from `useSpectatorMode`.
|
||||
3. Split the layout so rover media rows do not depend on sidebar data.
|
||||
4. Re-test mutation bursts and React commit behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- Fewer added/removed DOM mutation bursts.
|
||||
- Lower `ScriptDuration`.
|
||||
- Possibly lower `RecalcStyleCount` and `LayoutCount`.
|
||||
- `session:sync` socket counts may stay the same unless server/store work changes them.
|
||||
|
||||
## Risks
|
||||
|
||||
- Selector equality matters. Returning new arrays/objects from selectors can erase the win.
|
||||
- Be careful with lockdown mode; the full-page lockdown branch must still update promptly.
|
||||
- If `roster` reference stability is poor upstream, this issue may need `SessionContext`
|
||||
structural sharing before it fully improves.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Issue 005: Shared Log And Session Stream Pressure
|
||||
|
||||
## Summary
|
||||
|
||||
`/spectate` receives a large volume of shared live socket traffic, especially `log:entry`,
|
||||
`session:sync`, `sensorFrame`, and `commandAck`. Some of this will likely be fixed by the
|
||||
`/` backlog, but it remains important for `/spectate` because the route keeps logs/chat
|
||||
visible in the sidebar while also rendering multiple live media feeds.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium.
|
||||
|
||||
This is not as spectate-specific as the HUD map or WebRTC fan-out issues, but it adds
|
||||
steady parsing, state, and render pressure.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
High for logs and shared session pressure.
|
||||
|
||||
If the `/` work implements log batching, log virtualization, selector-based session
|
||||
updates, and chat/nickname churn reduction, a lot of this issue should improve without
|
||||
special `/spectate` changes.
|
||||
|
||||
Related upstream issues:
|
||||
|
||||
```txt
|
||||
perf/issues/003-high-volume-log-stream.md
|
||||
perf/issues/006-chat-and-nickname-churn.md
|
||||
perf/issues/007-timers-and-polling.md
|
||||
perf/issues/004-sensor-telemetry-render-frequency.md
|
||||
```
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
|
||||
- `webui/src/spectate/SpectatorApp/components/LogsRow.jsx`
|
||||
- `webui/src/components/ChatPanel/index.jsx`
|
||||
- `webui/src/components/RawUserPilePanel/index.jsx`
|
||||
- `webui/src/components/RoverQueuesPanel/index.jsx`
|
||||
- server log/session emitters
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
30s sample:
|
||||
|
||||
```txt
|
||||
log:entry events: 634
|
||||
log:entry bytes: 150,179
|
||||
session:sync events: 54
|
||||
session:sync bytes: 914,000
|
||||
commandAck events: 68
|
||||
chat:init bytes: 77,415
|
||||
log:init bytes: 44,530
|
||||
```
|
||||
|
||||
Text and input mutations also appeared:
|
||||
|
||||
```txt
|
||||
Text characterData mutations: 164
|
||||
input.field-input.flex-1 name mutations: 102
|
||||
input.chat-composer-input name mutations: 102
|
||||
```
|
||||
|
||||
The input-name churn matches the same type of sidebar/chat behavior observed on `/`.
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`SpectatorContent` keeps these mounted:
|
||||
|
||||
```txt
|
||||
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:88
|
||||
<ChatPanel allowSpectatorInput ... />
|
||||
|
||||
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:91
|
||||
<LogsRow ... />
|
||||
|
||||
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:79
|
||||
<RoverQueuesPanel title="Rovers" />
|
||||
|
||||
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:68
|
||||
<RawUserPilePanel ... />
|
||||
```
|
||||
|
||||
That is useful spectator UI, but it means `/spectate` pays for general page state while
|
||||
also paying for media and telemetry.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Logs and session syncs are deceptively expensive:
|
||||
|
||||
- socket payloads must be parsed
|
||||
- state must be updated
|
||||
- arrays/lists may be copied
|
||||
- list components may rerender
|
||||
- text/input attributes may mutate
|
||||
|
||||
This becomes worse on `/spectate` because the route is not just a dashboard. It is also a
|
||||
multi-feed media surface.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Reuse The `/` Log Fix
|
||||
|
||||
The `/` log issue should probably introduce:
|
||||
|
||||
- batching log updates
|
||||
- capping retained logs
|
||||
- virtualizing or windowing visible rows
|
||||
- pausing log rendering when collapsed/offscreen
|
||||
- separating log ingestion from log rendering
|
||||
|
||||
Apply the same path to `LogsRow`.
|
||||
|
||||
### Option B: Make Spectator Sidebar Panels Independently Subscribed
|
||||
|
||||
The rover media grid should not rerender because logs/chat/users changed. This overlaps
|
||||
with issue 004.
|
||||
|
||||
### Option C: Lower Or Gate Command/Log Visibility For Mobile
|
||||
|
||||
On mobile spectator layout, logs are less important than video health. Options:
|
||||
|
||||
- collapse logs by default
|
||||
- batch logs while collapsed
|
||||
- show only warning/error logs by default
|
||||
- update visible logs at 1-2Hz
|
||||
|
||||
### Option D: Reduce `session:sync` Payload Churn
|
||||
|
||||
If the server sends full session snapshots frequently, consider:
|
||||
|
||||
- event-specific deltas for high-frequency branches
|
||||
- preserving client-side branch references for unchanged data
|
||||
- moving logs/chat out of the broad session path if they are currently coupled
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Do not start here if you are working the `/` list first. Fix the `/` log/session/chat
|
||||
items, then rerun `/spectate` and reassess.
|
||||
|
||||
If still hot on `/spectate`:
|
||||
|
||||
1. Make `LogsRow` batch/virtualize/collapse.
|
||||
2. Keep the media grid isolated from sidebar state.
|
||||
3. Add a mobile spectator mode that reduces log rendering frequency.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- Lower `Text:characterData` mutations.
|
||||
- Fewer added/removed DOM mutation bursts.
|
||||
- Lower `ScriptDuration`.
|
||||
- `log:entry` socket count may remain high if only rendering is batched.
|
||||
|
||||
## Risks
|
||||
|
||||
- Logs are operationally useful; do not hide critical warnings.
|
||||
- If batching is too aggressive, the UI can feel stale.
|
||||
- Be careful not to mix log ingestion throttling with log display throttling. Usually,
|
||||
display throttling is safer.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Issue 006: Spectator Role Handshake Retries
|
||||
|
||||
## Summary
|
||||
|
||||
The spectator route sometimes attempts `subscribeAll` before the server has accepted the
|
||||
socket as a spectator. In the 8x throttled profile, the console repeatedly showed:
|
||||
|
||||
```txt
|
||||
Failed to enter spectator mode Error: Spectator role required
|
||||
```
|
||||
|
||||
This did not reproduce in the later 6x runtime audit, so it is intermittent. It is still
|
||||
worth fixing because it can create startup retries, extra session/auth events, console
|
||||
noise, and delayed media setup on slow devices.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-low.
|
||||
|
||||
This is not the main steady-state CPU eater. Treat it as a correctness/startup reliability
|
||||
issue that can make performance worse under throttling.
|
||||
|
||||
## Upstream Likelihood
|
||||
|
||||
Low.
|
||||
|
||||
This is specific to `/spectate` startup and server role/subscription ordering. The `/`
|
||||
performance backlog probably will not fix it.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/hooks/useSpectatorMode.js`
|
||||
- `webui/src/context/SocketContext.jsx`
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `server/src/services/authService/index.js`
|
||||
- `server/src/services/roverManager/socketHandlers.js`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-14-34-168Z/report.json
|
||||
```
|
||||
|
||||
That 8x CPU-throttled profile captured repeated console errors:
|
||||
|
||||
```txt
|
||||
Failed to enter spectator mode Error: Spectator role required
|
||||
```
|
||||
|
||||
Fresh 6x runtime audit:
|
||||
|
||||
```txt
|
||||
consoleErrors: []
|
||||
auth:role events: 4
|
||||
```
|
||||
|
||||
So the race is not constant, but it exists under some timing conditions.
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`useSpectatorMode` does this:
|
||||
|
||||
```txt
|
||||
webui/src/hooks/useSpectatorMode.js:18
|
||||
if (session?.role !== 'spectator') {
|
||||
await setRole('spectator');
|
||||
}
|
||||
|
||||
webui/src/hooks/useSpectatorMode.js:21
|
||||
await subscribeAll();
|
||||
```
|
||||
|
||||
The effect reruns when these change:
|
||||
|
||||
```txt
|
||||
webui/src/hooks/useSpectatorMode.js:36
|
||||
[connected, session?.mode, session?.role, setRole, subscribeAll]
|
||||
```
|
||||
|
||||
The server rejects `subscribeAll` unless the socket role is already spectator:
|
||||
|
||||
```txt
|
||||
server/src/services/roverManager/socketHandlers.js:161
|
||||
if (socket.data?.role !== 'spectator') {
|
||||
cb({ error: 'Spectator role required' });
|
||||
}
|
||||
```
|
||||
|
||||
The auth service can initialize a socket as spectator only if the socket handshake query
|
||||
requests it:
|
||||
|
||||
```txt
|
||||
server/src/services/authService/index.js:40
|
||||
const requestedRole = socket.handshake?.query?.role;
|
||||
|
||||
server/src/services/authService/index.js:41
|
||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||
```
|
||||
|
||||
If the spectate page connects as a normal user and then switches role after connect, slow
|
||||
timing can expose ordering issues.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The retry itself is not a huge CPU cost. The damage is indirect:
|
||||
|
||||
- extra auth/session events
|
||||
- repeated `subscribeAll` attempts
|
||||
- delayed rover room joins
|
||||
- delayed video/snapshot setup
|
||||
- noisy console errors during profiling
|
||||
- worse startup behavior on weak devices
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Connect `/spectate` With `role=spectator`
|
||||
|
||||
Make the spectator route establish its socket with a spectator role query from the start:
|
||||
|
||||
```txt
|
||||
io(..., { query: { role: 'spectator' } })
|
||||
```
|
||||
|
||||
This uses the existing server path in `authService`.
|
||||
|
||||
### Option B: Wait For Confirmed Role Before `subscribeAll`
|
||||
|
||||
After calling `setRole('spectator')`, wait until `auth:role` or session state confirms
|
||||
`role === 'spectator'`, then call `subscribeAll`.
|
||||
|
||||
Avoid calling `subscribeAll` in the same effect tick if the role update has not propagated.
|
||||
|
||||
### Option C: Add A Server-Side Atomic Spectator Enter Event
|
||||
|
||||
Create one event:
|
||||
|
||||
```txt
|
||||
session:enterSpectator
|
||||
```
|
||||
|
||||
Server behavior:
|
||||
|
||||
1. set socket role to spectator
|
||||
2. join all visible rover rooms
|
||||
3. return one ack
|
||||
|
||||
This removes client-side ordering risk.
|
||||
|
||||
### Option D: Debounce Or Guard Retries
|
||||
|
||||
If `subscribeAll` fails with `Spectator role required`, do not retry in a tight loop. Wait
|
||||
for an explicit role event or connection change.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
The cleanest fix is Option A plus Option B:
|
||||
|
||||
1. Make the `/spectate` socket connect as `role=spectator` when possible.
|
||||
2. Keep `useSpectatorMode` as a fallback, but call `subscribeAll` only after confirmed
|
||||
spectator role.
|
||||
|
||||
If socket creation is shared in a way that makes route-specific query parameters awkward,
|
||||
use the atomic server event instead.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the throttled profile several times:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=8 VIEWPORT=390x844 MOBILE=1 \
|
||||
node perf/live-cpu-profile.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- No `Failed to enter spectator mode` console errors.
|
||||
- Fewer `auth:role` events during startup.
|
||||
- More consistent startup timing.
|
||||
|
||||
## Risks
|
||||
|
||||
- Be careful not to accidentally make ordinary `/` users spectators.
|
||||
- If using route-specific socket query parameters, verify reconnects preserve the correct
|
||||
role.
|
||||
- In lockdown mode, spectator setup must still fail cleanly.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# `/spectate` CPU Performance Issue Index
|
||||
|
||||
This folder breaks the live-site `/spectate` CPU investigation into separate,
|
||||
work-ready issues. These are intentionally separate from `perf/issues`, which is the
|
||||
current `/` backlog.
|
||||
|
||||
The testing target was:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/spectate
|
||||
```
|
||||
|
||||
Primary artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
Secondary artifacts:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-14-34-168Z/report.json
|
||||
perf/results/2026-06-11T03-11-39-596Z-route-sweep/route-sweep-report.json
|
||||
```
|
||||
|
||||
## Ranked Issues
|
||||
|
||||
1. [Telemetry fan-out and always-on HUD maps](./001-telemetry-fanout-hud-maps.md)
|
||||
2. [Multi-rover WebRTC media fan-out](./002-multi-rover-webrtc-media-fanout.md)
|
||||
3. [Room camera and snapshot object URL churn](./003-room-camera-snapshot-object-url-churn.md)
|
||||
4. [Broad spectator session rerenders](./004-broad-spectator-session-rerenders.md)
|
||||
5. [Shared log/session stream pressure](./005-shared-log-session-stream-pressure.md)
|
||||
6. [Spectator role handshake retries](./006-spectator-role-handshake-retries.md)
|
||||
|
||||
## Quick Read
|
||||
|
||||
The biggest `/spectate` CPU issue is the number of live visual things being updated at
|
||||
once. The page receives about 45 `sensorFrame` events/sec in the sampled run, and every
|
||||
rover card has an always-on HUD map. That produced thousands of SVG `path` mutations in
|
||||
30 seconds.
|
||||
|
||||
The second issue is live media fan-out. The audit saw 6 `RTCPeerConnections`, which is a
|
||||
lot for low-end laptops and mobile browsers, especially alongside React/HUD updates.
|
||||
|
||||
The third issue is snapshot/camera image churn. Room camera frames created 117 `img.src`
|
||||
mutations and the socket audit saw 11MB of binary payloads in 30 seconds.
|
||||
|
||||
Issues 004 and 005 are where the `/` backlog overlaps most. If the `/` work adds better
|
||||
session selectors, log batching, chat batching, and telemetry visual throttling, revisit
|
||||
these spectate writeups before implementing them from scratch.
|
||||
|
||||
Issue 006 is lower priority because it was intermittent, but it is worth fixing because
|
||||
failed spectator setup can create extra retries, console noise, and unpredictable startup
|
||||
work.
|
||||
|
||||
## Upstream Labels
|
||||
|
||||
Each issue includes an "Upstream Likelihood" section:
|
||||
|
||||
- `High`: likely mostly fixed by the earlier `/` work.
|
||||
- `Medium`: `/` work should help, but `/spectate` still needs targeted changes.
|
||||
- `Low`: mostly spectate-specific.
|
||||
|
||||
Reference in New Issue
Block a user