From cb88ee356e7a5a0a1b878483ff64bf84a8c4f197 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Wed, 20 May 2026 20:45:41 -0400 Subject: [PATCH 1/2] feat: add daemon cleanup stress test and idle stream gating --- client/src/features/stream/useLiveStream.ts | 45 +- docs/guide/testing.md | 12 + docs/guide/video.md | 11 + package.json | 1 + scripts/stress/daemon-cleanup.mjs | 442 ++++++++++++++++++++ server/src/simulators/session.rs | 15 +- 6 files changed, 521 insertions(+), 5 deletions(-) create mode 100644 scripts/stress/daemon-cleanup.mjs diff --git a/client/src/features/stream/useLiveStream.ts b/client/src/features/stream/useLiveStream.ts index 975a4eb1..77990731 100644 --- a/client/src/features/stream/useLiveStream.ts +++ b/client/src/features/stream/useLiveStream.ts @@ -103,6 +103,10 @@ function isDocumentForeground(): boolean { return document.visibilityState === "visible"; } +function isViewerForeground(canvasVisible: boolean): boolean { + return isDocumentForeground() && canvasVisible; +} + export function useLiveStream({ canvasElement, paused = false, @@ -122,6 +126,7 @@ export function useLiveStream({ const retainedFrameRef = useRef(false); const previousSimulatorUdidRef = useRef(simulator?.udid); const connectedStreamTargetKeyRef = useRef(""); + const canvasVisibleRef = useRef(true); const latestVisualArtifactRef = useRef(null); const latestVisualArtifactSampleCountRef = useRef(0); const lastVisualArtifactSampleAtRef = useRef(0); @@ -223,6 +228,40 @@ export function useLiveStream({ }; }, []); + useEffect(() => { + if (!canvasElement || !simulator?.udid || paused) { + return; + } + + const sendCanvasForegroundState = () => { + workerClientRef.current?.sendStreamControl({ + clientId: clientTelemetryIdRef.current, + foreground: isViewerForeground(canvasVisibleRef.current), + }); + }; + + if (typeof IntersectionObserver !== "function") { + canvasVisibleRef.current = true; + sendCanvasForegroundState(); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[entries.length - 1]; + canvasVisibleRef.current = Boolean( + entry?.isIntersecting && entry.intersectionRatio > 0, + ); + sendCanvasForegroundState(); + }, + { threshold: [0, 0.01] }, + ); + observer.observe(canvasElement); + return () => { + observer.disconnect(); + }; + }, [canvasElement, paused, simulator?.udid]); + useEffect(() => { latestDecodedFramesRef.current = stats.decodedFrames; latestRenderedFramesRef.current = stats.renderedFrames; @@ -355,7 +394,9 @@ export function useLiveStream({ return; } - const sendForegroundState = (foreground = isDocumentForeground()) => { + const sendForegroundState = ( + foreground = isViewerForeground(canvasVisibleRef.current), + ) => { workerClientRef.current?.sendStreamControl({ clientId: clientTelemetryIdRef.current, foreground, @@ -449,7 +490,7 @@ export function useLiveStream({ }; workerClientRef.current?.sendStreamControl({ clientId: clientTelemetryIdRef.current, - foreground: isDocumentForeground(), + foreground: isViewerForeground(canvasVisibleRef.current), }); if ( sendStreamClientStats(payload) || diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 28c59e8c..954dfe21 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -100,3 +100,15 @@ Include simulator refresh traffic: ```sh npm run test:stress -- --udid --iterations 2000 --concurrency 16 ``` + +## Stress Test Daemon Cleanup + +```sh +npm run build:cli +npm run test:stress:daemon -- --iterations 30 --concurrency 3 +``` + +This starts isolated temporary project daemons, hits health and metrics, stops +them through the CLI, and verifies the process group, listener port, and daemon +status are cleaned up. Use `--binary /path/to/simdeck` to test an installed or +packaged binary. diff --git a/docs/guide/video.md b/docs/guide/video.md index 836a8fbb..50b5e91d 100644 --- a/docs/guide/video.md +++ b/docs/guide/video.md @@ -4,6 +4,17 @@ SimDeck streams live device video to the browser. Local sessions default to high iOS simulator H.264 uses VideoToolbox for hardware encoding and x264 for software encoding. +## When Encoding Runs + +SimDeck starts encoding when a browser stream needs H.264 frames. The server +requests an initial keyframe to answer the WebRTC or H.264 WebSocket viewer, +then keeps a shared refresh pump active while frame subscribers exist. + +The browser reports whether the page and stream canvas are foreground. When all +known viewers are hidden or the last frame subscriber disconnects, the native +session pauses encoder input and releases the active compression session. A +visible viewer, explicit refresh, or stream reconnect asks for a fresh keyframe. + ## Pick A Stream Quality Start with the default: diff --git a/package.json b/package.json index 92d1f68c..23283f2b 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs scripts/studio-host-provider.test.mjs", "test:github-actions": "node --test scripts/github-actions.test.mjs", "test:stress": "node scripts/stress/simdeck.mjs", + "test:stress:daemon": "node scripts/stress/daemon-cleanup.mjs", "bench:encoder:build": "scripts/bench/build-encoder-benchmark.sh", "codex:setup": "node scripts/codex-setup.mjs", "codex:cache:save": "node scripts/codex-worktree-cache.mjs save --best-effort", diff --git a/scripts/stress/daemon-cleanup.mjs b/scripts/stress/daemon-cleanup.mjs new file mode 100644 index 00000000..d1c4d95b --- /dev/null +++ b/scripts/stress/daemon-cleanup.mjs @@ -0,0 +1,442 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const args = parseArgs(process.argv.slice(2)); +const binary = resolve( + root, + String( + args.binary ?? + process.env.SIMDECK_STRESS_BINARY ?? + join(root, "build", "simdeck"), + ), +); +const iterations = positiveInt( + args.iterations ?? process.env.SIMDECK_DAEMON_STRESS_ITERATIONS, + 20, +); +const concurrency = positiveInt( + args.concurrency ?? process.env.SIMDECK_DAEMON_STRESS_CONCURRENCY, + 2, +); +const basePort = positiveInt( + args["base-port"] ?? process.env.SIMDECK_DAEMON_STRESS_BASE_PORT, + 45100, +); +const settleMs = positiveInt( + args["settle-ms"] ?? process.env.SIMDECK_DAEMON_STRESS_SETTLE_MS, + 750, +); +const maxStopMs = positiveInt( + args["max-stop-ms"] ?? process.env.SIMDECK_DAEMON_STRESS_MAX_STOP_MS, + 8000, +); +const requestsPerIteration = positiveInt( + args.requests ?? process.env.SIMDECK_DAEMON_STRESS_REQUESTS, + 3, +); +const keepTemp = booleanArg( + args["keep-temp"] ?? process.env.SIMDECK_DAEMON_STRESS_KEEP_TEMP, +); + +if (!existsSync(binary)) { + console.error( + `Missing SimDeck binary at ${binary}. Run npm run build:cli or pass --binary.`, + ); + process.exit(1); +} + +const clientRoot = join(root, "client", "dist"); +const useClientRoot = existsSync(clientRoot); +const failures = []; +const results = []; +let nextIteration = 0; +const startedAt = Date.now(); + +await Promise.all( + Array.from({ length: concurrency }, async (_, workerIndex) => { + while (true) { + const iteration = nextIteration; + nextIteration += 1; + if (iteration >= iterations) { + return; + } + const result = await runIteration(workerIndex, iteration); + results.push(result); + if (!result.ok) { + failures.push( + `worker=${workerIndex} iteration=${iteration}: ${result.failures.join("; ")}`, + ); + } + } + }), +); + +results.sort((left, right) => left.iteration - right.iteration); +const elapsedMs = Date.now() - startedAt; +const summary = { + ok: failures.length === 0, + binary, + iterations, + concurrency, + basePort, + settleMs, + maxStopMs, + requestsPerIteration, + elapsedMs, + completed: results.length, + failures: failures.slice(0, 20), + results: results.map((result) => ({ + iteration: result.iteration, + worker: result.worker, + port: result.port, + pid: result.pid, + startMs: result.startMs, + stopMs: result.stopMs, + processCountAtStart: result.processesAtStart.length, + maxRssMb: result.maxRssMb, + maxOpenFiles: result.maxOpenFiles, + ok: result.ok, + failures: result.failures, + })), +}; + +console.log(JSON.stringify(summary, null, 2)); +if (!summary.ok) { + process.exit(1); +} + +async function runIteration(worker, iteration) { + const tempRoot = await mkdtemp(join(tmpdir(), "simdeck-daemon-stress-")); + const projectRoot = join(tempRoot, "project"); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + join(projectRoot, "package.json"), + JSON.stringify({ + private: true, + name: `simdeck-daemon-stress-${iteration}`, + }), + ); + + const port = basePort + worker * 200 + (iteration % 200); + const failures = []; + let metadata = null; + let processesAtStart = []; + let maxRssMb = null; + let maxOpenFiles = null; + let startMs = 0; + let stopMs = 0; + + try { + killPortListeners(port); + const startArgs = [ + "daemon", + "start", + "--port", + String(port), + "--bind", + "127.0.0.1", + "--video-codec", + "software", + "--stream-quality", + "tiny", + ]; + if (useClientRoot) { + startArgs.push("--client-root", clientRoot); + } + + const startedAt = Date.now(); + metadata = runJson(startArgs, { cwd: projectRoot }); + startMs = Date.now() - startedAt; + if (metadata.ok !== true) { + failures.push( + `start did not return ok=true: ${JSON.stringify(metadata)}`, + ); + } + if (!metadata.pid || !Number.isFinite(Number(metadata.pid))) { + failures.push( + `start did not return a daemon pid: ${JSON.stringify(metadata)}`, + ); + } + + const health = await fetchJson(metadata.url, "/api/health"); + if ( + health.ok !== true || + Number(health.httpPort) !== Number(metadata.url.split(":").pop()) + ) { + failures.push( + `health payload was not for the started daemon: ${JSON.stringify(health)}`, + ); + } + + for (let index = 0; index < requestsPerIteration; index += 1) { + await fetchJson( + metadata.url, + index % 2 === 0 ? "/api/health" : "/api/metrics", + ); + } + + processesAtStart = processGroupProcesses(Number(metadata.pid)); + if (processesAtStart.length === 0) { + failures.push(`process group ${metadata.pid} was empty after start`); + } + for (const process of processesAtStart) { + const rssMb = rssMbForPid(process.pid); + const openFiles = openFileCountForPid(process.pid); + if (rssMb != null) { + maxRssMb = maxRssMb == null ? rssMb : Math.max(maxRssMb, rssMb); + } + if (openFiles != null) { + maxOpenFiles = + maxOpenFiles == null ? openFiles : Math.max(maxOpenFiles, openFiles); + } + } + + const stoppedAt = Date.now(); + const stop = runJson(["daemon", "stop"], { cwd: projectRoot }); + stopMs = Date.now() - stoppedAt; + if (stop.ok !== true || stop.running !== false) { + failures.push( + `stop did not report running=false: ${JSON.stringify(stop)}`, + ); + } + if (stopMs > maxStopMs) { + failures.push(`stop took ${stopMs}ms, above ${maxStopMs}ms`); + } + await sleep(settleMs); + + const leakedProcesses = processGroupProcesses(Number(metadata.pid)); + if (leakedProcesses.length > 0) { + failures.push( + `process group ${metadata.pid} still has pids ${leakedProcesses + .map((process) => process.pid) + .join(",")}`, + ); + } + const leakedListeners = portListeners(port); + if (leakedListeners.length > 0) { + failures.push( + `port ${port} still has listeners ${leakedListeners.join(",")}`, + ); + } + const status = runJson(["daemon", "status"], { cwd: projectRoot }); + if ( + status.running || + status.healthy || + status.processRunning || + status.stale + ) { + failures.push(`daemon status remained active: ${JSON.stringify(status)}`); + } + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } finally { + if (metadata?.pid) { + terminateProcessGroup(Number(metadata.pid)); + } + killPortListeners(port); + try { + runJson(["daemon", "stop"], { cwd: projectRoot, allowFailure: true }); + } catch { + // Best effort; the assertions above carry the useful failure. + } + if (!keepTemp) { + rmSync(tempRoot, { recursive: true, force: true }); + } + } + + return { + ok: failures.length === 0, + failures, + iteration, + worker, + port, + pid: metadata?.pid ?? null, + startMs, + stopMs, + processesAtStart, + maxRssMb, + maxOpenFiles, + }; +} + +async function fetchJson(baseUrl, path) { + const response = await fetch(new URL(path, baseUrl)); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `${path} returned ${response.status}: ${text.slice(0, 500)}`, + ); + } + return JSON.parse(text); +} + +function runJson(commandArgs, options = {}) { + const result = spawnSync(binary, commandArgs, { + cwd: options.cwd ?? root, + encoding: "utf8", + env: { + ...process.env, + SIMDECK_REALTIME_STREAM: "0", + }, + }); + if (!options.allowFailure && result.status !== 0) { + throw new Error( + `${binary} ${commandArgs.join(" ")} failed with ${result.status ?? result.signal}: ${[ + result.stdout, + result.stderr, + result.error?.message, + ] + .filter(Boolean) + .join("\n")}`, + ); + } + const text = result.stdout.trim(); + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch (error) { + if (options.allowFailure) { + return {}; + } + throw new Error( + `Unable to parse JSON from ${commandArgs.join(" ")}: ${text}`, + ); + } +} + +function processGroupProcesses(pgid) { + if (!Number.isFinite(pgid) || pgid <= 0) { + return []; + } + const result = spawnSync("ps", ["-axo", "pid=,ppid=,pgid=,command="], { + encoding: "utf8", + }); + if (result.status !== 0) { + return []; + } + return result.stdout + .split("\n") + .map((line) => parsePsLine(line)) + .filter((process) => process && process.pgid === pgid); +} + +function parsePsLine(line) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/); + if (!match) { + return null; + } + return { + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + command: match[4], + }; +} + +function rssMbForPid(pid) { + const result = spawnSync("ps", ["-o", "rss=", "-p", String(pid)], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + const rssKb = Number(result.stdout.trim()); + return Number.isFinite(rssKb) ? Number((rssKb / 1024).toFixed(2)) : null; +} + +function openFileCountForPid(pid) { + const result = spawnSync("lsof", ["-nP", "-p", String(pid)], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + return Math.max(0, result.stdout.split("\n").filter(Boolean).length - 1); +} + +function portListeners(port) { + const result = spawnSync( + "lsof", + ["-nP", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0 || !result.stdout.trim()) { + return []; + } + return result.stdout.trim().split(/\s+/).filter(Boolean); +} + +function killPortListeners(port) { + for (const pid of portListeners(port)) { + if (pid !== String(process.pid)) { + spawnSync("kill", ["-TERM", pid], { stdio: "ignore" }); + } + } +} + +function terminateProcessGroup(pgid) { + if (!Number.isFinite(pgid) || pgid <= 0) { + return; + } + spawnSync("kill", ["-TERM", "--", `-${pgid}`], { stdio: "ignore" }); + spawnSync("kill", ["-TERM", String(pgid)], { stdio: "ignore" }); +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith("--")) { + continue; + } + const [rawKey, inlineValue] = value.slice(2).split("=", 2); + if (inlineValue != null) { + parsed[rawKey] = inlineValue; + } else if (values[index + 1] && !values[index + 1].startsWith("--")) { + parsed[rawKey] = values[index + 1]; + index += 1; + } else { + parsed[rawKey] = "true"; + } + } + return parsed; +} + +function optionalInt(value) { + if (value == null || value === "") { + return null; + } + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function positiveInt(value, fallback) { + const parsed = optionalInt(value); + return parsed && parsed > 0 ? parsed : fallback; +} + +function booleanArg(value) { + if (value == null) { + return false; + } + const normalized = String(value).trim().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/server/src/simulators/session.rs b/server/src/simulators/session.rs index 780ff478..202f0454 100644 --- a/server/src/simulators/session.rs +++ b/server/src/simulators/session.rs @@ -67,12 +67,16 @@ impl FrameSubscription { impl Drop for FrameSubscription { fn drop(&mut self) { - self.inner + let previous = self + .inner .active_frame_subscribers .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { Some(value.saturating_sub(1)) }) - .ok(); + .unwrap_or(0); + if previous <= 1 { + self.inner.native.set_client_foreground(false); + } } } @@ -167,9 +171,13 @@ impl SimulatorSession { pub fn subscribe(&self) -> FrameSubscription { *self.inner.state.lock().unwrap() = SessionState::Streaming; - self.inner + let previous = self + .inner .active_frame_subscribers .fetch_add(1, Ordering::Relaxed); + if previous == 0 { + self.inner.native.set_client_foreground(true); + } self.inner.start_refresh_pump(); FrameSubscription { inner: self.inner.clone(), @@ -182,6 +190,7 @@ impl SimulatorSession { } pub async fn wait_for_keyframe(&self, timeout_duration: Duration) -> Option { + self.inner.native.set_client_foreground(true); let deadline = Instant::now() + timeout_duration; let baseline_sequence = self .latest_keyframe() From da8f3647e30631ee1704ffbcdd29d07477b0495f Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Fri, 22 May 2026 17:39:38 -0400 Subject: [PATCH 2/2] fix: cap auto hardware video streams --- cli/XCWH264Encoder.m | 78 +++++++++++++++++----- client/src/api/types.ts | 1 + client/src/features/toolbar/DebugPanel.tsx | 9 +++ docs/guide/video.md | 5 ++ server/src/transport/webrtc.rs | 19 ++++++ 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/cli/XCWH264Encoder.m b/cli/XCWH264Encoder.m index c1abceca..00aba43e 100644 --- a/cli/XCWH264Encoder.m +++ b/cli/XCWH264Encoder.m @@ -47,7 +47,10 @@ static const double XCWHardwareFallbackLoadPercent = 500.0; static const NSUInteger XCWHardwareFallbackConsecutiveOverBudgetFrameThreshold = 60; static const uint64_t XCWAutoHardwareRetryIntervalUs = 10000000; +static const NSUInteger XCWMaximumAutoHardwareEncoders = 1; static void *XCWH264EncoderQueueSpecificKey = &XCWH264EncoderQueueSpecificKey; +static os_unfair_lock XCWAutoHardwareEncoderLock = OS_UNFAIR_LOCK_INIT; +static NSUInteger XCWActiveAutoHardwareEncoderCount = 0; typedef NS_ENUM(NSUInteger, XCWVideoEncoderMode) { XCWVideoEncoderModeAuto, @@ -575,6 +578,8 @@ - (void)recordEncodeLatencyLockedWithSubmittedAtUs:(uint64_t)submittedAtUs measu - (void)invalidateX264EncoderLocked; - (void)handleCompressionOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer submittedAtUs:(uint64_t)submittedAtUs; +- (BOOL)acquireAutoHardwareSlotIfNeededLocked; +- (void)releaseAutoHardwareSlotIfNeededLocked; - (uint64_t)activeFrameIntervalUsLocked; - (uint64_t)encoderLatencyBudgetUsLocked; - (uint64_t)pacingDelayBeforeNextFrameAtTimeUs:(uint64_t)nowUs; @@ -603,6 +608,7 @@ @implementation XCWH264Encoder { BOOL _scalingActive; XCWVideoEncoderMode _encoderMode; XCWVideoEncoderMode _activeEncoderMode; + BOOL _holdsAutoHardwareSlot; BOOL _clientForeground; BOOL _acceptingFrameInput; BOOL _lowLatencyMode; @@ -725,6 +731,7 @@ - (void)requestKeyFrame { - (void)reconfigureForStreamQualityChange { dispatch_async(_queue, ^{ + [self releaseAutoHardwareSlotIfNeededLocked]; [self invalidateCompressionSessionLocked]; self->_encoderMode = XCWVideoEncoderModeFromEnvironment(); self->_activeEncoderMode = self->_encoderMode; @@ -761,6 +768,7 @@ - (void)setClientForeground:(BOOL)foreground { } os_unfair_lock_unlock(&self->_pendingLock); if (!foreground) { + [self releaseAutoHardwareSlotIfNeededLocked]; [self invalidateCompressionSessionLocked]; self->_needsKeyFrame = YES; return; @@ -881,6 +889,7 @@ - (NSDictionary *)statsRepresentation { @"encoderMode": XCWVideoEncoderModeName(self->_encoderMode), @"activeEncoderMode": XCWVideoEncoderModeName(self->_activeEncoderMode), @"clientForeground": @(self->_clientForeground), + @"autoHardwareSlot": @(self->_holdsAutoHardwareSlot), @"autoSoftwareFallbackActive": @(autoSoftwareFallbackActive), @"autoSoftwareFallbackRemainingUs": @(autoSoftwareFallbackRemainingUs), @"autoSoftwareFallbacks": @(self->_autoSoftwareFallbackCount), @@ -902,6 +911,7 @@ - (NSDictionary *)statsRepresentation { - (void)invalidate { dispatch_sync(_queue, ^{ [self drainPendingFramesLocked]; + [self releaseAutoHardwareSlotIfNeededLocked]; [self invalidateCompressionSessionLocked]; }); @@ -976,10 +986,44 @@ - (void)resetAutoFallbackLatencyStateLocked { _wasOverloaded = NO; } +- (BOOL)acquireAutoHardwareSlotIfNeededLocked { + if (_encoderMode != XCWVideoEncoderModeAuto || !_clientForeground) { + return NO; + } + if (_holdsAutoHardwareSlot) { + return YES; + } + + BOOL acquired = NO; + os_unfair_lock_lock(&XCWAutoHardwareEncoderLock); + if (XCWActiveAutoHardwareEncoderCount < XCWMaximumAutoHardwareEncoders) { + XCWActiveAutoHardwareEncoderCount += 1; + acquired = YES; + } + os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock); + _holdsAutoHardwareSlot = acquired; + return acquired; +} + +- (void)releaseAutoHardwareSlotIfNeededLocked { + if (!_holdsAutoHardwareSlot) { + return; + } + os_unfair_lock_lock(&XCWAutoHardwareEncoderLock); + if (XCWActiveAutoHardwareEncoderCount > 0) { + XCWActiveAutoHardwareEncoderCount -= 1; + } + os_unfair_lock_unlock(&XCWAutoHardwareEncoderLock); + _holdsAutoHardwareSlot = NO; +} + - (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode { if (_activeEncoderMode == mode) { return; } + if (mode != XCWVideoEncoderModeAuto) { + [self releaseAutoHardwareSlotIfNeededLocked]; + } _activeEncoderMode = mode; _codecType = XCWVideoCodecTypeForMode(_activeEncoderMode); if (_activeEncoderMode == XCWVideoEncoderModeH264Software) { @@ -996,17 +1040,27 @@ - (void)switchActiveEncoderModeLocked:(XCWVideoEncoderMode)mode { } - (void)updateActiveEncoderModeForClientForegroundLockedAtTimeUs:(uint64_t)nowUs { - if (_encoderMode == XCWVideoEncoderModeAuto && - _autoSoftwareFallbackUntilUs != 0 && - nowUs < _autoSoftwareFallbackUntilUs) { + if (_encoderMode != XCWVideoEncoderModeAuto) { + [self switchActiveEncoderModeLocked:_encoderMode]; + return; + } + if (!_clientForeground) { + [self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software]; + return; + } + if (_autoSoftwareFallbackUntilUs != 0 && nowUs < _autoSoftwareFallbackUntilUs) { [self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software]; return; } - if (_encoderMode == XCWVideoEncoderModeAuto && _autoSoftwareFallbackUntilUs != 0) { + if (_autoSoftwareFallbackUntilUs != 0) { _autoSoftwareFallbackUntilUs = 0; _autoHardwareRetryCount += 1; } - [self switchActiveEncoderModeLocked:_encoderMode]; + if ([self acquireAutoHardwareSlotIfNeededLocked]) { + [self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto]; + } else { + [self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software]; + } } - (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs { @@ -1019,18 +1073,6 @@ - (void)enterAutoSoftwareFallbackLockedAtTimeUs:(uint64_t)nowUs { [self switchActiveEncoderModeLocked:XCWVideoEncoderModeH264Software]; } -- (void)retryAutoHardwareIfNeededLockedAtTimeUs:(uint64_t)nowUs { - if (![self isAutoSoftwareFallbackActiveLocked] || - !_clientForeground || - _autoSoftwareFallbackUntilUs == 0 || - nowUs < _autoSoftwareFallbackUntilUs) { - return; - } - _autoSoftwareFallbackUntilUs = 0; - _autoHardwareRetryCount += 1; - [self switchActiveEncoderModeLocked:XCWVideoEncoderModeAuto]; -} - - (uint64_t)activeFrameIntervalUsLocked { if (_activeEncoderMode == XCWVideoEncoderModeH264Software) { return _softwareFrameIntervalUs > 0 ? _softwareFrameIntervalUs : [self initialSoftwareFrameIntervalUsLocked]; @@ -1238,7 +1280,7 @@ - (BOOL)encodePixelBufferLocked:(CVPixelBufferRef)pixelBuffer { } uint64_t nowUs = (uint64_t)(CACurrentMediaTime() * 1000000.0); - [self retryAutoHardwareIfNeededLockedAtTimeUs:nowUs]; + [self updateActiveEncoderModeForClientForegroundLockedAtTimeUs:nowUs]; CGSize targetSize = XCWScaledDimensionsForSourceSize(sourceWidth, sourceHeight, _activeEncoderMode, _lowLatencyMode, _realtimeStreamMode); int32_t targetWidth = (int32_t)targetSize.width; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index a2d3ea64..052d3551 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -2,6 +2,7 @@ export interface EncoderStats { activeEncoderMode?: string; averageEncodeLatencyUs?: number; averageEncoderLoadPercent?: number; + autoHardwareSlot?: boolean; autoHardwareRetries?: number; autoSoftwareFallbackActive?: boolean; autoSoftwareFallbackRemainingUs?: number; diff --git a/client/src/features/toolbar/DebugPanel.tsx b/client/src/features/toolbar/DebugPanel.tsx index dbbd3551..f2c15e13 100644 --- a/client/src/features/toolbar/DebugPanel.tsx +++ b/client/src/features/toolbar/DebugPanel.tsx @@ -90,6 +90,15 @@ export function DebugPanel({ : "no" : "—", }, + { + label: "Auto HW Slot", + value: + typeof encoder.autoHardwareSlot === "boolean" + ? encoder.autoHardwareSlot + ? "yes" + : "no" + : "—", + }, { label: "Encoder State", value: encoder.overloadState ?? "—" }, { label: "Encoder Load", diff --git a/docs/guide/video.md b/docs/guide/video.md index 50b5e91d..74c3f5e6 100644 --- a/docs/guide/video.md +++ b/docs/guide/video.md @@ -58,6 +58,11 @@ simdeck daemon restart --video-codec software | `hardware` | Dedicated local machines where VideoToolbox hardware H.264 is reliable. | | `software` | x264 software H.264 for CI, screen recording conflicts, or hardware encoder stalls. | +When multiple simulator streams run at the same time, `auto` keeps one active +stream on the hardware encoder path and routes additional active auto streams to +software encoding. This avoids saturating the shared VideoToolbox hardware +encoder while preserving explicit `--video-codec hardware` behavior. + For very constrained software sessions: ```sh diff --git a/server/src/transport/webrtc.rs b/server/src/transport/webrtc.rs index b0c3f6cb..c86d4562 100644 --- a/server/src/transport/webrtc.rs +++ b/server/src/transport/webrtc.rs @@ -2741,6 +2741,25 @@ mod tests { assert!(!super::has_media_stream(&udid)); } + #[test] + fn clearing_webrtc_stream_is_scoped_and_idempotent() { + let udid = format!("test-clear-{}", std::process::id()); + super::reset_webrtc_media_streams_for_test(&udid); + let (first_token, mut first_rx) = super::register_webrtc_media_stream_for_test(&udid); + let (second_token, mut second_rx) = super::register_webrtc_media_stream_for_test(&udid); + + super::clear_webrtc_media_stream_for_test(&udid, &first_token); + super::clear_webrtc_media_stream_for_test(&udid, &first_token); + + assert!(first_rx.try_recv().is_err()); + assert!(second_rx.try_recv().is_err()); + assert_eq!(super::active_webrtc_media_stream_count(&udid), 1); + assert!(super::has_media_stream(&udid)); + + super::clear_webrtc_media_stream_for_test(&udid, &second_token); + assert!(!super::has_media_stream(&udid)); + } + #[test] fn registering_same_client_webrtc_stream_replaces_old_stream() { let udid = format!("test-client-cap-{}", std::process::id());