Skip to content

Commit b704f9a

Browse files
committed
Stabilize detached WebRTC daemons
1 parent b561418 commit b704f9a

11 files changed

Lines changed: 159 additions & 79 deletions

File tree

cli/XCWH264Encoder.m

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
static const int32_t XCWMaximumLowLatencySoftwareEncodedDimension = 1170;
1212
static const int32_t XCWTargetRealTimeFrameRate = 60;
1313
static const int32_t XCWTargetSoftwareFrameRate = 60;
14-
static const int32_t XCWTargetLowLatencySoftwareFrameRate = 30;
14+
static const int32_t XCWTargetLowLatencySoftwareFrameRate = 15;
1515
static const NSUInteger XCWMaximumInFlightFrames = 2;
1616
static const int32_t XCWMinimumAverageBitRate = 18000000;
1717
static const int32_t XCWMinimumSoftwareAverageBitRate = 3000000;
@@ -24,9 +24,9 @@
2424
static const uint64_t XCWSoftwareMaximumFrameIntervalUs = 50000;
2525
static const uint64_t XCWSoftwareFrameIntervalStepUs = 5556;
2626
static const NSUInteger XCWSoftwareHealthyFrameWindow = 4;
27-
static const uint64_t XCWLowLatencySoftwareMinimumFrameIntervalUs = 33333;
28-
static const uint64_t XCWLowLatencySoftwareInitialFrameIntervalUs = 33333;
29-
static const uint64_t XCWLowLatencySoftwareMaximumFrameIntervalUs = 100000;
27+
static const uint64_t XCWLowLatencySoftwareMinimumFrameIntervalUs = 66667;
28+
static const uint64_t XCWLowLatencySoftwareInitialFrameIntervalUs = 66667;
29+
static const uint64_t XCWLowLatencySoftwareMaximumFrameIntervalUs = 133333;
3030
static const uint64_t XCWLowLatencySoftwareFrameIntervalStepUs = 11111;
3131
static const NSUInteger XCWLowLatencySoftwareHealthyFrameWindow = 8;
3232

