Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/cli/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ project default from `simdeck use <udid>`, 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 <port>` / `-p` | `4310` | HTTP port |
| `--port <port>` / `-p` | `4310` | HTTP port; `service restart` preserves the existing service port when omitted |
| `--bind <ip>` | `127.0.0.1` | Use `0.0.0.0` or `::` for LAN access |
| `--advertise-host <host>` | detected | Host printed for remote browsers |
| `--client-root <path>` | bundled client | Static client directory |
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 52 additions & 4 deletions packages/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -616,8 +618,11 @@ enum ServiceCommand {
access_token: Option<String>,
},
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<u16>,
#[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))]
bind: IpAddr,
#[arg(long)]
Expand Down Expand Up @@ -1518,7 +1523,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);
}
}
Expand Down Expand Up @@ -1591,14 +1600,18 @@ fn command_arg_after(command: &str, flag: &str) -> Option<String> {
}

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) {
return;
}
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) {
Expand Down Expand Up @@ -2500,6 +2513,19 @@ fn restart_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()>
start_detached_service(options)
}

fn service_restart_port(explicit_port: Option<u16>) -> anyhow::Result<u16> {
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<u16>,
bind: IpAddr,
Expand Down Expand Up @@ -3498,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,
Expand Down Expand Up @@ -6027,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(
Expand Down
1 change: 0 additions & 1 deletion packages/server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ fn install(mut options: ServiceOptions) -> anyhow::Result<ServiceInstallResult>
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();
Expand Down
199 changes: 199 additions & 0 deletions scripts/integration/service-restart.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading