Skip to content

Commit 7120d20

Browse files
authored
Restore local WebRTC defaults and CI stream profile (#11)
* Experiment with lean CI realtime stream * Honor lean CI realtime profile * Optimize CI streaming experiments * Restore usable CI software stream quality * Retry CoreSimulator headless screen attach * Handle direct SimulatorKit screen adapters * Restore local hardware H264 defaults * Probe foreground bind address for port selection * Avoid loading loops on failed simulator streams * Restore smooth local WebRTC defaults * Reduce local WebRTC stream pressure * Revert "Reduce local WebRTC stream pressure" This reverts commit 1824195. * Revert "Restore smooth local WebRTC defaults" This reverts commit 229bfb5. * Uncap local hardware WebRTC streaming * Restore main WebRTC streaming implementation * Remove local hardware stream FPS cap * Avoid reconnecting established WebRTC streams on frame stalls * Tolerate transient WebRTC disconnected state * Use realtime cleanup without capping local hardware * Restore smooth local WebRTC defaults * Restore main local preview hot path * Clean up CI stream profile branch * Fix PR CI formatting and clippy * Use integration server for stdout screenshot test
1 parent 2ded600 commit 7120d20

10 files changed

Lines changed: 110 additions & 45 deletions

File tree

client/src/app/AppShell.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ function simulatorDisplaySize(
135135
};
136136
}
137137

