lensmcp 1.16.0 → 1.16.2

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.
package/lib/cli.js CHANGED
@@ -200,12 +200,24 @@ function runGateway(ctx, args, out, err) {
200
200
  const logFile = join(cwd, '.lensmcp', 'gateway.log');
201
201
  const dashUrl = `https://lensmcp.local${cfg.dashboardBasePath}/`;
202
202
  const start = () => {
203
- const running = readPid(pidFile);
204
- if (running && isAlive(running)) {
203
+ // Reconcile with reality FIRST: an alive pid-file wins; else adopt an orphaned gateway on :443 (the
204
+ // desync self-heal) — so a second `start` NEVER spawns a duplicate that EADDRINUSE-crashes + orphans.
205
+ const { pid: running, healed } = reconcileGateway(pidFile, cwd);
206
+ if (running !== undefined && isAlive(running)) {
207
+ if (healed)
208
+ out(`healed: adopted a running gateway (pid ${running}) the pid file had lost — no duplicate started.`);
205
209
  out(`gateway already running (pid ${running}).`);
206
210
  out(` dashboard → ${dashUrl}`);
207
211
  return { exitCode: 0 };
208
212
  }
213
+ // :443 is free of any lens gateway — but if a FOREIGN process holds it, spawning would just
214
+ // EADDRINUSE-crash and re-create the orphan mess. Surface it instead of stacking a doomed child.
215
+ const foreign = pidsOnPort443()[0];
216
+ if (foreign !== undefined) {
217
+ err(`port :443 is held by pid ${foreign}, which is not this workspace's gateway.\n` +
218
+ `Free it first (stop that process), then \`lensmcp gateway start\`.`);
219
+ return { exitCode: 1 };
220
+ }
209
221
  const target = findGatewayTarget(cwd, stringFlag(opts.flags['--project']), stringFlag(opts.flags['--target']));
210
222
  if (!target) {
211
223
  err('Could not find a gateway target (an executor `@lensmcp/cluster:gateway`) in this workspace.\n' +
@@ -235,32 +247,31 @@ function runGateway(ctx, args, out, err) {
235
247
  return { exitCode: 0 };
236
248
  };
237
249
  const stop = () => {
238
- const pid = readPid(pidFile);
239
- if (!pid || !isAlive(pid)) {
250
+ // Reality-based: kill the tracked pid OR an orphaned gateway the pid file lost track of (so `stop`
251
+ // works even after the desync — the case where the old CLI reported "not running" but :443 was held).
252
+ const { pid, healed } = reconcileGateway(pidFile, cwd);
253
+ if (pid === undefined) {
240
254
  out('gateway not running.');
241
255
  rmFile(pidFile);
242
256
  return { exitCode: 0 };
243
257
  }
258
+ if (healed)
259
+ out(`healed: found an orphaned gateway (pid ${pid}) the pid file had lost — stopping it.`);
244
260
  // Detached child is its own process-group leader → negative pid kills the whole tree.
245
- try {
246
- process.kill(-pid, 'SIGTERM');
247
- }
248
- catch {
249
- try {
250
- process.kill(pid, 'SIGTERM');
251
- }
252
- catch {
253
- /* already gone */
254
- }
255
- }
261
+ killGatewayTree(pid);
256
262
  rmFile(pidFile);
263
+ // Escalate: if a lens gateway STILL holds :443 (a differently-parented tree), kill that too, so
264
+ // `stop` genuinely frees the port instead of leaving a wedged listener behind.
265
+ const still = gatewayOnPort443(cwd);
266
+ if (still !== undefined && still !== pid)
267
+ killGatewayTree(still);
257
268
  out(`gateway stopped (pid ${pid}).`);
258
269
  return { exitCode: 0 };
259
270
  };
260
271
  const status = () => {
261
- const pid = readPid(pidFile);
272
+ const { pid, healed } = reconcileGateway(pidFile, cwd);
262
273
  const alive = pid !== undefined && isAlive(pid);
263
- out(`gateway: ${alive ? `running (pid ${pid})` : 'stopped'}`);
274
+ out(`gateway: ${alive ? `running (pid ${pid})${healed ? ' — recovered a stale pid file' : ''}` : 'stopped'}`);
264
275
  out(` workspace → ${cfg.key}`);
265
276
  out(` dashboard → ${dashUrl}`);
266
277
  if (alive)
@@ -376,6 +387,79 @@ function isAlive(pid) {
376
387
  return e.code === 'EPERM'; // exists but not ours
377
388
  }
378
389
  }
390
+ /* ───────── reality-based gateway discovery (self-heal the pid-file desync) ─────────
391
+ * The gateway is tracked ONLY by `.lensmcp/gateway.pid`. If a start crashed AFTER binding failed
392
+ * (a duplicate `start`/`restart` while an untracked gateway already held :443 → EADDRINUSE), the pid
393
+ * file ends up pointing at the DEAD child while the ORIGINAL gateway keeps running orphaned — so
394
+ * `status` falsely reports "stopped" and `stop` can't kill it. These helpers cross-check the ACTUAL
395
+ * :443 owner so start/stop/status reconcile with reality instead of trusting a stale pid file.
396
+ * Unix-only (lsof/ps); on a platform without them they no-op → the pid-file path is unchanged. */
397
+ /** All pids LISTENING on :443 (best-effort via lsof; [] when free / lsof unavailable). */
398
+ function pidsOnPort443() {
399
+ try {
400
+ const r = spawnSync('lsof', ['-nP', '-iTCP:443', '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
401
+ if (r.status !== 0 || !r.stdout)
402
+ return [];
403
+ return [...new Set(r.stdout.split(/\s+/).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
404
+ }
405
+ catch {
406
+ return [];
407
+ }
408
+ }
409
+ /** The full command line of a pid (best-effort via ps; '' on failure). */
410
+ function processCmd(pid) {
411
+ try {
412
+ return (spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).stdout ?? '').trim();
413
+ }
414
+ catch {
415
+ return '';
416
+ }
417
+ }
418
+ /** The pid on :443 that belongs to THIS workspace's lensmcp gateway. Only the gateway binds :443 in a
419
+ * lens workspace, so a run-executor / `@lensmcp/cluster` process of `cwd` holding :443 IS the gateway.
420
+ * Returns undefined when :443 is free, held by a foreign process, or lsof/ps are unavailable. */
421
+ function gatewayOnPort443(cwd) {
422
+ const sigExecutor = join(cwd, 'node_modules', 'nx', 'dist', 'bin', 'run-executor.js');
423
+ const sigCluster = join(cwd, 'node_modules', '@lensmcp', 'cluster');
424
+ for (const pid of pidsOnPort443()) {
425
+ const cmd = processCmd(pid);
426
+ if (cmd.includes(sigExecutor) || cmd.includes(sigCluster))
427
+ return pid;
428
+ }
429
+ return undefined;
430
+ }
431
+ /** Reconcile the tracked pid with reality: a LIVE pid-file wins; otherwise ADOPT an orphaned gateway
432
+ * still holding :443 (rewriting the pid file) — the self-heal for the stale-pid desync. */
433
+ function reconcileGateway(pidFile, cwd) {
434
+ const tracked = readPid(pidFile);
435
+ if (tracked !== undefined && isAlive(tracked))
436
+ return { pid: tracked, healed: false };
437
+ const orphan = gatewayOnPort443(cwd);
438
+ if (orphan !== undefined) {
439
+ try {
440
+ writeFileSync(pidFile, String(orphan));
441
+ }
442
+ catch {
443
+ /* pid-file unwritable — still return the orphan so stop/status act on reality */
444
+ }
445
+ return { pid: orphan, healed: true };
446
+ }
447
+ return { healed: false };
448
+ }
449
+ /** Best-effort kill of a detached gateway process GROUP (the leader is its own group via `detached`). */
450
+ function killGatewayTree(pid) {
451
+ try {
452
+ process.kill(-pid, 'SIGTERM');
453
+ }
454
+ catch {
455
+ try {
456
+ process.kill(pid, 'SIGTERM');
457
+ }
458
+ catch {
459
+ /* already gone */
460
+ }
461
+ }
462
+ }
379
463
  function rmFile(path) {
380
464
  try {
381
465
  if (existsSync(path))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -2,7 +2,7 @@
2
2
  "name": "lensmcp",
3
3
  "displayName": "LensMCP",
4
4
  "description": "The observability lens for coding agents. One command brings up the dev cluster gateway (every project.json `cluster` decl → its host on :443), the per-project lens dashboard at https://lensmcp.local/<project>/, and the MCP server your agent connects to — scoped automatically to whatever project you opened Claude Code in.",
5
- "version": "1.16.0",
5
+ "version": "1.16.2",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"
@@ -10,8 +10,14 @@ Run the stop script with the Bash tool:
10
10
  "${CLAUDE_PLUGIN_ROOT}/scripts/gateway-stop.sh"
11
11
  ```
12
12
 
13
- It calls `lensmcp gateway stop` for `${CLAUDE_PROJECT_DIR}`, which reads the pid recorded
14
- in `.lensmcp/gateway.pid` and terminates the gateway process group (the gateway plus the
15
- app/service pods + the lens dashboard it spawned).
13
+ It calls `lensmcp gateway stop` for `${CLAUDE_PROJECT_DIR}`, which terminates the gateway process
14
+ group (the gateway plus the app/service pods + the lens dashboard it spawned).
16
15
 
17
- Report whether it stopped, or that it was already not running.
16
+ `stop`/`status`/`start` are **reality-based**: they trust `.lensmcp/gateway.pid` only when it is
17
+ alive, and otherwise **reconcile against the actual :443 owner**. So `stop` still kills an *orphaned*
18
+ gateway that a stale pid file had lost track of (the desync where a crashed duplicate-`start` left the
19
+ pid file pointing at a dead process while the original gateway kept running) — and `start` **adopts**
20
+ that orphan instead of spawning a duplicate that would `EADDRINUSE`-crash.
21
+
22
+ Report whether it stopped, whether it **healed** a stale/orphaned gateway (the CLI prints a `healed:` line),
23
+ or that it was already not running.