From 6855123429e0de467264a6b38967697a2ecbc4d7 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Thu, 28 May 2026 16:53:31 -0400 Subject: [PATCH 1/2] fix: speed up LaunchAgent service startup --- packages/server/src/main.rs | 14 ++++++++++++-- packages/server/src/service.rs | 1 - 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index b2301cc3..05e47c6b 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -66,6 +66,8 @@ const SERVER_HEALTH_WATCHDOG_STALE_HEARTBEAT: Duration = Duration::from_secs(60) const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 12; const SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD: usize = 3; const SERVICE_PORT: u16 = 4310; +const ORPHAN_WORKSPACE_SERVICE_SHUTDOWN_GRACE: Duration = Duration::from_millis(250); +const ORPHAN_WORKSPACE_SERVICE_KILL_GRACE: Duration = Duration::from_millis(250); #[derive(Parser)] #[command(name = "simdeck")] @@ -1518,7 +1520,11 @@ fn cleanup_orphaned_workspace_services( continue; } if killed_groups.insert(process.pgid) { - terminate_process_group(process.pgid, Duration::from_secs(3)); + terminate_process_group_with_kill_timeout( + process.pgid, + ORPHAN_WORKSPACE_SERVICE_SHUTDOWN_GRACE, + ORPHAN_WORKSPACE_SERVICE_KILL_GRACE, + ); killed.push(process); } } @@ -1591,6 +1597,10 @@ fn command_arg_after(command: &str, flag: &str) -> Option { } fn terminate_process_group(pid: u32, timeout: Duration) { + terminate_process_group_with_kill_timeout(pid, timeout, Duration::from_secs(2)); +} + +fn terminate_process_group_with_kill_timeout(pid: u32, timeout: Duration, kill_timeout: Duration) { signal_process_group(pid, "TERM"); signal_process(pid, "TERM"); if wait_for_process_exit(pid, timeout) { @@ -1598,7 +1608,7 @@ fn terminate_process_group(pid: u32, timeout: Duration) { } signal_process_group(pid, "KILL"); signal_process(pid, "KILL"); - let _ = wait_for_process_exit(pid, Duration::from_secs(2)); + let _ = wait_for_process_exit(pid, kill_timeout); } fn signal_process(pid: u32, signal: &str) { diff --git a/packages/server/src/service.rs b/packages/server/src/service.rs index 5d3f7f73..7155ad6f 100644 --- a/packages/server/src/service.rs +++ b/packages/server/src/service.rs @@ -157,7 +157,6 @@ fn install(mut options: ServiceOptions) -> anyhow::Result fs::write(&plist_path, plist).with_context(|| format!("write {}", plist_path.display()))?; run_launchctl(["bootstrap", &domain, plist_path.to_string_lossy().as_ref()])?; - run_launchctl(["kickstart", "-k", &format!("{domain}/{SERVICE_LABEL}")])?; let advertise_host = options.advertise_host.clone(); let access_token = options.access_token.clone(); From 774b73b72dd7f0f59de147d7e632ee4f6cb4b041 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Thu, 28 May 2026 17:11:01 -0400 Subject: [PATCH 2/2] fix: preserve service restart port --- .github/workflows/ci.yml | 5 + docs/cli/flags.md | 5 +- docs/guide/service.md | 3 + package.json | 1 + packages/server/src/main.rs | 42 ++++- scripts/integration/service-restart.mjs | 199 ++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 scripts/integration/service-restart.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c2b7e6a..6d3f2ee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,11 @@ jobs: env: SIMDECK_INTEGRATION_VERBOSE: "1" + - name: LaunchAgent service restart integration test + run: npm run test:integration:service + env: + SIMDECK_INTEGRATION_LAUNCHAGENT: "1" + integration-js-api: name: JS API simulator integration runs-on: macos-15 diff --git a/docs/cli/flags.md b/docs/cli/flags.md index cdd48eb0..d8b4cf74 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -22,10 +22,13 @@ project default from `simdeck use `, then auto-inference from the service. ## Server options Used by `simdeck`, `service start`, `service restart`, `service on`, and `service reset`. +When `service restart` is run without `--port`, it preserves the installed +LaunchAgent port or the current singleton service port before falling back to +`4310`. | Flag | Default | Notes | | ---------------------------- | -------------- | --------------------------------------------------------------------------------- | -| `--port ` / `-p` | `4310` | HTTP port | +| `--port ` / `-p` | `4310` | HTTP port; `service restart` preserves the existing service port when omitted | | `--bind ` | `127.0.0.1` | Use `0.0.0.0` or `::` for LAN access | | `--advertise-host ` | detected | Host printed for remote browsers | | `--client-root ` | bundled client | Static client directory | diff --git a/docs/guide/service.md b/docs/guide/service.md index 35eebff2..81f886ad 100644 --- a/docs/guide/service.md +++ b/docs/guide/service.md @@ -54,6 +54,9 @@ service that `simdeck` uses. `service reset` rotates the LaunchAgent token and pairing code. `service off` removes the LaunchAgent. `service kill` and `service killall` stop every SimDeck service process they can find, including services started by another SimDeck binary. +When `service restart` is run without `--port`, it keeps the installed +LaunchAgent port or the current singleton service port before falling back to +`4310`. ## Options diff --git a/package.json b/package.json index 7b971d4d..ba275b1e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "test": "cargo test --manifest-path packages/server/Cargo.toml && npm run --prefix packages/client test && npm run test:github-actions && npm run test:integration-harness", "test:integration:cli": "node scripts/integration/cli.mjs", "test:integration:cli:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/cli.mjs", + "test:integration:service": "node scripts/integration/service-restart.mjs", "test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs", "test:integration:js-api": "node scripts/integration/js-api.mjs", "test:integration:webrtc": "node scripts/integration/webrtc.mjs", diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index 05e47c6b..43bfe0ef 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -618,8 +618,11 @@ enum ServiceCommand { access_token: Option, }, Restart { - #[arg(long, default_value_t = SERVICE_PORT)] - port: u16, + #[arg( + long, + help = "Defaults to the existing service port, or 4310 when no service state exists" + )] + port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] bind: IpAddr, #[arg(long)] @@ -2510,6 +2513,19 @@ fn restart_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()> start_detached_service(options) } +fn service_restart_port(explicit_port: Option) -> anyhow::Result { + if let Some(port) = explicit_port { + return Ok(port); + } + if let Some(port) = service::installed_port()? { + return Ok(port); + } + if let Some(metadata) = read_service_metadata().ok().flatten() { + return Ok(metadata.port); + } + Ok(SERVICE_PORT) +} + struct PairGlobalServiceOptions { port: Option, bind: IpAddr, @@ -3508,6 +3524,7 @@ fn main() -> anyhow::Result<()> { stream_quality, local_stream_fps, } => { + let port = service_restart_port(port)?; cleanup_orphaned_workspace_services_for_root(None); restart_detached_service(ServiceLaunchOptions { port, @@ -6037,6 +6054,27 @@ mod tests { assert_eq!(access_token.as_deref(), Some("explicit-token")); } + #[test] + fn service_restart_command_preserves_omitted_port() { + let cli = Cli::parse_from(["simdeck", "service", "restart"]); + let Command::Service { + command: ServiceCommand::Restart { port, .. }, + } = cli.command + else { + panic!("expected service restart command"); + }; + assert_eq!(port, None); + + let cli = Cli::parse_from(["simdeck", "service", "restart", "--port", "4315"]); + let Command::Service { + command: ServiceCommand::Restart { port, .. }, + } = cli.command + else { + panic!("expected service restart command"); + }; + assert_eq!(port, Some(4315)); + } + #[test] fn workspace_service_process_parser_reads_supervised_command_paths() { let process = parse_workspace_service_process_line( diff --git a/scripts/integration/service-restart.mjs b/scripts/integration/service-restart.mjs new file mode 100644 index 00000000..3b435069 --- /dev/null +++ b/scripts/integration/service-restart.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { createServer } from "node:net"; +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 binary = resolve( + root, + process.env.SIMDECK_INTEGRATION_SERVICE_BINARY ?? join("build", "simdeck"), +); +const enabled = + process.env.SIMDECK_INTEGRATION_LAUNCHAGENT === "1" || + process.env.CI === "true"; + +if (process.platform !== "darwin") { + console.log("Skipping LaunchAgent restart integration test on non-macOS."); + process.exit(0); +} + +if (!enabled) { + console.log( + "Skipping LaunchAgent restart integration test. Set SIMDECK_INTEGRATION_LAUNCHAGENT=1 to run it.", + ); + process.exit(0); +} + +if (!existsSync(binary)) { + throw new Error( + `Missing SimDeck binary at ${binary}. Run npm run build:cli first.`, + ); +} + +const tempRoot = await mkdtemp(join(tmpdir(), "simdeck-launchagent-it-")); +const home = join(tempRoot, "home"); +const projectRoot = join(tempRoot, "project"); +mkdirSync(home, { recursive: true }); +mkdirSync(projectRoot, { recursive: true }); + +const servicePort = await findFreePort(); +const clientRoot = join(root, "packages", "client", "dist"); +const env = { + ...process.env, + HOME: home, +}; + +let blocker = null; + +try { + blocker = await listenIfAvailable("127.0.0.1", 4310); + runJson(["service", "off"], { allowFailure: true }); + + const onArgs = [ + "service", + "on", + "--port", + String(servicePort), + "--bind", + "127.0.0.1", + "--video-codec", + "software", + "--stream-quality", + "tiny", + ]; + if (existsSync(clientRoot)) { + onArgs.push("--client-root", clientRoot); + } + + const installed = runJson(onArgs); + assertEqual(installed.ok, true, "service on should succeed"); + assertEqual( + installed.port, + servicePort, + "service on should install requested port", + ); + + await waitForHealth(servicePort); + + const restarted = runJson(["service", "restart"]); + assertEqual(restarted.ok, true, "service restart should succeed"); + assertEqual( + restarted.port, + servicePort, + "service restart without --port should preserve the installed LaunchAgent port", + ); + + await waitForHealth(servicePort); + const status = runJson(["service", "status"]); + assertEqual(status.healthy, true, "service should be healthy after restart"); + assertEqual( + status.service?.port, + servicePort, + "status should report the preserved LaunchAgent port", + ); + + console.log( + JSON.stringify( + { + ok: true, + binary, + servicePort, + defaultPortBlocked: Boolean(blocker), + }, + null, + 2, + ), + ); +} finally { + try { + runJson(["service", "off"], { allowFailure: true }); + } finally { + if (blocker) { + await new Promise((resolveClose) => blocker.close(resolveClose)); + } + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function runJson(args, options = {}) { + const result = spawnSync(binary, args, { + cwd: projectRoot, + env, + encoding: "utf8", + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (result.status !== 0) { + if (options.allowFailure) { + return null; + } + throw new Error( + `${binary} ${args.join(" ")} failed with ${result.status}:\n${output}`, + ); + } + if (!output) { + return null; + } + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error( + `${binary} ${args.join(" ")} did not print JSON: ${error.message}\n${output}`, + ); + } +} + +async function waitForHealth(port) { + const deadline = Date.now() + 15_000; + let lastError = null; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/health`); + if (response.ok) { + return; + } + lastError = new Error(`/api/health returned ${response.status}`); + } catch (error) { + lastError = error; + } + await sleep(50); + } + throw new Error( + `Timed out waiting for SimDeck service on ${port}: ${lastError?.message ?? "unknown error"}`, + ); +} + +async function findFreePort() { + const server = await listenIfAvailable("127.0.0.1", 0); + const address = server.address(); + await new Promise((resolveClose) => server.close(resolveClose)); + return address.port; +} + +function listenIfAvailable(host, port) { + return new Promise((resolveListen, rejectListen) => { + const server = createServer(); + server.once("error", (error) => { + if (error.code === "EADDRINUSE" && port !== 0) { + resolveListen(null); + return; + } + rejectListen(error); + }); + server.listen(port, host, () => resolveListen(server)); + }); +} + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}