Skip to content

Commit 3ff5c03

Browse files
hugocasaclaude
andcommitted
refactor: tighten unit-file port regex and warn on explicit-port collision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b7db8fc commit 3ff5c03

3 files changed

Lines changed: 78 additions & 9 deletions

File tree

bin/src/install-ports.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,63 @@ describe("readPortFromUnit", () => {
8787
it("returns null for a missing file", () => {
8888
expect(readPortFromUnit("/no/such/path.service")).toBeNull();
8989
});
90+
91+
// Round-trips against the exact strings `service.ts` emits, so a future
92+
// re-indent or wrapping change in `generateLaunchdPlist` / `generateSystemdUnit`
93+
// surfaces as a failing test rather than a silent regression to "auto-pick".
94+
it("round-trips against the systemd unit format service.ts writes", async () => {
95+
const dir = await makeTempDir();
96+
const filePath = join(dir, "webmux-roundtrip.service");
97+
const content = [
98+
"[Unit]",
99+
"Description=webmux dashboard — roundtrip",
100+
"",
101+
"[Service]",
102+
"Type=simple",
103+
"ExecStart=/usr/local/bin/webmux serve --port 5117",
104+
"WorkingDirectory=/home/x/proj",
105+
"Restart=on-failure",
106+
"RestartSec=5",
107+
"Environment=PORT=5117",
108+
"Environment=WEBMUX_PROJECT_DIR=/home/x/proj",
109+
"Environment=PATH=/usr/local/bin",
110+
"",
111+
"[Install]",
112+
"WantedBy=default.target",
113+
"",
114+
].join("\n");
115+
await writeFile(filePath, content);
116+
117+
expect(readPortFromUnit(filePath)).toBe(5117);
118+
});
119+
120+
it("round-trips against the launchd plist format service.ts writes", async () => {
121+
const dir = await makeTempDir();
122+
const filePath = join(dir, "com.webmux.roundtrip.plist");
123+
const content = [
124+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
125+
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
126+
"<plist version=\"1.0\">",
127+
"<dict>",
128+
" <key>Label</key>",
129+
" <string>com.webmux.roundtrip</string>",
130+
" <key>ProgramArguments</key>",
131+
" <array>",
132+
" <string>/usr/local/bin/webmux</string>",
133+
" <string>serve</string>",
134+
" <string>--port</string>",
135+
" <string>5222</string>",
136+
" </array>",
137+
" <key>WorkingDirectory</key>",
138+
" <string>/Users/x/proj</string>",
139+
"</dict>",
140+
"</plist>",
141+
"",
142+
].join("\n");
143+
await writeFile(filePath, content);
144+
145+
expect(readPortFromUnit(filePath)).toBe(5222);
146+
});
90147
});
91148

92149
describe("readInstalledServicePorts", () => {

bin/src/install-ports.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { createInstanceRegistry } from "../../backend/src/adapters/instance-regi
66
export const DEFAULT_SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
77
export const DEFAULT_LAUNCHD_DIR = join(homedir(), "Library", "LaunchAgents");
88

9-
const UNIT_PORT_RE = /--port[\s\S]{0,40}?(\d{2,5})/;
9+
const SYSTEMD_PORT_RE = /--port\s+(\d+)/;
10+
const LAUNCHD_PORT_RE = /<string>--port<\/string>\s*<string>(\d+)<\/string>/;
1011

1112
/** Lowest port `>= start` not in `taken`. */
1213
export function pickFreePort(start: number, taken: Iterable<number>): number {
@@ -51,21 +52,22 @@ export function readInstalledServicePorts(opts: {
5152
return ports;
5253
}
5354

54-
/** Parse a `--port N` value out of a service unit file. Tolerates the gap
55-
* between the flag and value being whitespace (systemd) or whitespace +
56-
* XML wrapping (launchd plist). Returns null when no port is declared or
57-
* the file is unreadable. */
55+
/** Parse a `--port N` value out of a service unit file. Dispatches on file
56+
* extension so each format gets a tight regex (`--port 5111` for systemd vs
57+
* `<string>--port</string>\s*<string>5111</string>` for launchd plists) —
58+
* no shared char-window assumption that breaks if either generator's
59+
* indentation changes. Returns null when no port is declared or the file
60+
* is unreadable. */
5861
export function readPortFromUnit(filePath: string): number | null {
5962
let text: string;
6063
try {
6164
text = readFileSync(filePath, "utf8");
6265
} catch {
6366
return null;
6467
}
65-
const match = UNIT_PORT_RE.exec(text);
66-
if (!match) return null;
67-
const port = parseInt(match[1], 10);
68-
return Number.isNaN(port) ? null : port;
68+
const regex = filePath.endsWith(".plist") ? LAUNCHD_PORT_RE : SYSTEMD_PORT_RE;
69+
const match = regex.exec(text);
70+
return match ? parseInt(match[1], 10) : null;
6971
}
7072

7173
/** Combine live-registry ports and installed-unit ports into a single set

bin/src/service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ async function install(config: ServiceConfig, portExplicit: boolean): Promise<vo
193193
const requestedPort = config.port;
194194
let chosenPort = requestedPort;
195195
let portNote: string | null = null;
196+
let portWarning: string | null = null;
196197

197198
if (!portExplicit) {
198199
const existingPort = alreadyInstalled ? readPortFromUnit(filePath) : null;
@@ -208,6 +209,14 @@ async function install(config: ServiceConfig, portExplicit: boolean): Promise<vo
208209
portNote = `Port ${requestedPort} is already used by another webmux instance — picked ${chosenPort} instead (pass --port to override).`;
209210
}
210211
}
212+
} else {
213+
// Explicit `--port` always wins, but the service will fail to bind on
214+
// start if something else is already there — surface it now rather than
215+
// making the user dig through `journalctl` / `launchctl` logs later.
216+
const taken = discoverTakenPorts({ excludeUnitPath: filePath });
217+
if (taken.has(requestedPort)) {
218+
portWarning = `Port ${requestedPort} is already claimed by another webmux instance. The service will fail to bind on start; omit --port to auto-pick a free port.`;
219+
}
211220
}
212221

213222
config = { ...config, port: chosenPort };
@@ -227,6 +236,7 @@ async function install(config: ServiceConfig, portExplicit: boolean): Promise<vo
227236
);
228237

229238
if (portNote) p.log.info(portNote);
239+
if (portWarning) p.log.warn(portWarning);
230240

231241
const ok = await p.confirm({ message: "Proceed?" });
232242
if (p.isCancel(ok) || !ok) {

0 commit comments

Comments
 (0)