plum-e2e 2.6.5 → 2.6.7

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.
@@ -77,12 +77,17 @@ function findPidOnPort(port) {
77
77
  if (process.platform === 'win32') {
78
78
  const out = execSync('netstat -ano', { encoding: 'utf8' });
79
79
  for (const line of out.split('\n')) {
80
- const upper = line.toUpperCase();
81
- if (upper.includes(`:${portStr}`) && upper.includes('LISTENING')) {
82
- const parts = line.trim().split(/\s+/);
83
- const pid = parseInt(parts[parts.length - 1], 10);
84
- if (!isNaN(pid) && pid > 0) return pid;
85
- }
80
+ if (!line.toUpperCase().includes('LISTENING')) continue;
81
+ const parts = line.trim().split(/\s+/);
82
+ // Columns: Proto, Local Address, Foreign Address, State, PID.
83
+ // Match the port exactly off the local address (e.g. "0.0.0.0:3002"
84
+ // or "[::]:3002") a substring check would false-positive port
85
+ // "300" against "3002", "13000", etc.
86
+ const localAddress = parts[1] ?? '';
87
+ const localPort = localAddress.slice(localAddress.lastIndexOf(':') + 1);
88
+ if (localPort !== portStr) continue;
89
+ const pid = parseInt(parts[parts.length - 1], 10);
90
+ if (!isNaN(pid) && pid > 0) return pid;
86
91
  }
87
92
  } else {
88
93
  const out = execSync(`lsof -i :${portStr} -t -sTCP:LISTEN`, { encoding: 'utf8' }).trim();
@@ -122,12 +127,18 @@ function parsePort(url) {
122
127
  }
123
128
  }
124
129
 
125
- /** Drops registry entries whose process has died and persists the result. */
130
+ /**
131
+ * Clears the pid of registry entries whose process has died, and persists the
132
+ * result. Keeps the entry itself (with its `port`) rather than deleting it —
133
+ * that's the only place the last-used port for a runner is remembered, so a
134
+ * later Start can reuse it instead of falling back to whatever's in the
135
+ * runner's URL (or a hardcoded default, if the URL has no explicit port).
136
+ */
126
137
  function pruneDead(registry = loadRegistry()) {
127
138
  let changed = false;
128
139
  for (const [id, entry] of Object.entries(registry)) {
129
- if (!entry?.pid || !isAlive(entry.pid)) {
130
- delete registry[id];
140
+ if (entry?.pid && !isAlive(entry.pid)) {
141
+ registry[id] = { ...entry, pid: null };
131
142
  changed = true;
132
143
  }
133
144
  }
@@ -233,10 +244,10 @@ function stopNode(id, fallbackPort = null) {
233
244
  let signalled = false;
234
245
 
235
246
  let pid = entry?.pid && isAlive(entry.pid) ? entry.pid : null;
247
+ const port = fallbackPort ?? (entry?.port ? Number(entry.port) : null);
236
248
 
237
- if (!pid) {
238
- const port = fallbackPort ?? (entry?.port ? Number(entry.port) : null);
239
- if (port) pid = findPidOnPort(port);
249
+ if (!pid && port) {
250
+ pid = findPidOnPort(port);
240
251
  }
241
252
 
242
253
  if (pid) {
@@ -245,11 +256,53 @@ function stopNode(id, fallbackPort = null) {
245
256
  signalled = true;
246
257
  } catch {}
247
258
  }
248
- delete registry[id];
259
+
260
+ // Keep the port on record (clearing only the pid) so a later Start reuses
261
+ // the same port instead of falling back to the runner's URL — which may
262
+ // not even have an explicit port — or a hardcoded default.
263
+ if (port) {
264
+ registry[id] = { ...entry, pid: null, port: String(port) };
265
+ } else {
266
+ delete registry[id];
267
+ }
249
268
  saveRegistry(registry);
250
269
  return signalled;
251
270
  }
252
271
 
272
+ /**
273
+ * Frees a TCP port by killing whatever process is bound to it, so Start can't
274
+ * fail with EADDRINUSE against a stale/orphaned process this manager lost
275
+ * track of (e.g. one that ignored SIGTERM from a previous stop, or was never
276
+ * in the registry to begin with). SIGTERM first, escalating to SIGKILL if the
277
+ * process is still alive after a short grace period.
278
+ *
279
+ * Returns true if a process was found (and signalled), false if the port was
280
+ * already free.
281
+ */
282
+ async function killPort(port) {
283
+ let pid = findPidOnPort(port);
284
+ if (!pid) return false;
285
+
286
+ try {
287
+ process.kill(pid, 'SIGTERM');
288
+ } catch {}
289
+
290
+ const deadline = Date.now() + 2000;
291
+ while (Date.now() < deadline && isAlive(pid)) {
292
+ await new Promise((resolve) => setTimeout(resolve, 150));
293
+ }
294
+
295
+ pid = findPidOnPort(port);
296
+ if (pid && isAlive(pid)) {
297
+ try {
298
+ process.kill(pid, 'SIGKILL');
299
+ } catch {}
300
+ await new Promise((resolve) => setTimeout(resolve, 200));
301
+ }
302
+
303
+ return true;
304
+ }
305
+
253
306
  module.exports = {
254
307
  BACKEND_DIR,
255
308
  LOGS_DIR,
@@ -260,6 +313,7 @@ module.exports = {
260
313
  isLocalUrl,
261
314
  parsePort,
262
315
  findPidOnPort,
316
+ killPort,
263
317
  pruneDead,
264
318
  statusOf,
265
319
  prepareEnv,
@@ -0,0 +1,18 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3996 (node/runner mode)
@@ -0,0 +1,18 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3996 (node/runner mode)
@@ -40,7 +40,8 @@ const {
40
40
  prepareEnv,
41
41
  startNode,
42
42
  stopNode,
43
- findPidOnPort
43
+ findPidOnPort,
44
+ killPort
44
45
  } = runnerProcess;
45
46
  const { generateToken, registerWithPrimary, detectLanIp } = nodeRegister;
46
47
 
@@ -203,15 +204,26 @@ async function runAction(r) {
203
204
  const action = await clack.select({ message: `${r.name} — ${r.url}`, options });
204
205
  if (cancelled(action) || action === 'back') return;
205
206
 
206
- const port = parsePort(r.url);
207
+ // Prefer the port this runner actually last ran on (remembered in the
208
+ // local registry even after being stopped) over parsing the URL — the URL
209
+ // may not carry an explicit port at all, and would otherwise silently fall
210
+ // back to the primary's own default port.
211
+ const remembered = runnerProcess.loadRegistry()[r.id]?.port;
212
+ const port = remembered || parsePort(r.url);
207
213
 
208
214
  if (action === 'start') {
215
+ const s = clack.spinner();
216
+ s.start(`Freeing port ${port}...`);
217
+ const killed = await killPort(Number(port));
218
+ s.stop(killed ? pc.dim(`Freed port ${port}`) : pc.dim(`Port ${port} was already free`));
219
+
209
220
  prepareNodeEnv();
210
221
  const entry = startNode({ id: r.id, port, token: r.token });
211
222
  clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
212
223
  } else if (action === 'stop') {
213
224
  if (r.managed) {
214
225
  const ok = stopNode(r.id);
226
+ await killPort(Number(port));
215
227
  clack.log.success(
216
228
  ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`)
217
229
  );
@@ -223,6 +235,11 @@ async function runAction(r) {
223
235
  s.stop(pc.green(`Stopped "${r.name}"`));
224
236
  } catch (e) {
225
237
  s.stop(pc.red(`Could not stop "${r.name}": ${e.message}`));
238
+ } finally {
239
+ // Belt-and-suspenders: if this runner happens to be local (or
240
+ // the network shutdown call above silently failed), make sure
241
+ // nothing is left bound to its port.
242
+ await killPort(Number(port));
226
243
  }
227
244
  }
228
245
  } else if (action === 'restart') {
@@ -230,7 +247,7 @@ async function runAction(r) {
230
247
  const s = clack.spinner();
231
248
  s.start(`Restarting "${r.name}"...`);
232
249
  stopNode(r.id);
233
- await new Promise((resolve) => setTimeout(resolve, 600));
250
+ await killPort(Number(port));
234
251
  const entry = startNode({ id: r.id, port, token: r.token });
235
252
  s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
236
253
  } else {
package/backend/server.js CHANGED
@@ -92,16 +92,30 @@ async function start() {
92
92
  try {
93
93
  const reg = loadRegistry();
94
94
  // A self-restart already wrote the replacement's pid under this
95
- // id before this process exits — only clear the entry if it's
96
- // still ours, so we don't erase the new process's registration.
95
+ // id before this process exits — only touch the entry if it's
96
+ // still ours, so we don't clobber the new process's registration.
97
+ // Keep the entry (with its port) rather than deleting it — that's
98
+ // the only place a later manual Start can find the port this
99
+ // runner was last running on.
97
100
  if (reg[runnerId]?.pid === process.pid) {
98
- delete reg[runnerId];
101
+ reg[runnerId] = { ...reg[runnerId], pid: null };
99
102
  saveRegistry(reg);
100
103
  }
101
104
  } catch {}
102
105
  };
103
- process.once('SIGTERM', cleanup);
104
- process.once('SIGINT', cleanup);
106
+ // Adding a SIGTERM/SIGINT listener suppresses Node's default
107
+ // "terminate immediately" behavior — the handler must exit itself,
108
+ // or the process (and the port it's bound to) lives on forever
109
+ // after a plain `kill`/SIGTERM with nothing left to stop it short
110
+ // of SIGKILL.
111
+ process.once('SIGTERM', () => {
112
+ cleanup();
113
+ process.exit(0);
114
+ });
115
+ process.once('SIGINT', () => {
116
+ cleanup();
117
+ process.exit(0);
118
+ });
105
119
  process.once('exit', cleanup);
106
120
  }
107
121
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.6.5",
3
+ "version": "2.6.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"