Skip to content

Commit 774b73b

Browse files
committed
fix: preserve service restart port
1 parent 6855123 commit 774b73b

6 files changed

Lines changed: 252 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ jobs:
212212
env:
213213
SIMDECK_INTEGRATION_VERBOSE: "1"
214214

215+
- name: LaunchAgent service restart integration test
216+
run: npm run test:integration:service
217+
env:
218+
SIMDECK_INTEGRATION_LAUNCHAGENT: "1"
219+
215220
integration-js-api:
216221
name: JS API simulator integration
217222
runs-on: macos-15

docs/cli/flags.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ project default from `simdeck use <udid>`, then auto-inference from the service.
2222
## Server options
2323

2424
Used by `simdeck`, `service start`, `service restart`, `service on`, and `service reset`.
25+
When `service restart` is run without `--port`, it preserves the installed
26+
LaunchAgent port or the current singleton service port before falling back to
27+
`4310`.
2528

2629
| Flag | Default | Notes |
2730
| ---------------------------- | -------------- | --------------------------------------------------------------------------------- |
28-
| `--port <port>` / `-p` | `4310` | HTTP port |
31+
| `--port <port>` / `-p` | `4310` | HTTP port; `service restart` preserves the existing service port when omitted |
2932
| `--bind <ip>` | `127.0.0.1` | Use `0.0.0.0` or `::` for LAN access |
3033
| `--advertise-host <host>` | detected | Host printed for remote browsers |
3134
| `--client-root <path>` | bundled client | Static client directory |

docs/guide/service.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ service that `simdeck` uses. `service reset` rotates the LaunchAgent token and
5454
pairing code. `service off` removes the LaunchAgent. `service kill` and
5555
`service killall` stop every SimDeck service process they can find, including
5656
services started by another SimDeck binary.
57+
When `service restart` is run without `--port`, it keeps the installed
58+
LaunchAgent port or the current singleton service port before falling back to
59+
`4310`.
5760

5861
## Options
5962

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"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",
6868
"test:integration:cli": "node scripts/integration/cli.mjs",
6969
"test:integration:cli:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/cli.mjs",
70+
"test:integration:service": "node scripts/integration/service-restart.mjs",
7071
"test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs",
7172
"test:integration:js-api": "node scripts/integration/js-api.mjs",
7273
"test:integration:webrtc": "node scripts/integration/webrtc.mjs",

packages/server/src/main.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,11 @@ enum ServiceCommand {
618618
access_token: Option<String>,
619619
},
620620
Restart {
621-
#[arg(long, default_value_t = SERVICE_PORT)]
622-
port: u16,
621+
#[arg(
622+
long,
623+
help = "Defaults to the existing service port, or 4310 when no service state exists"
624+
)]
625+
port: Option<u16>,
623626
#[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))]
624627
bind: IpAddr,
625628
#[arg(long)]
@@ -2510,6 +2513,19 @@ fn restart_detached_service(options: ServiceLaunchOptions) -> anyhow::Result<()>
25102513
start_detached_service(options)
25112514
}
25122515