client/src/features/stream/streamWorkerClient.ts

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import type {
88

99
const HAVE_CURRENT_DATA = 2;
1010
const WEBRTC_CONTROL_CHANNEL_LABEL = "simdeck-control";
11-
const WEBRTC_FIRST_FRAME_TIMEOUT_MS = 3500;
12-
const WEBRTC_STALLED_FRAME_TIMEOUT_MS = 3500;
11+
const WEBRTC_FIRST_FRAME_TIMEOUT_MS = 10000;
12+
const WEBRTC_STALLED_FRAME_TIMEOUT_MS = 8000;
1313

1414
let activeWebRtcControlChannel: RTCDataChannel | null = null;
1515
let activeStreamClient: StreamWorkerClient | null = null;
@@ -50,7 +50,7 @@ class WebRtcStreamClient implements StreamClientBackend {
5050
private lastVideoFrameAt = 0;
5151
private peerConnection: RTCPeerConnection | null = null;
5252
private reconnectTimeout = 0;
53-
private renderVideoToCanvas = false;
53+
private reportedVideoConfig = false;
5454
private shouldReconnect = false;
5555
private stats: StreamStats = createEmptyStreamStats();
5656
private video: HTMLVideoElement | null = null;
@@ -62,7 +62,6 @@ class WebRtcStreamClient implements StreamClientBackend {
6262

6363
attachCanvas(canvasElement: HTMLCanvasElement) {
6464
this.canvas = canvasElement;
65-
this.canvas.classList.remove("stream-canvas-webrtc-render");
6665
}
6766

6867
clear() {
@@ -80,6 +79,7 @@ class WebRtcStreamClient implements StreamClientBackend {
8079
const generation = ++this.connectGeneration;
8180
this.shouldReconnect = true;
8281
this.diagnostics = createWebRtcDiagnostics();
82+
this.reportedVideoConfig = false;
8383
this.stats = createEmptyStreamStats();
8484
this.onMessage({
8585
type: "status",
@@ -122,18 +122,13 @@ class WebRtcStreamClient implements StreamClientBackend {
122122
}
123123
const stream = event.streams[0] ?? new MediaStream([event.track]);
124124
const video = document.createElement("video");
125-
this.renderVideoToCanvas = shouldRenderWebRtcVideoThroughCanvas();
126-
canvasElement.classList.toggle(
127-
"stream-canvas-webrtc-render",
128-
this.renderVideoToCanvas,
129-
);
130125
video.autoplay = true;
131-
video.className = this.renderVideoToCanvas
132-
? "stream-video stream-video-canvas-source"
133-
: "stream-video";
126+
video.className = "stream-video";
134127
video.disablePictureInPicture = true;
135128
video.muted = true;
136129
video.playsInline = true;
130+
video.setAttribute("playsinline", "");
131+
video.setAttribute("webkit-playsinline", "");
137132
video.preload = "auto";
138133
(video as HTMLVideoElement & { latencyHint?: string }).latencyHint =
139134
"interactive";
@@ -151,14 +146,7 @@ class WebRtcStreamClient implements StreamClientBackend {
151146
return;
152147
}
153148
this.syncCanvasSize(video.videoWidth, video.videoHeight);
154-
this.onMessage({
155-
type: "video-config",
156-
size: { height: video.videoHeight, width: video.videoWidth },
157-
});
158-
this.onMessage({
159-
type: "status",
160-
status: { detail: "WebRTC media connected", state: "streaming" },
161-
});
149+
this.reportVideoConfig(video.videoWidth, video.videoHeight);
162150
this.scheduleVideoFrame();
163151
};
164152
video.addEventListener("loadedmetadata", startPlayback);
@@ -168,6 +156,7 @@ class WebRtcStreamClient implements StreamClientBackend {
168156
void video.play().catch(() => {
169157
// The readiness listeners above retry once the media stream has data.
170158
});
159+
this.scheduleVideoFrame();
171160
};
172161

173162
peerConnection.onconnectionstatechange = () => {
@@ -250,8 +239,7 @@ class WebRtcStreamClient implements StreamClientBackend {
250239
this.video.remove();
251240
}
252241
this.video = null;
253-
this.canvas?.classList.remove("stream-canvas-webrtc-render");
254-
this.renderVideoToCanvas = false;
242+
this.reportedVideoConfig = false;
255243
this.controlChannel?.close();
256244
if (activeWebRtcControlChannel === this.controlChannel) {
257245
activeWebRtcControlChannel = null;
@@ -487,13 +475,9 @@ class WebRtcStreamClient implements StreamClientBackend {
487475
this.video.videoHeight > 0
488476
) {
489477
this.syncCanvasSize(this.video.videoWidth, this.video.videoHeight);
478+
this.reportVideoConfig(this.video.videoWidth, this.video.videoHeight);
490479
const now = performance.now();
491480
const renderStartedAt = performance.now();
492-
if (this.renderVideoToCanvas) {
493-
this.canvas
494-
.getContext("2d")
495-
?.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
496-
}
497481
const latestRenderMs = performance.now() - renderStartedAt;
498482
this.stats.decodedFrames += 1;
499483
this.stats.renderedFrames += 1;
@@ -530,6 +514,21 @@ class WebRtcStreamClient implements StreamClientBackend {
530514
this.animationFrame = window.requestAnimationFrame(this.drawVideoFrame);
531515
}
532516

517+
private reportVideoConfig(width: number, height: number) {
518+
if (this.reportedVideoConfig) {
519+
return;
520+
}
521+
this.reportedVideoConfig = true;
522+
this.onMessage({
523+
type: "video-config",
524+
size: { height, width },
525+
});
526+
this.onMessage({
527+
type: "status",
528+
status: { detail: "WebRTC media connected", state: "streaming" },
529+
});
530+
}
531+
533532
private cancelVideoFrameCallback() {
534533
if (!this.videoFrameCallback || !this.video) {
535534
return;
@@ -620,14 +619,6 @@ function configureReceiverCodecPreferences(transceiver: RTCRtpTransceiver) {
620619
]);
621620
}
622621

623-
function shouldRenderWebRtcVideoThroughCanvas(): boolean {
624-
const userAgent = window.navigator.userAgent;
625-
return (
626-
/Safari/i.test(userAgent) &&
627-
!/Chrome|Chromium|CriOS|Edg|OPR/i.test(userAgent)
628-
);
629-
}
630-
631622
function iceServers(): RTCIceServer[] {
632623
const params = new URLSearchParams(window.location.search);
633624
const raw = params.get("iceServers") ?? "stun:stun.l.google.com:19302";

client/src/styles/components.css

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,10 +1167,6 @@
11671167
pointer-events: none;
11681168
}
11691169

1170-
.stream-canvas-webrtc-render {
1171-
z-index: 1;
1172-
}
1173-
11741170
.stream-video {
11751171
position: absolute;
11761172
inset: 0;
@@ -1183,11 +1179,6 @@
11831179
pointer-events: none;
11841180
}
11851181

1186-
.stream-video-canvas-source {
1187-
z-index: 0;
1188-
opacity: 0;
1189-
}
1190-
11911182
.accessibility-picker-layer {
11921183
position: absolute;
11931184
inset: 0;

docs/cli/commands.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ Print daemon metadata for the current project:
6868
simdeck daemon status
6969
```
7070

71+
Detached daemons report the supervisor PID and `logPath`; the supervised child
72+
process is restarted automatically after recoverable server exits.
73+
7174
### `daemon restart`
7275

7376
Stop the daemon for the current project, then start a fresh one with the same

docs/cli/flags.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Targets a specific running SimDeck daemon for commands that support the HTTP fas
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. |
3737
| `--video-codec` | `h264-software` | One of `h264` or `h264-software`. See [Video Pipeline](/guide/video). |
38-
| `--low-latency` | `false` | Software H.264 profile for slower runners: caps at 30 fps and favors freshness. |
38+
| `--low-latency` | `false` | Software H.264 profile for slower runners: caps at 15 fps and favors freshness. |
3939
| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. |
4040

4141
The public commands generate an access token automatically. Use `simdeck daemon status` to read it for direct API callers.

docs/guide/daemon.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ This starts or reuses the project daemon, serves the bundled browser client, and
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. |
6363
| `--video-codec` | `h264-software` | One of `h264` or `h264-software`. See [Video](/guide/video). |
64-
| `--low-latency` | `false` | Software H.264 profile for slower runners; caps at 30 fps and drops stale frames. |
64+
| `--low-latency` | `false` | Software H.264 profile for slower runners; caps at 15 fps and drops stale frames. |
6565
| `--open` | `false` | `ui` only. Open the browser after the daemon is ready. |
6666

6767
Example:
@@ -76,7 +76,10 @@ simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
7676
simdeck daemon status
7777
```
7878

79-
The status output includes the daemon URL, PID, project root, and access token. Local same-origin browser use does not require copying the token; direct remote API callers should send it as `X-SimDeck-Token` or `Authorization: Bearer <token>`.
79+
The status output includes the daemon URL, supervisor PID, project root, access
80+
token, and detached daemon log path. Local same-origin browser use does not
81+
require copying the token; direct remote API callers should send it as
82+
`X-SimDeck-Token` or `Authorization: Bearer <token>`.
8083

8184
## Restart
8285

docs/guide/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ The encoder did not produce a keyframe within 3 seconds. The most common causes:
8787

8888
On virtualized CI Macs where hardware H.264 is unavailable, keep
8989
`h264-software`. If the stream still falls behind, restart with
90-
`--video-codec h264-software --low-latency`; that profile caps at 30 fps,
90+
`--video-codec h264-software --low-latency`; that profile caps at 15 fps,
9191
drops stale pending frames, and caps the longest edge at 1170 pixels before backlog
9292
turns into visible stream delay.
9393

docs/guide/video.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ For slower runners, add `--low-latency` with software H.264:
2323
simdeck daemon start --video-codec h264-software --low-latency
2424
```
2525

26-
Low-latency mode caps software H.264 at 30 fps, keeps a single in-flight frame,
26+
Low-latency mode caps software H.264 at 15 fps, keeps a single in-flight frame,
2727
scales the longest edge to 1170 pixels, and backs off FPS more aggressively when
28-
encode pressure rises. It is CLI-only because it is meant for less capable
29-
machines where freshness matters more than maximum smoothness.
28+
encode pressure rises. WebRTC refresh pacing uses the same 15 fps floor so the
29+
server does not keep waking capture/encode faster than the stream can consume.
30+
It is CLI-only because it is meant for less capable machines where freshness
31+
matters more than maximum smoothness.
3032

3133
The chosen codec is reported to clients in the JSON `videoCodec` field on `GET /api/health`.
3234

@@ -58,7 +60,7 @@ A few practical guidelines:
5860
- **Start on the default for compatibility.** `h264-software` works without requiring the hardware encoder, but full-resolution latency can be high.
5961
- **Switch to `h264` on local Apple Silicon when hardware encode is available.** Hardware H.264 gives the smoothest local preview with the least CPU.
6062
- **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.
61-
- **Use `h264-software --low-latency` on virtualized CI Macs when hardware encode is unavailable.** This profile caps at 30 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.
63+
- **Use `h264-software --low-latency` on virtualized CI Macs when hardware encode is unavailable.** This profile 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.
6264

6365
## Tuning with metrics
6466

server/src/main.rs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use std::time::{Duration, Instant};
4141
use tracing::info;
4242

4343
const RECOVERABLE_RESTART_EXIT_CODE: i32 = 75;
44+
const SUPERVISED_DAEMON_METADATA_PID_ENV: &str = "SIMDECK_DAEMON_METADATA_PID";
4445
const RESTART_ON_CORE_SIMULATOR_MISMATCH_ENV: &str = "SIMDECK_RESTART_ON_CORE_SIMULATOR_MISMATCH";
4546
const SERVER_FD_RESTART_THRESHOLD: usize = 4096;
4647
const SERVER_HEALTH_WATCHDOG_INITIAL_DELAY: Duration = Duration::from_secs(15);
@@ -510,6 +511,8 @@ struct DaemonMetadata {
510511
pairing_code: Option<String>,
511512
binary_path: PathBuf,
512513
started_at: u64,
514+
#[serde(default, skip_serializing_if = "Option::is_none")]
515+
log_path: Option<PathBuf>,
513516
}
514517

515518
#[derive(Clone, Debug)]
@@ -572,6 +575,7 @@ fn ensure_project_daemon_with_status(
572575
fn start_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result<DaemonMetadata> {
573576
let project_root = project_root()?;
574577
let metadata_path = daemon_metadata_path_for_root(&project_root)?;
578+
let log_path = daemon_log_path_for_root(&project_root)?;
575579
let port = choose_daemon_port(options.port)?;
576580
let access_token = auth::generate_access_token();
577581
let pairing_code = auth::generate_pairing_code();
@@ -606,11 +610,43 @@ fn start_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result<DaemonMe
606610
args.push(client_root.to_string_lossy().into_owned());
607611
}
608612

609-
let child = ProcessCommand::new(&executable)
613+
let log_stdout = fs::OpenOptions::new()
614+
.create(true)
615+
.append(true)
616+
.open(&log_path)
617+
.with_context(|| format!("open daemon log {}", log_path.display()))?;
618+
let log_stderr = log_stdout
619+
.try_clone()
620+
.with_context(|| format!("clone daemon log {}", log_path.display()))?;
621+
let supervisor_script = format!(
622+
r#"trap 'if [ -n "$child" ]; then kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; fi; exit 0' TERM INT
623+
while :; do
624+
{metadata_pid_env}=$$ "$@" &
625+
child=$!
626+
wait "$child"
627+
status=$?
628+
child=
629+
if [ "$status" -eq {recoverable_restart_exit_code} ] || [ "$status" -ge 128 ]; then
630+
printf '[simdeck-supervisor] daemon exited with status %s; restarting\n' "$status" >&2
631+
sleep 1
632+
continue
633+
fi
634+
exit "$status"
635+
done
636+
"#,
637+
metadata_pid_env = SUPERVISED_DAEMON_METADATA_PID_ENV,
638+
recoverable_restart_exit_code = RECOVERABLE_RESTART_EXIT_CODE
639+
);
640+
641+
let child = ProcessCommand::new("/bin/sh")
642+
.arg("-c")
643+
.arg(supervisor_script)
644+
.arg("simdeck-supervisor")
645+
.arg(&executable)
610646
.args(args)
611647
.stdin(Stdio::null())
612-
.stdout(Stdio::null())
613-
.stderr(Stdio::null())
648+
.stdout(Stdio::from(log_stdout))
649+
.stderr(Stdio::from(log_stderr))
614650
.spawn()
615651
.context("start project SimDeck daemon")?;
616652

@@ -622,6 +658,7 @@ fn start_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result<DaemonMe
622658
pairing_code: Some(pairing_code),
623659
binary_path: executable,
624660
started_at: now_secs(),
661+
log_path: Some(log_path),
625662
};
626663
write_daemon_metadata(&metadata)?;
627664
wait_for_daemon(&metadata, Duration::from_secs(15))?;
@@ -787,6 +824,14 @@ fn daemon_metadata_path_for_root(root: &Path) -> anyhow::Result<PathBuf> {
787824
.join(format!("{:016x}.json", hasher.finish())))
788825
}
789826

827+
fn daemon_log_path_for_root(root: &Path) -> anyhow::Result<PathBuf> {
828+
let mut hasher = DefaultHasher::new();
829+
root.to_string_lossy().hash(&mut hasher);
830+
Ok(env::temp_dir()
831+
.join("simdeck")
832+
.join(format!("{:016x}.log", hasher.finish())))
833+
}
834+
790835
fn daemon_metadata_paths() -> anyhow::Result<Vec<PathBuf>> {
791836
let dir = env::temp_dir().join("simdeck");
792837
if !dir.exists() {
@@ -950,6 +995,7 @@ fn run_foreground_ui(selector: Option<String>) -> anyhow::Result<()> {
950995
pairing_code: Some(pairing_code.clone()),
951996
binary_path: executable,
952997
started_at: now_secs(),
998+
log_path: None,
953999
};
9541000
write_daemon_metadata(&metadata)?;
9551001

@@ -978,6 +1024,13 @@ fn run_foreground_ui(selector: Option<String>) -> anyhow::Result<()> {
9781024
result
9791025
}
9801026

1027+
fn supervised_daemon_metadata_pid() -> Option<u32> {
1028+
env::var(SUPERVISED_DAEMON_METADATA_PID_ENV)
1029+
.ok()
1030+
.and_then(|value| value.parse::<u32>().ok())
1031+
.filter(|pid| *pid > 0)
1032+
}
1033+
9811034
fn detect_lan_ip() -> Option<IpAddr> {
9821035
for target in ["8.8.8.8:80", "1.1.1.1:80"] {
9831036
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?;
@@ -1111,14 +1164,16 @@ fn main() -> anyhow::Result<()> {
11111164
env::set_current_dir(&project_root).with_context(|| {
11121165
format!("set daemon project root to {}", project_root.display())
11131166
})?;
1167+
let log_path = daemon_log_path_for_root(&project_root).ok();
11141168
write_daemon_metadata(&DaemonMetadata {
11151169
project_root,
1116-
pid: std::process::id(),
1170+
pid: supervised_daemon_metadata_pid().unwrap_or_else(std::process::id),
11171171
http_url: format!("http://127.0.0.1:{port}"),
11181172
access_token: access_token.clone(),
11191173
pairing_code: pairing_code.clone(),
11201174
binary_path: env::current_exe().context("resolve daemon executable")?,
11211175
started_at: now_secs(),
1176+
log_path,
11221177
})?;
11231178
let result = serve_with_appkit(
11241179
port,

0 commit comments

Comments
 (0)