mulmoterminal 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,10 @@ const PKG_DIR = join(__dirname, "..");
18
18
  const SERVER_ENTRY = join(PKG_DIR, "server", "index.ts");
19
19
  const DEFAULT_PORT = 3456;
20
20
  const READY_TIMEOUT_MS = 15_000;
21
- const MAX_PORT_PROBES = 20;
21
+ const MAX_BIND_RETRIES = 5;
22
+ // Server exit code meaning "port taken at bind time" — keep in sync with
23
+ // server/index.ts (PORT_IN_USE_EXIT_CODE).
24
+ const PORT_IN_USE_EXIT_CODE = 75;
22
25
 
23
26
  // Single source of truth: read the version from the shipped package.json so
24
27
  // `--version` never drifts from the published version.
@@ -45,31 +48,48 @@ function pickOpenCommand() {
45
48
  return "xdg-open";
46
49
  }
47
50
 
48
- // Resolve with true if nothing is listening on `port`, false otherwise.
51
+ // Resolve with true if nothing is listening on `port`, false otherwise. Binds
52
+ // without a host — same as the server's `server.listen(port)` (the `::`
53
+ // dual-stack address) — so the probe and the real bind agree on availability.
54
+ // Probing 127.0.0.1 here let a port held only on `::` slip through as "free".
49
55
  function isPortFree(port) {
50
56
  return new Promise((resolve) => {
51
57
  const probe = createServer();
52
58
  probe.once("error", () => resolve(false));
53
59
  probe.once("listening", () => probe.close(() => resolve(true)));
54
- probe.listen(port, "127.0.0.1");
60
+ probe.listen(port);
55
61
  });
56
62
  }
57
63
 
58
- async function findAvailablePort(start) {
59
- for (let port = start; port < start + MAX_PORT_PROBES; port++) {
60
- if (await isPortFree(port)) return port;
61
- }
62
- return null;
64
+ // Ask the OS for a free port (listen on 0) and return the one it assigned, or
65
+ // null. An effectively-random fallback when the preferred port is taken; the
66
+ // bind-retry in main() closes the small probe-to-bind race so concurrent starts
67
+ // don't clash.
68
+ function findEphemeralPort() {
69
+ return new Promise((resolve) => {
70
+ const probe = createServer();
71
+ probe.once("error", () => resolve(null));
72
+ probe.once("listening", () => {
73
+ const addr = probe.address();
74
+ const assigned = addr && typeof addr === "object" ? addr.port : null;
75
+ probe.close(() => resolve(assigned));
76
+ });
77
+ probe.listen(0);
78
+ });
63
79
  }
64
80
 
65
81
  // Poll the server until it answers, then call onReady; give up after the timeout
66
- // so the launcher never hangs on a crash loop.
82
+ // so the launcher never hangs on a crash loop. Returns a cancel function — a
83
+ // raced/abandoned attempt stops polling so it can't fire a stale banner.
67
84
  function waitUntilReady(port, onReady) {
68
85
  const startedAt = Date.now();
86
+ let timer = null;
87
+ let cancelled = false;
69
88
  const attempt = () => {
89
+ if (cancelled) return;
70
90
  const req = httpGet({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
71
91
  res.resume();
72
- onReady();
92
+ if (!cancelled) onReady();
73
93
  });
74
94
  req.on("error", retry);
75
95
  req.on("timeout", () => {
@@ -78,10 +98,14 @@ function waitUntilReady(port, onReady) {
78
98
  });
79
99
  };
80
100
  const retry = () => {
81
- if (Date.now() - startedAt > READY_TIMEOUT_MS) return;
82
- setTimeout(attempt, 300);
101
+ if (cancelled || Date.now() - startedAt > READY_TIMEOUT_MS) return;
102
+ timer = setTimeout(attempt, 300);
83
103
  };
84
104
  attempt();
105
+ return () => {
106
+ cancelled = true;
107
+ if (timer) clearTimeout(timer);
108
+ };
85
109
  }
86
110
 
87
111
  function printReadyBanner(url) {
@@ -111,15 +135,57 @@ async function choosePort(requested, explicit) {
111
135
  error(`Port ${requested} is already in use. Stop the other process or pick a different --port.`);
112
136
  process.exit(1);
113
137
  }
114
- const fallback = await findAvailablePort(requested + 1);
138
+ const fallback = await findEphemeralPort();
115
139
  if (fallback === null) {
116
- error(`Port ${requested} is in use and no free port found nearby.`);
140
+ error(`Port ${requested} is in use and no free port could be found.`);
117
141
  process.exit(1);
118
142
  }
119
143
  log(`Port ${requested} busy → using ${fallback} instead. (Pass --port <N> to pin.)`);
120
144
  return fallback;
121
145
  }
122
146
 
147
+ // Spawn the server on `port` and report the child via `onChild` (so signal
148
+ // handlers target the live process). Resolves only when the server exits because
149
+ // the port was taken at bind time before it became ready — the caller then
150
+ // retries on a fresh port. In every other case (clean shutdown, fatal error,
151
+ // or the server simply running) the process exits with the server's code.
152
+ function runServer(port, noOpen, onChild) {
153
+ return new Promise((resolve) => {
154
+ log(`Starting MulmoTerminal on port ${port}...`);
155
+ const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
156
+ cwd: PKG_DIR,
157
+ env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
158
+ stdio: "inherit",
159
+ });
160
+ onChild(server);
161
+
162
+ const url = `http://localhost:${port}`;
163
+ const cancelReady = waitUntilReady(port, () => {
164
+ printReadyBanner(url);
165
+ if (noOpen) return;
166
+ try {
167
+ // The command is a hardcoded literal; url is http://localhost:<numeric port>.
168
+ // eslint-disable-next-line sonarjs/os-command
169
+ execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
170
+ } catch {
171
+ log(`Open your browser: ${url}`);
172
+ }
173
+ });
174
+
175
+ server.on("exit", (code) => {
176
+ cancelReady();
177
+ // Exit code 75 means this child failed to bind (EADDRINUSE) and never
178
+ // served — always retriable, regardless of what a probe to the port saw
179
+ // (another process could have answered it). Other exits are terminal.
180
+ if (code === PORT_IN_USE_EXIT_CODE) {
181
+ resolve();
182
+ return;
183
+ }
184
+ process.exit(code ?? 1);
185
+ });
186
+ });
187
+ }
188
+
123
189
  async function main() {
124
190
  const args = process.argv.slice(2);
125
191
 
@@ -128,7 +194,7 @@ async function main() {
128
194
  Usage: npx mulmoterminal [options]
129
195
 
130
196
  Options:
131
- --port <number> Server port (default: ${DEFAULT_PORT})
197
+ --port <number> Server port (default: ${DEFAULT_PORT}; a free port is chosen if it's busy)
132
198
  --no-open Don't open the browser automatically
133
199
  --version Show version
134
200
  --help Show this help
@@ -153,36 +219,39 @@ Options:
153
219
  }
154
220
 
155
221
  const { requestedPort, portExplicit } = parsePortArg(args);
156
- const port = await choosePort(requestedPort, portExplicit);
157
-
158
- log(`Starting MulmoTerminal on port ${port}...`);
159
- const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
160
- cwd: PKG_DIR,
161
- env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
162
- stdio: "inherit",
163
- });
164
-
165
- const url = `http://localhost:${port}`;
166
222
  const noOpen = args.includes("--no-open");