2516+
fn service_restart_port(explicit_port: Option<u16>) -> anyhow::Result<u16> {
2517+
if let Some(port) = explicit_port {
2518+
return Ok(port);
2519+
}
2520+
if let Some(port) = service::installed_port()? {
2521+
return Ok(port);
2522+
}
2523+
if let Some(metadata) = read_service_metadata().ok().flatten() {
2524+
return Ok(metadata.port);
2525+
}
2526+
Ok(SERVICE_PORT)
2527+
}
2528+
25132529
struct PairGlobalServiceOptions {
25142530
port: Option<u16>,
25152531
bind: IpAddr,
@@ -3508,6 +3524,7 @@ fn main() -> anyhow::Result<()> {
35083524
stream_quality,
35093525
local_stream_fps,
35103526
} => {
3527+
let port = service_restart_port(port)?;
35113528
cleanup_orphaned_workspace_services_for_root(None);
35123529
restart_detached_service(ServiceLaunchOptions {
35133530
port,
@@ -6037,6 +6054,27 @@ mod tests {
60376054
assert_eq!(access_token.as_deref(), Some("explicit-token"));
60386055
}
60396056

6057+
#[test]
6058+
fn service_restart_command_preserves_omitted_port() {
6059+
let cli = Cli::parse_from(["simdeck", "service", "restart"]);
6060+
let Command::Service {
6061+
command: ServiceCommand::Restart { port, .. },
6062+
} = cli.command
6063+
else {
6064+
panic!("expected service restart command");
6065+
};
6066+
assert_eq!(port, None);
6067+
6068+
let cli = Cli::parse_from(["simdeck", "service", "restart", "--port", "4315"]);
6069+
let Command::Service {
6070+
command: ServiceCommand::Restart { port, .. },
6071+
} = cli.command
6072+
else {
6073+
panic!("expected service restart command");
6074+
};
6075+
assert_eq!(port, Some(4315));
6076+
}
6077+
60406078
#[test]
60416079
fn workspace_service_process_parser_reads_supervised_command_paths() {
60426080
let process = parse_workspace_service_process_line(
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env node
2+
3+
import { spawnSync } from "node:child_process";
4+
import { existsSync, mkdirSync, rmSync } from "node:fs";
5+
import { mkdtemp } from "node:fs/promises";
6+
import { createServer } from "node:net";
7+
import { tmpdir } from "node:os";
8+
import { dirname, join, resolve } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
11+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
12+
const binary = resolve(
13+
root,
14+
process.env.SIMDECK_INTEGRATION_SERVICE_BINARY ?? join("build", "simdeck"),
15+
);
16+
const enabled =
17+
process.env.SIMDECK_INTEGRATION_LAUNCHAGENT === "1" ||
18+
process.env.CI === "true";
19+
20+
if (process.platform !== "darwin") {
21+
console.log("Skipping LaunchAgent restart integration test on non-macOS.");
22+
process.exit(0);
23+
}
24+
25+
if (!enabled) {
26+
console.log(
27+
"Skipping LaunchAgent restart integration test. Set SIMDECK_INTEGRATION_LAUNCHAGENT=1 to run it.",
28+
);
29+
process.exit(0);
30+
}
31+
32+
if (!existsSync(binary)) {
33+
throw new Error(
34+
`Missing SimDeck binary at ${binary}. Run npm run build:cli first.`,
35+
);
36+
}
37+
38+
const tempRoot = await mkdtemp(join(tmpdir(), "simdeck-launchagent-it-"));
39+
const home = join(tempRoot, "home");
40+
const projectRoot = join(tempRoot, "project");
41+
mkdirSync(home, { recursive: true });
42+
mkdirSync(projectRoot, { recursive: true });
43+
44+
const servicePort = await findFreePort();
45+
const clientRoot = join(root, "packages", "client", "dist");
46+
const env = {
47+
...process.env,
48+
HOME: home,
49+
};
50+
51+
let blocker = null;
52+
53+
try {
54+
blocker = await listenIfAvailable("127.0.0.1", 4310);
55+
runJson(["service", "off"], { allowFailure: true });
56+
57+
const onArgs = [
58+
"service",
59+
"on",
60+
"--port",
61+
String(servicePort),
62+
"--bind",
63+
"127.0.0.1",
64+
"--video-codec",
65+
"software",
66+
"--stream-quality",
67+
"tiny",
68+
];
69+
if (existsSync(clientRoot)) {
70+
onArgs.push("--client-root", clientRoot);
71+
}
72+
73+
const installed = runJson(onArgs);
74+
assertEqual(installed.ok, true, "service on should succeed");
75+
assertEqual(
76+
installed.port,
77+
servicePort,
78+
"service on should install requested port",
79+
);
80+
81+
await waitForHealth(servicePort);
82+
83+
const restarted = runJson(["service", "restart"]);
84+
assertEqual(restarted.ok, true, "service restart should succeed");
85+
assertEqual(
86+
restarted.port,
87+
servicePort,
88+
"service restart without --port should preserve the installed LaunchAgent port",
89+
);
90+
91+
await waitForHealth(servicePort);
92+
const status = runJson(["service", "status"]);
93+
assertEqual(status.healthy, true, "service should be healthy after restart");
94+
assertEqual(
95+
status.service?.port,
96+
servicePort,
97+
"status should report the preserved LaunchAgent port",
98+
);
99+
100+
console.log(
101+
JSON.stringify(
102+
{
103+
ok: true,
104+
binary,
105+
servicePort,
106+
defaultPortBlocked: Boolean(blocker),
107+
},
108+
null,
109+
2,
110+
),
111+
);
112+
} finally {
113+
try {
114+
runJson(["service", "off"], { allowFailure: true });
115+
} finally {
116+
if (blocker) {
117+
await new Promise((resolveClose) => blocker.close(resolveClose));
118+
}
119+
rmSync(tempRoot, { recursive: true, force: true });
120+
}
121+
}
122+
123+
function runJson(args, options = {}) {
124+
const result = spawnSync(binary, args, {
125+
cwd: projectRoot,
126+
env,
127+
encoding: "utf8",
128+
});
129+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
130+
if (result.status !== 0) {
131+
if (options.allowFailure) {
132+
return null;
133+
}
134+
throw new Error(
135+
`${binary} ${args.join(" ")} failed with ${result.status}:\n${output}`,
136+
);
137+
}
138+
if (!output) {
139+
return null;
140+
}
141+
try {
142+
return JSON.parse(result.stdout);
143+
} catch (error) {
144+
throw new Error(
145+
`${binary} ${args.join(" ")} did not print JSON: ${error.message}\n${output}`,
146+
);
147+
}
148+
}
149+
150+
async function waitForHealth(port) {
151+
const deadline = Date.now() + 15_000;
152+
let lastError = null;
153+
while (Date.now() < deadline) {
154+
try {
155+
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
156+
if (response.ok) {
157+
return;
158+
}
159+
lastError = new Error(`/api/health returned ${response.status}`);
160+
} catch (error) {
161+
lastError = error;
162+
}
163+
await sleep(50);
164+
}
165+
throw new Error(
166+
`Timed out waiting for SimDeck service on ${port}: ${lastError?.message ?? "unknown error"}`,
167+
);
168+
}
169+
170+
async function findFreePort() {
171+
const server = await listenIfAvailable("127.0.0.1", 0);
172+
const address = server.address();
173+
await new Promise((resolveClose) => server.close(resolveClose));
174+
return address.port;
175+
}
176+
177+
function listenIfAvailable(host, port) {
178+
return new Promise((resolveListen, rejectListen) => {
179+
const server = createServer();
180+
server.once("error", (error) => {
181+
if (error.code === "EADDRINUSE" && port !== 0) {
182+
resolveListen(null);
183+
return;
184+
}
185+
rejectListen(error);
186+
});
187+
server.listen(port, host, () => resolveListen(server));
188+
});
189+
}
190+
191+
function sleep(ms) {
192+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
193+
}
194+
195+
function assertEqual(actual, expected, message) {
196+
if (actual !== expected) {
197+
throw new Error(`${message}: expected ${expected}, got ${actual}`);
198+
}
199+
}

0 commit comments

Comments
 (0)