138+
function simulatorDisplayReady(simulator: SimulatorMetadata): boolean {
139+
const display = simulator.privateDisplay;
140+
return Boolean(
141+
simulator.isBooted &&
142+
display?.displayReady &&
143+
display.displayWidth > 0 &&
144+
display.displayHeight > 0,
145+
);
146+
}
147+
138148
function mergeAccessibilitySources(
139149
...sources: unknown[]
140150
): AccessibilitySource[] {
@@ -190,6 +200,9 @@ export function AppShell() {
190200
);
191201
const [menuOpen, setMenuOpen] = useState(false);
192202
const [localError, setLocalError] = useState("");
203+
const [failedStreamUDIDs, setFailedStreamUDIDs] = useState<Set<string>>(
204+
() => new Set(),
205+
);
193206
const [pairingCode, setPairingCode] = useState("");
194207
const [pairingError, setPairingError] = useState("");
195208
const [pairingBusy, setPairingBusy] = useState(false);
@@ -292,6 +305,8 @@ export function AppShell() {
292305
simulators.find((simulator) =>
293306
simulatorMatchesIdentifier(simulator, selectedUDID),
294307
) ??
308+
filteredSimulators.find((simulator) => simulatorDisplayReady(simulator)) ??
309+
filteredSimulators.find((simulator) => simulator.isBooted) ??
295310
filteredSimulators[0] ??
296311
null;
297312
const selectedSimulatorDetail =
@@ -333,6 +348,36 @@ export function AppShell() {
333348
canvasElement: streamCanvasElement,
334349
simulator: selectedSimulator,
335350
});
351+
352+
useEffect(() => {
353+
if (
354+
!selectedSimulator ||
355+
!streamError ||
356+
readDeviceQueryParam() ||
357+
!isStreamAttachFailure(streamError)
358+
) {
359+
return;
360+
}
361+
const failedUDID = selectedSimulator.udid;
362+
setFailedStreamUDIDs((current) => {
363+
if (current.has(failedUDID)) {
364+
return current;
365+
}
366+
return new Set(current).add(failedUDID);
367+
});
368+
const nextSimulator = simulators.find(
369+
(simulator) =>
370+
simulator.isBooted &&
371+
simulator.udid !== failedUDID &&
372+
!failedStreamUDIDs.has(simulator.udid),
373+
);
374+
if (nextSimulator) {
375+
setSelectedUDID(nextSimulator.udid);
376+
setLocalError(
377+
`${selectedSimulator.name} did not expose a live simulator screen. Switched to ${nextSimulator.name}.`,
378+
);
379+
}
380+
}, [failedStreamUDIDs, selectedSimulator, simulators, streamError]);
336381
const shouldRenderChrome =
337382
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
338383
const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null;
@@ -1389,3 +1434,13 @@ function readDeviceQueryParam(): string | undefined {
13891434
const trimmed = value?.trim();
13901435
return trimmed ? trimmed : undefined;
13911436
}
1437+
1438+
function isStreamAttachFailure(message: string): boolean {
1439+
const normalized = message.toLowerCase();
1440+
return (
1441+
normalized.includes("headless screen") ||
1442+
normalized.includes("screen adapter") ||
1443+
normalized.includes("coresimulator did not provide") ||
1444+
normalized.includes("did not expose any live screens")
1445+
);
1446+
}

docs/api/health.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Returns the static bootstrap information the browser client needs, plus a freshn
1111
"ok": true,
1212
"httpPort": 4310,
1313
"timestamp": 1714094761.234,
14-
"videoCodec": "h264-software",
14+
"videoCodec": "h264",
1515
"lowLatency": false,
1616
"webRtc": {
1717
"iceServers": [{ "urls": ["stun:stun.l.google.com:19302"] }],

docs/api/rest.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Returns server health and the active video encoder mode.
2222
"ok": true,
2323
"httpPort": 4310,
2424
"timestamp": 1714094761.234,
25-
"videoCodec": "h264-software",
25+
"videoCodec": "h264",
2626
"lowLatency": false,
2727
"webRtc": {
2828
"iceServers": [{ "urls": ["stun:stun.l.google.com:19302"] }],

docs/cli/flags.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Targets a specific running SimDeck daemon for commands that support the HTTP fas
3434
| `--bind <ip>` | `127.0.0.1` | Bind address (`0.0.0.0` for [LAN access](/guide/lan-access), `::` for IPv6). |
3535
| `--advertise-host` | matches local host | Hostname or IP printed for LAN browser access. |
3636
| `--client-root` | bundled `client/dist` | Override the static browser client directory. |
37-
| `--video-codec` | `h264-software` | One of `h264` or `h264-software`. See [Video Pipeline](/guide/video). |
37+
| `--video-codec` | `h264` | One of `h264` or `h264-software`. See [Video Pipeline](/guide/video). |
3838
| `--low-latency` | `false` | Software H.264 profile for slower runners: caps at 15 fps and favors freshness. |
3939
| `--stream-quality` | auto/default | Optional realtime stream quality profile: `quality`, `balanced`, `smooth`, `economy`, or `ci-software`. |
4040
| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. |

docs/guide/daemon.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ This starts or reuses the project daemon, serves the bundled browser client, and
6060
| `--bind <ip>` | `127.0.0.1` | Bind address. Use `0.0.0.0` for [LAN access](/guide/lan-access). |
6161
| `--advertise-host` | matches local host | Hostname or IP advertised to browser clients. |
6262
| `--client-root` | bundled `client/dist` | Override the static browser client directory. |
63-
| `--video-codec` | `h264-software` | One of `h264` or `h264-software`. See [Video](/guide/video). |
63+
| `--video-codec` | `h264` | One of `h264` or `h264-software`. See [Video](/guide/video). |
6464
| `--low-latency` | `false` | Software H.264 profile for slower runners; caps at 15 fps and drops stale frames. |
6565
| `--stream-quality` | auto/default | Optional realtime stream quality profile, including `ci-software` for CI providers. |
6666
| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. |

docs/guide/lan-access.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Whatever you advertise must be resolvable from the remote client.
4848
{
4949
"ok": true,
5050
"httpPort": 4310,
51-
"videoCodec": "h264-software",
51+
"videoCodec": "h264",
5252
"lowLatency": false,
5353
"webRtc": {
5454
"iceServers": [{ "urls": ["stun:stun.l.google.com:19302"] }],

docs/guide/video.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ SimDeck streams the iOS Simulator over WebRTC using browser-native H.264 video p
66

77
The server can encode the simulator display in two modes, picked at startup with `--video-codec`:
88

9-
| Value | Encoder | When to use it |
10-
| --------------------------- | ------------------------------- | -------------------------------------------------------------- |
11-
| `h264` | Hardware H.264 via VideoToolbox | Best local performance when the hardware encoder is available. |
12-
| `h264-software` _(default)_ | Software H.264 via VideoToolbox | Compatibility fallback when hardware encode is unavailable. |
9+
| Value | Encoder | When to use it |
10+
| ------------------ | ------------------------------- | -------------------------------------------------------------- |
11+
| `h264` _(default)_ | Hardware H.264 via VideoToolbox | Best local performance when the hardware encoder is available. |
12+
| `h264-software` | Software H.264 via VideoToolbox | Compatibility fallback when hardware encode is unavailable. |
1313

1414
Restart the daemon to change encoder mode:
1515

@@ -76,10 +76,9 @@ The WebRTC path favors freshness: stale frames are dropped and the sender reques
7676

7777
A few practical guidelines:
7878

79-
- **Start on the default for compatibility.** `h264-software` works without requiring the hardware encoder, but full-resolution latency can be high.
80-
- **Switch to `h264` on local Apple Silicon when hardware encode is available.** Hardware H.264 gives the smoothest local preview with the least CPU.
79+
- **Start on the default for local preview.** `h264` gives the smoothest preview when VideoToolbox can provide a hardware encoder.
8180
- **Switch to `h264-software` when the hardware encoder stalls or is unavailable.** The encoder scales the longest edge to 1600 pixels, can climb toward 60 fps, and backs off dynamically under encode latency.
82-
- **Use `--stream-quality ci-software` for Studio providers on virtualized CI Macs when hardware encode is unavailable.** This profile uses software H.264 at an 844-pixel longest edge, targets 20 fps, lowers bitrate pressure, and favors fresh frames over full-resolution sharpness.
81+
- **Use `--stream-quality ci-software` for Studio providers on virtualized CI Macs when hardware encode is unavailable.** This profile uses software H.264 at a 960-pixel longest edge, targets 24 fps, lowers bitrate pressure, and favors fresh frames over full-resolution sharpness.
8382
- **Use `h264-software --low-latency` only when you need the older extra-conservative software profile.** It caps at 15 fps, uses a single pending frame, reduces the longest edge to 1170 pixels, and backs off before software encode latency turns into seconds of stream delay.
8483

8584
## Tuning with metrics

scripts/integration/cli.mjs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,14 @@ async function main() {
213213
async () => {
214214
fs.writeFileSync(
215215
stdoutPng,
216-
runBuffer(simdeck, ["screenshot", simulatorUDID, "--stdout"], {
217-
timeoutMs: 300_000,
218-
maxBuffer: 64 * 1024 * 1024,
219-
}),
216+
runBuffer(
217+
simdeck,
218+
["--server-url", serverUrl, "screenshot", simulatorUDID, "--stdout"],
219+
{
220+
timeoutMs: 300_000,
221+
maxBuffer: 64 * 1024 * 1024,
222+
},
223+
),
220224
);
221225
assertPng(stdoutPng);
222226
},

server/src/api/routes.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ const STREAM_QUALITY_PROFILES: &[StreamQualityProfile] = &[
106106
StreamQualityProfile {
107107
id: "ci-software",
108108
label: "CI Software",
109-
max_edge: 844,
110-
fps: 20,
111-
min_bitrate: 800_000,
112-
bits_per_pixel: 1,
109+
max_edge: 960,
110+
fps: 24,
111+
min_bitrate: 1_200_000,
112+
bits_per_pixel: 2,
113113
},
114114
StreamQualityProfile {
115115
id: "quality",
@@ -591,17 +591,17 @@ async fn set_stream_quality(
591591
.max_edge
592592
.or_else(|| profile.map(|profile| profile.max_edge))
593593
.unwrap_or(1440)
594-
.clamp(720, 1920);
594+
.clamp(320, 1920);
595595
let fps = payload
596596
.fps
597597
.or_else(|| profile.map(|profile| profile.fps))
598598
.unwrap_or(30)
599-
.clamp(15, 60);
599+
.clamp(10, 60);
600600
let min_bitrate = payload
601601
.min_bitrate
602602
.or_else(|| profile.map(|profile| profile.min_bitrate))
603603
.unwrap_or(3_000_000)
604-
.clamp(750_000, 20_000_000);
604+
.clamp(200_000, 20_000_000);
605605
let bits_per_pixel = payload
606606
.bits_per_pixel
607607
.or_else(|| profile.map(|profile| profile.bits_per_pixel))
@@ -645,12 +645,12 @@ fn stream_quality_state() -> Value {
645645
min_bitrate: 3_000_000,
646646
bits_per_pixel: 4,
647647
});
648-
let max_edge = env_u32("SIMDECK_REALTIME_MAX_EDGE", fallback.max_edge, 720, 1920);
649-
let fps = env_u32("SIMDECK_REALTIME_FPS", fallback.fps, 15, 60);
648+
let max_edge = env_u32("SIMDECK_REALTIME_MAX_EDGE", fallback.max_edge, 320, 1920);
649+
let fps = env_u32("SIMDECK_REALTIME_FPS", fallback.fps, 10, 60);
650650
let min_bitrate = env_u32(
651651
"SIMDECK_REALTIME_MIN_BITRATE",
652652
fallback.min_bitrate,
653-
750_000,
653+
200_000,
654654
20_000_000,
655655
);
656656
let bits_per_pixel = env_u32(

server/src/main.rs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ const SERVER_FD_RESTART_THRESHOLD: usize = 4096;
4747
const SERVER_HEALTH_WATCHDOG_INITIAL_DELAY: Duration = Duration::from_secs(15);
4848
const SERVER_HEALTH_WATCHDOG_INTERVAL: Duration = Duration::from_secs(5);
4949
const SERVER_HEALTH_WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
50-
const SERVER_HEALTH_WATCHDOG_STALE_HEARTBEAT: Duration = Duration::from_secs(10);
51-
const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 3;
50+
const SERVER_HEALTH_WATCHDOG_STALE_HEARTBEAT: Duration = Duration::from_secs(60);
51+
const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 12;
5252

5353
#[derive(Parser)]
5454
#[command(name = "simdeck")]
@@ -79,7 +79,7 @@ enum Command {
7979
advertise_host: Option<String>,
8080
#[arg(long)]
8181
client_root: Option<PathBuf>,
82-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
82+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
8383
video_codec: VideoCodecMode,
8484
#[arg(long)]
8585
low_latency: bool,
@@ -106,7 +106,7 @@ enum Command {
106106
advertise_host: Option<String>,
107107
#[arg(long)]
108108
client_root: Option<PathBuf>,
109-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
109+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
110110
video_codec: VideoCodecMode,
111111
#[arg(long)]
112112
low_latency: bool,
@@ -380,7 +380,7 @@ enum DaemonCommand {
380380
advertise_host: Option<String>,
381381
#[arg(long)]
382382
client_root: Option<PathBuf>,
383-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
383+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
384384
video_codec: VideoCodecMode,
385385
#[arg(long)]
386386
low_latency: bool,
@@ -396,7 +396,7 @@ enum DaemonCommand {
396396
advertise_host: Option<String>,
397397
#[arg(long)]
398398
client_root: Option<PathBuf>,
399-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
399+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
400400
video_codec: VideoCodecMode,
401401
#[arg(long)]
402402
low_latency: bool,
@@ -420,7 +420,7 @@ enum DaemonCommand {
420420
advertise_host: Option<String>,
421421
#[arg(long)]
422422
client_root: Option<PathBuf>,
423-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
423+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
424424
video_codec: VideoCodecMode,
425425
#[arg(long)]
426426
low_latency: bool,
@@ -463,7 +463,7 @@ enum ServiceCommand {
463463
advertise_host: Option<String>,
464464
#[arg(long)]
465465
client_root: Option<PathBuf>,
466-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
466+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
467467
video_codec: VideoCodecMode,
468468
#[arg(long)]
469469
low_latency: bool,
@@ -481,7 +481,7 @@ enum ServiceCommand {
481481
advertise_host: Option<String>,
482482
#[arg(long)]
483483
client_root: Option<PathBuf>,
484-
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264Software)]
484+
#[arg(long, value_enum, default_value_t = VideoCodecMode::H264)]
485485
video_codec: VideoCodecMode,
486486
#[arg(long)]
487487
low_latency: bool,
@@ -652,10 +652,10 @@ fn stream_quality_env_for_profile(profile: &str) -> anyhow::Result<StreamQuality
652652
}),
653653
"ci-software" => Ok(StreamQualityEnvironment {
654654
profile: "ci-software",
655-
max_edge: 844,
656-
fps: 20,
657-
min_bitrate: 800_000,
658-
bits_per_pixel: 1,
655+
max_edge: 960,
656+
fps: 24,
657+
min_bitrate: 1_200_000,
658+
bits_per_pixel: 2,
659659
}),
660660
_ => anyhow::bail!("Unknown stream quality profile `{profile}`."),
661661
}
@@ -710,7 +710,7 @@ impl Default for DaemonLaunchOptions {
710710
bind: IpAddr::V4(Ipv4Addr::LOCALHOST),
711711
advertise_host: None,
712712
client_root: None,
713-
video_codec: VideoCodecMode::H264Software,
713+
video_codec: VideoCodecMode::H264,
714714
low_latency: false,
715715
realtime_stream: false,
716716
stream_quality_profile: None,
@@ -1071,17 +1071,24 @@ fn project_root() -> anyhow::Result<PathBuf> {
10711071
}
10721072

10731073
fn choose_daemon_port(preferred: u16) -> anyhow::Result<u16> {
1074+
choose_daemon_port_for_bind(preferred, IpAddr::V4(Ipv4Addr::LOCALHOST))
1075+
}
1076+
1077+
fn choose_daemon_port_for_bind(preferred: u16, bind: IpAddr) -> anyhow::Result<u16> {
10741078
let start = preferred.max(1024);
10751079
for port in start..start.saturating_add(200) {
1076-
if port_available(port) {
1080+
if port_available(bind, port) {
10771081
return Ok(port);
10781082
}
10791083
}
10801084
anyhow::bail!("No available SimDeck daemon port near {preferred}")
10811085
}
10821086

1083-
fn port_available(port: u16) -> bool {
1084-
TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_ok()
1087+
fn port_available(bind: IpAddr, port: u16) -> bool {
1088+
if bind.is_unspecified() && TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_err() {
1089+
return false;
1090+
}
1091+
TcpListener::bind((bind, port)).is_ok()
10851092
}
10861093

10871094
fn open_browser(url: &str) -> anyhow::Result<()> {
@@ -1184,9 +1191,9 @@ fn run_foreground_ui(selector: Option<String>) -> anyhow::Result<()> {
11841191
}
11851192

11861193
let project_root = project_root()?;
1187-
let port = choose_daemon_port(4310)?;
11881194
let bind = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
1189-
let video_codec = VideoCodecMode::H264Software;
1195+
let port = choose_daemon_port_for_bind(4310, bind)?;
1196+
let video_codec = VideoCodecMode::H264;
11901197
let low_latency = false;
11911198
let advertise_host = detect_lan_ip()
11921199
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))

0 commit comments

Comments
 (0)