167
- waitUntilReady(port, () => {
168
- printReadyBanner(url);
169
- if (noOpen) return;
170
- try {
171
- // The command is a hardcoded literal; url is http://localhost:<numeric port>.
172
- // eslint-disable-next-line sonarjs/os-command
173
- execSync(`${pickOpenCommand()} ${url}`, { stdio: "pipe" });
174
- } catch {
175
- log(`Open your browser: ${url}`);
176
- }
177
- });
178
223
 
224
+ // Registered once; always targets the live child across bind-retries.
225
+ let child = null;
179
226
  const shutdown = () => {
180
- server.kill("SIGTERM");
227
+ child?.kill("SIGTERM");
181
228
  process.exit(0);
182
229
  };
183
230
  process.on("SIGINT", shutdown);
184
231
  process.on("SIGTERM", shutdown);
185
- server.on("exit", (code) => process.exit(code ?? 1));
232
+
233
+ // Start on the chosen port; if the server loses the rare probe-to-bind race,
234
+ // fall back to a fresh OS-assigned port and retry. An explicit --port is not
235
+ // second-guessed. `runServer` only returns when the port was raced.
236
+ let port = await choosePort(requestedPort, portExplicit);
237
+ for (let attempt = 0; attempt <= MAX_BIND_RETRIES; attempt++) {
238
+ await runServer(port, noOpen, (c) => {
239
+ child = c;
240
+ });
241
+ if (portExplicit) {
242
+ error(`Port ${port} is already in use. Stop the other process or pick a different --port.`);
243
+ process.exit(1);
244
+ }
245
+ const next = await findEphemeralPort();
246
+ if (next === null) {
247
+ error("No free port available to retry on.");
248
+ process.exit(1);
249
+ }
250
+ log(`Port ${port} was taken at bind time → retrying on ${next}.`);
251
+ port = next;
252
+ }
253
+ error(`Could not bind a free port after ${MAX_BIND_RETRIES + 1} attempts.`);
254
+ process.exit(1);
186
255
  }
187
256
 
188
257
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoterminal",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MulmoTerminal — browser terminal + GUI panel for Claude Code. One command to start.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server/index.ts CHANGED
@@ -906,6 +906,21 @@ wss.on("connection", (ws, req) => {
906
906
  ws.on("close", () => handleClientClose(entry, ws, sessionId));
907
907
  });
908
908
 
909
+ // Exit code the launcher (bin/mulmoterminal.js) treats as "port was taken at
910
+ // bind time" so it can retry on a fresh port. Keep in sync with the launcher.
911
+ const PORT_IN_USE_EXIT_CODE = 75;
912
+
913
+ // A bind failure (most often the port already in use) must not surface as an
914
+ // unhandled 'error' event / stack trace — exit with a clear message instead.
915
+ server.on("error", (err) => {
916
+ if (hasErrnoCode(err) && err.code === "EADDRINUSE") {
917
+ console.error(`[mulmoterminal] Port ${PORT} is already in use — set PORT=<n> or pass --port <n>.`);
918
+ process.exit(PORT_IN_USE_EXIT_CODE);
919
+ }
920
+ console.error(`[mulmoterminal] server error: ${messageOf(err)}`);
921
+ process.exit(1);
922
+ });
923
+
909
924
  server.listen(PORT, () => {
910
925
  console.log(`mulmoterminal running at http://localhost:${PORT}`);
911
926
  });