Skip to content

Commit 053e81b

Browse files
authored
feat: add daemon cleanup stress test and tighten stream foreground handling (#59)
* feat: add daemon cleanup stress test and idle stream gating * fix: cap auto hardware video streams
1 parent df55899 commit 053e81b

10 files changed

Lines changed: 615 additions & 23 deletions

File tree

cli/XCWH264Encoder.m

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@
4747
static const double XCWHardwareFallbackLoadPercent = 500.0;
4848
static const NSUInteger XCWHardwareFallbackConsecutiveOverBudgetFrameThreshold = 60;
4949
static const uint64_t XCWAutoHardwareRetryIntervalUs = 10000000;
50+
static const NSUInteger XCWMaximumAutoHardwareEncoders = 1;
5051
static void *XCWH264EncoderQueueSpecificKey = &XCWH264EncoderQueueSpecificKey;
52+
static os_unfair_lock XCWAutoHardwareEncoderLock = OS_UNFAIR_LOCK_INIT;
53+
static NSUInteger XCWActiveAutoHardwareEncoderCount = 0;
5154

5255
typedef NS_ENUM(NSUInteger, XCWVideoEncoderMode) {
5356
XCWVideoEncoderModeAuto,
@@ -575,6 +578,8 @@ - (void)recordEncodeLatencyLockedWithSubmittedAtUs:(uint64_t)submittedAtUs measu
575578
- (void)invalidateX264EncoderLocked;
576579
- (void)handleCompressionOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
577580
submittedAtUs:(uint64_t)submittedAtUs;
581+
- (BOOL)acquireAutoHardwareSlotIfNeededLocked;
582+
- (void)releaseAutoHardwareSlotIfNeededLocked;
578583
- (uint64_t)activeFrameIntervalUsLocked;
579584
- (uint64_t)encoderLatencyBudgetUsLocked;
580585
- (uint64_t)pacingDelayBeforeNextFrameAtTimeUs:(uint64_t)nowUs;
@@ -603,6 +608,7 @@ @implementation XCWH264Encoder {
603608
BOOL _scalingActive;
604609
XCWVideoEncoderMode _encoderMode;
605610
XCWVideoEncoderMode _activeEncoderMode;
611+
BOOL _holdsAutoHardwareSlot;
606612
BOOL _clientForeground;
607613
BOOL _acceptingFrameInput;
608614
BOOL _lowLatencyMode;
@@ -725,6 +731,7 @@ - (void)requestKeyFrame {
725731

726732
- (void)reconfigureForStreamQualityChange {
727733
dispatch_async(_queue, ^{
734+
[self releaseAutoHardwareSlotIfNeededLocked];
728735
[self invalidateCompressionSessionLocked];
729736
self->_encoderMode = XCWVideoEncoderModeFromEnvironment();
730737
self->_activeEncoderMode = self->_encoderMode;
@@ -761,6 +768,7 @@ - (void)setClientForeground:(BOOL)foreground {
761768
}
762769
os_unfair_lock_unlock(&self->_pendingLock);
763770
if (!foreground) {
771+
[self releaseAutoHardwareSlotIfNeededLocked];
764772
[self invalidateCompressionSessionLocked];
765773
self->_needsKeyFrame = YES;
766774
return;
@@ -881,6 +889,7 @@ - (NSDictionary *)statsRepresentation {
881889
@"encoderMode": XCWVideoEncoderModeName(self->_encoderMode),
882890
@"activeEncoderMode": XCWVideoEncoderModeName(self->_activeEncoderMode),
883891
@"clientForeground": @(self->_clientForeground),
892+
@"autoHardwareSlot": @(self->_holdsAutoHardwareSlot),
884893
@"autoSoftwareFallbackActive": @(autoSoftwareFallbackActive),
885894
@"autoSoftwareFallbackRemainingUs": @(autoSoftwareFallbackRemainingUs),
886895
@"autoSoftwareFallbacks": @(self->_autoSoftwareFallbackCount),
@@ -902,6 +911,7 @@ - (NSDictionary *)statsRepresentation {
902911
- (void)invalidate {
903912
dispatch_sync(_queue, ^{
904913
[self drainPendingFramesLocked];
914+
[self releaseAutoHardwareSlotIfNeededLocked];
905915
[self invalidateCompressionSessionLocked];
906916
});
907917

@@ -976,10 +986,44 @@ - (void)resetAutoFallbackLatencyStateLocked {
976986
_wasOverloaded = NO;
977987
}
978988

989+
- (BOOL)acquireAutoHardwareSlotIfNeededLocked {
990+
if (_encoderMode != XCWVideoEncoderModeAuto || !_clientForeground) {
991+
return NO;
992+
}
993+
if (_holdsAutoHardwareSlot) {
994+
return YES;
995+
}
996+
997+
BOOL acquired = NO;
998+
os_unfair_lock_lock(&XCWAutoHardwareEncoderLock);
999+
if (XCWActiveAutoHardwareEncoderCount < XCWMaximumAutoHardwareEncoders) {
1000+
XCWActiveAutoHardwareEncoderCount += 1;
1001+
acquired = YES;
1002+
}
1003+
os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock);
1004+
_holdsAutoHardwareSlot = acquired;
1005+
return acquired;
1006+
}
1007+
1008+
- (void)releaseAutoHardwareSlotIfNeededLocked {
1009+
if (!_holdsAutoHardwareSlot) {
1010+
return;
1011+
}
1012+
os_unfair_lock_lock(&XCWAutoHardwareEncoderLock);
1013+
if (XCWActiveAutoHardwareEncoderCount > 0) {
1014+
XCWActiveAutoHardwareEncoderCount -= 1;
1015+
}
1016+
os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock);
1017+
_holdsAutoHardwareSlot = NO;
1018+
}
1019+
9791020
- (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode {
9801021
if (_activeEncoderMode == mode) {
9811022
return;
9821023
}
1024+
if (mode != XCWVideoEncoderModeAuto) {
1025+
[self releaseAutoHardwareSlotIfNeededLocked];
1026+
}
9831027
_activeEncoderMode = mode;
9841028
_codecType = XCWVideoCodecTypeForMode(_activeEncoderMode);
9851029
if (_activeEncoderMode == XCWVideoEncoderModeH264Software) {
@@ -996,17 +1040,27 @@ - (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode {
9961040
}
9971041

9981042
- (void)updateActiveEncoderModeForClientForegroundLockedAtTimeUs:(uint64_t)nowUs {
999-
if (_encoderMode == XCWVideoEncoderModeAuto &&
1000-
_autoSoftwareFallbackUntilUs != 0 &&
1001-
nowUs < _autoSoftwareFallbackUntilUs) {
1043+
if (_encoderMode != XCWVideoEncoderModeAuto) {
1044+
[self switchActiveEncoderModeLocked:_encoderMode];
1045+
return;
1046+
}
1047+
if (!_clientForeground) {
1048+
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
1049+
return;
1050+
}
1051+
if (_autoSoftwareFallbackUntilUs != 0 && nowUs < _autoSoftwareFallbackUntilUs) {
10021052
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
10031053
return;
10041054
}
1005-
if (_encoderMode == XCWVideoEncoderModeAuto && _autoSoftwareFallbackUntilUs != 0) {
1055+
if (_autoSoftwareFallbackUntilUs != 0) {
10061056
_autoSoftwareFallbackUntilUs = 0;
10071057
_autoHardwareRetryCount += 1;
10081058
}
1009-
[self switchActiveEncoderModeLocked:_encoderMode];
1059+
if ([self acquireAutoHardwareSlotIfNeededLocked]) {
1060+
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto];
1061+
} else {
1062+
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
1063+
}
10101064
}
10111065

10121066
- (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs {
@@ -1019,18 +1073,6 @@ - (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs {
10191073
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software];
10201074
}
10211075

1022-
- (void)retryAutoHardwareIfNeededLockedAtTimeUs:(uint64_t)nowUs {
1023-
if (![self isAutoSoftwareFallbackActiveLocked] ||
1024-
!_clientForeground ||
1025-
_autoSoftwareFallbackUntilUs == 0 ||
1026-
nowUs < _autoSoftwareFallbackUntilUs) {
1027-
return;
1028-
}
1029-
_autoSoftwareFallbackUntilUs = 0;
1030-
_autoHardwareRetryCount += 1;
1031-
[self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto];
1032-
}
1033-
10341076
- (uint64_t)activeFrameIntervalUsLocked {
10351077
if (_activeEncoderMode == XCWVideoEncoderModeH264Software) {
10361078
return _softwareFrameIntervalUs > 0 ? _softwareFrameIntervalUs : [self initialSoftwareFrameIntervalUsLocked];
@@ -1238,7 +1280,7 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer {
12381280
}
12391281

12401282
uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0);
1241-
[self retryAutoHardwareIfNeededLockedAtTimeUs:nowUs];
1283+
[self updateActiveEncoderModeForClientForegroundLockedAtTimeUs:nowUs];
12421284

12431285
CGSize targetSize = XCWScaledDimensionsForSourceSize(sourceWidth, sourceHeight, _activeEncoderMode, _lowLatencyMode, _realtimeStreamMode);
12441286
int32_t targetWidth = (int32_t)targetSize.width;

client/src/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface EncoderStats {
22
activeEncoderMode?: string;
33
averageEncodeLatencyUs?: number;
44
averageEncoderLoadPercent?: number;
5+
autoHardwareSlot?: boolean;
56
autoHardwareRetries?: number;
67
autoSoftwareFallbackActive?: boolean;
78
autoSoftwareFallbackRemainingUs?: number;

client/src/features/stream/useLiveStream.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ function isDocumentForeground(): boolean {
103103
return document.visibilityState === "visible";
104104
}
105105

106+
function isViewerForeground(canvasVisible: boolean): boolean {
107+
return isDocumentForeground() && canvasVisible;
108+
}
109+
106110
export function useLiveStream({
107111
canvasElement,
108112
paused = false,
@@ -122,6 +126,7 @@ export function useLiveStream({
122126
const retainedFrameRef = useRef(false);
123127
const previousSimulatorUdidRef = useRef<string | undefined>(simulator?.udid);
124128
const connectedStreamTargetKeyRef = useRef("");
129+
const canvasVisibleRef = useRef(true);
125130
const latestVisualArtifactRef = useRef<VisualArtifactSample | null>(null);
126131
const latestVisualArtifactSampleCountRef = useRef(0);
127132
const lastVisualArtifactSampleAtRef = useRef(0);
@@ -223,6 +228,40 @@ export function useLiveStream({
223228
};
224229
}, []);
225230

231+
useEffect(() => {
232+
if (!canvasElement || !simulator?.udid || paused) {
233+
return;
234+
}
235+
236+
const sendCanvasForegroundState = () => {
237+
workerClientRef.current?.sendStreamControl({
238+
clientId: clientTelemetryIdRef.current,
239+
foreground: isViewerForeground(canvasVisibleRef.current),
240+
});
241+
};
242+
243+
if (typeof IntersectionObserver !== "function") {
244+
canvasVisibleRef.current = true;
245+
sendCanvasForegroundState();
246+
return;
247+
}
248+
249+
const observer = new IntersectionObserver(
250+
(entries) => {
251+
const entry = entries[entries.length - 1];
252+
canvasVisibleRef.current = Boolean(
253+
entry?.isIntersecting && entry.intersectionRatio > 0,
254+
);
255+
sendCanvasForegroundState();
256+
},
257+
{ threshold: [0, 0.01] },
258+
);
259+
observer.observe(canvasElement);
260+
return () => {
261+
observer.disconnect();
262+
};
263+
}, [canvasElement, paused, simulator?.udid]);
264+
226265
useEffect(() => {
227266
latestDecodedFramesRef.current = stats.decodedFrames;
228267
latestRenderedFramesRef.current = stats.renderedFrames;
@@ -355,7 +394,9 @@ export function useLiveStream({
355394
return;
356395
}
357396

358-
const sendForegroundState = (foreground = isDocumentForeground()) => {
397+
const sendForegroundState = (
398+
foreground = isViewerForeground(canvasVisibleRef.current),
399+
) => {
359400
workerClientRef.current?.sendStreamControl({
360401
clientId: clientTelemetryIdRef.current,
361402
foreground,
@@ -449,7 +490,7 @@ export function useLiveStream({
449490
};
450491
workerClientRef.current?.sendStreamControl({
451492
clientId: clientTelemetryIdRef.current,
452-
foreground: isDocumentForeground(),
493+
foreground: isViewerForeground(canvasVisibleRef.current),
453494
});
454495
if (
455496
sendStreamClientStats(payload) ||

client/src/features/toolbar/DebugPanel.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ export function DebugPanel({
9090
: "no"
9191
: "—",
9292
},
93+
{
94+
label: "Auto HW Slot",
95+
value:
96+
typeof encoder.autoHardwareSlot === "boolean"
97+
? encoder.autoHardwareSlot
98+
? "yes"
99+
: "no"
100+
: "—",
101+
},
93102
{ label: "Encoder State", value: encoder.overloadState ?? "—" },
94103
{
95104
label: "Encoder Load",

docs/guide/testing.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,15 @@ Include simulator refresh traffic:
100100
```sh
101101
npm run test:stress -- --udid <udid> --iterations 2000 --concurrency 16
102102
```
103+
104+
## Stress Test Daemon Cleanup
105+
106+
```sh
107+
npm run build:cli
108+
npm run test:stress:daemon -- --iterations 30 --concurrency 3
109+
```
110+
111+
This starts isolated temporary project daemons, hits health and metrics, stops
112+
them through the CLI, and verifies the process group, listener port, and daemon
113+
status are cleaned up. Use `--binary /path/to/simdeck` to test an installed or
114+
packaged binary.

docs/guide/video.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ SimDeck streams live device video to the browser. Local sessions default to high
44

55
iOS simulator H.264 uses VideoToolbox for hardware encoding and x264 for software encoding.
66

7+
## When Encoding Runs
8+
9+
SimDeck starts encoding when a browser stream needs H.264 frames. The server
10+
requests an initial keyframe to answer the WebRTC or H.264 WebSocket viewer,
11+
then keeps a shared refresh pump active while frame subscribers exist.
12+
13+
The browser reports whether the page and stream canvas are foreground. When all
14+
known viewers are hidden or the last frame subscriber disconnects, the native
15+
session pauses encoder input and releases the active compression session. A
16+
visible viewer, explicit refresh, or stream reconnect asks for a fresh keyframe.
17+
718
## Pick A Stream Quality
819

920
Start with the default:
@@ -47,6 +58,11 @@ simdeck daemon restart --video-codec software
4758
| `hardware` | Dedicated local machines where VideoToolbox hardware H.264 is reliable. |
4859
| `software` | x264 software H.264 for CI, screen recording conflicts, or hardware encoder stalls. |
4960

61+
When multiple simulator streams run at the same time, `auto` keeps one active
62+
stream on the hardware encoder path and routes additional active auto streams to
63+
software encoding. This avoids saturating the shared VideoToolbox hardware
64+
encoder while preserving explicit `--video-codec hardware` behavior.
65+
5066
For very constrained software sessions:
5167

5268
```sh

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs scripts/studio-host-provider.test.mjs",
8181
"test:github-actions": "node --test scripts/github-actions.test.mjs",
8282
"test:stress": "node scripts/stress/simdeck.mjs",
83+
"test:stress:daemon": "node scripts/stress/daemon-cleanup.mjs",
8384
"bench:encoder:build": "scripts/bench/build-encoder-benchmark.sh",
8485
"codex:setup": "node scripts/codex-setup.mjs",
8586
"codex:cache:save": "node scripts/codex-worktree-cache.mjs save --best-effort",

0 commit comments

Comments
 (0)