@lensmcp/cluster 1.16.29 → 1.16.31

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.
@@ -186,7 +186,7 @@ deps) {
186
186
  rt.rebuildRoutes();
187
187
  // Publish this workspace to the GLOBAL registry so the root chooser at `${lensHost}/` lists it
188
188
  // (and marks it live while this gateway runs). Best-effort — a registry write never blocks serving.
189
- (0, workspace_registry_1.registerWorkspace)({ key: scope.key, root: rt.root, base: lensBase, pid: process.pid, updatedAt: new Date().toISOString() });
189
+ (0, workspace_registry_1.registerWorkspace)({ key: scope.key, root: rt.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
190
190
  console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at https://${lensHost}${lensBase}/)`);
191
191
  emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort, base: lensBase });
192
192
  spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
@@ -271,7 +271,7 @@ deps) {
271
271
  rt.registry.appendRoutes(fragment.wsKey, [{ host: lensHost, prefix: lensBase, project: observability_1.LENS_DASHBOARD_PROJECT, target: `http://localhost:${port}` }]);
272
272
  // Publish the hosted workspace to the GLOBAL chooser registry (keyed on the DAEMON's pid — the daemon is
273
273
  // what keeps it live), so `lensmcp.local/` lists it. Reaped on unregister below.
274
- (0, workspace_registry_1.registerWorkspace)({ key: fragment.wsKey, root: fragment.root, base: lensBase, pid: process.pid, updatedAt: new Date().toISOString() });
274
+ (0, workspace_registry_1.registerWorkspace)({ key: fragment.wsKey, root: fragment.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
275
275
  console.log(`[gateway] hosting dashboard for workspace '${fragment.wsKey}' → http://localhost:${port} (routed at https://${lensHost}${lensBase}/)`);
276
276
  emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${port}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port, base: lensBase });
277
277
  spawnManagedChild(label, process.execPath, [bundle], {
@@ -1,8 +1,9 @@
1
1
  import type { GatewayRuntime, ServiceCtl, SockPool } from './types';
2
2
  import type { Observability } from './observability';
3
3
  /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
4
- * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs, and
5
- * only counts source-extension files. Order-independent (readdir order irrelevant) and null-safe. */
4
+ * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
5
+ * GITIGNORED path (runtime output is not source — see `SRC_EXCLUDE`), and only counts source-extension
6
+ * files. Order-independent (readdir order irrelevant) and null-safe. */
6
7
  export declare function sourceSetSignature(root: string): Promise<string>;
7
8
  export interface PodRecycleConfig {
8
9
  maxAgeMs: number;
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,iBAAiB,CAAC;AA8C/D;;sGAEsG;AACtG,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CA4XvF"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,iBAAiB,CAAC;AAyE/D;;;yEAGyE;AACzE,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA6BtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CAiYvF"}
@@ -14,6 +14,7 @@ const node_child_process_1 = require("node:child_process");
14
14
  const fs = tslib_1.__importStar(require("node:fs"));
15
15
  const http = tslib_1.__importStar(require("node:http"));
16
16
  const path = tslib_1.__importStar(require("node:path"));
17
+ const node_util_1 = require("node:util");
17
18
  const discovery_1 = require("./discovery");
18
19
  const pod_env_1 = require("./pod-env");
19
20
  const types_1 = require("./types");
@@ -48,6 +49,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
49
  // degraded watcher missed it. We detect a set change — NOT a content change — by a cheap order-independent
49
50
  // signature over source file PATHS (count + xor of per-path hashes): adding/removing a path flips the
50
51
  // signature; editing a file's CONTENTS does not, so ordinary saves never trigger a recycle.
52
+ // RUNTIME OUTPUT IS NOT SOURCE. The hardcoded list below can only ever name the conventional output dirs;
53
+ // a workspace whose app WRITES into some other gitignored dir under the root would otherwise read every
54
+ // write as "a dev added a source file" and recycle every pod. Worked failure (foodguard, 2026-07-28): the
55
+ // engine's local artifact store defaults to `./artifacts` at the workspace root, so each run's
56
+ // `publish_pack` added pack-manifest + source-snapshot `.json` → the set flipped → the sweeper SIGTERMed all
57
+ // four vite pods → a ~12-18s `gateway.unavailable` 502 window per run (201 dashboard recycles in one
58
+ // session, and it crossed workspaces: the sweeper's viteChildren span every REGISTERED workspace while the
59
+ // scan only walks the daemon host's root). So the set is filtered by git's OWN ignore verdict too.
51
60
  const SRC_EXCLUDE = new Set(['node_modules', 'dist', 'tmp', 'coverage', 'build', 'out', 'target']);
52
61
  const SRC_EXT = new Set([
53
62
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.sass', '.less',
@@ -62,12 +71,32 @@ function fnv1a(s) {
62
71
  }
63
72
  return h >>> 0;
64
73
  }
74
+ const execFileP = (0, node_util_1.promisify)(node_child_process_1.execFile);
75
+ /** The repo-relative paths git IGNORES — directories carry a trailing `/` (git's `--directory` collapses a
76
+ * wholly-ignored dir), files do not. ONE `git ls-files` per scan (~80ms on a 2.6k-file workspace, off the
77
+ * event loop) buys exact gitignore semantics — nested `.gitignore`, `.git/info/exclude`, global excludes —
78
+ * with no new dependency and no hand-rolled glob matcher. A partially-ignored dir isn't collapsed, so its
79
+ * individual ignored files are listed and filtered per-file instead. Fails OPEN to an empty set (git absent,
80
+ * not a repo, timeout) → the hardcoded `SRC_EXCLUDE` list alone still applies, exactly as before. */
81
+ async function gitIgnoredPaths(root) {
82
+ try {
83
+ const { stdout } = await execFileP('git', ['ls-files', '--others', '--directory', '--ignored', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 32 * 1024 * 1024, timeout: 10_000, windowsHide: true });
84
+ return new Set(stdout.split('\0').filter(Boolean)); // -z → NUL-separated, never git's quoted-path form
85
+ }
86
+ catch {
87
+ return new Set();
88
+ }
89
+ }
65
90
  /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
66
- * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs, and
67
- * only counts source-extension files. Order-independent (readdir order irrelevant) and null-safe. */
91
+ * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
92
+ * GITIGNORED path (runtime output is not source — see `SRC_EXCLUDE`), and only counts source-extension
93
+ * files. Order-independent (readdir order irrelevant) and null-safe. */
68
94
  async function sourceSetSignature(root) {
69
95
  let count = 0;
70
96
  let xor = 0;
97
+ const ignored = await gitIgnoredPaths(root);
98
+ // git speaks posix-relative; `path.relative` yields `\` on win32 — normalize before every lookup.
99
+ const rel = (abs) => path.relative(root, abs).split(path.sep).join('/');
71
100
  const walk = async (dir) => {
72
101
  if (count > SCAN_FILE_CAP)
73
102
  return;
@@ -82,17 +111,22 @@ async function sourceSetSignature(root) {
82
111
  if (count > SCAN_FILE_CAP)
83
112
  return;
84
113
  const name = e.name;
114
+ const abs = path.join(dir, name);
85
115
  if (e.isDirectory()) {
86
116
  if (name.startsWith('.') || SRC_EXCLUDE.has(name) || name.endsWith('-devserver'))
87
117
  continue;
88
- await walk(path.join(dir, name));
118
+ if (ignored.has(`${rel(abs)}/`))
119
+ continue; // a gitignored output dir (foodguard's `artifacts/`)
120
+ await walk(abs);
89
121
  }
90
122
  else if (e.isFile()) {
91
123
  const dot = name.lastIndexOf('.');
92
124
  if (dot <= 0 || !SRC_EXT.has(name.slice(dot)))
93
125
  continue;
126
+ if (ignored.has(rel(abs)))
127
+ continue; // a gitignored file in a tracked dir (generated reports)
94
128
  count += 1;
95
- xor ^= fnv1a(path.join(dir, name));
129
+ xor ^= fnv1a(abs);
96
130
  }
97
131
  }
98
132
  };
@@ -162,7 +196,7 @@ function createServiceLayer(rt, obs) {
162
196
  * its whole devserver tree with it. One `ps` exec; a clean cold start is a no-op.
163
197
  */
164
198
  const reapForeignRunners = (svc, target) => {
165
- let out = '';
199
+ let out;
166
200
  try {
167
201
  out = (0, node_child_process_1.execFileSync)('ps', ['ax', '-ww', '-o', 'pid=,pgid=,command='], { encoding: 'utf8', timeout: 5_000 });
168
202
  }
@@ -266,6 +300,11 @@ function createServiceLayer(rt, obs) {
266
300
  NX_ISOLATE_PLUGINS: 'false',
267
301
  ...(svc.wsKey ? { LENSMCP_WS_KEY: svc.wsKey } : {}),
268
302
  LENSMCP_EVENT_FILE: svc.wsKey === rt.wsKey ? rt.eventFile : path.join(svc.root, '.lensmcp', 'events.jsonl'),
303
+ // Rides the env inherit down the whole nx chain (nx run → run-executor → serve fork) into
304
+ // main.devserver, whose gateway watchdog self-exits the pod tree when this pid dies. Without
305
+ // it an UNGRACEFUL gateway death (crash/SIGKILL — no process-group kill runs) orphans the
306
+ // entire chain under launchd forever (2026-07-28: a day-old tetros pod tree at PPID 1).
307
+ LENSMCP_GATEWAY_PID: String(process.pid),
269
308
  ...eastWestEnv(rt, svc.project),
270
309
  },
271
310
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1 +1 @@
1
- {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/observability.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,qBAAqB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC5B;;4GAEwG;IACxG,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvJ,GAAG,IAAI,MAAM,CAAC;IACd,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,OAAO,GAAG,SAAS,CAAC;IAC3D,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1F,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5G,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5E,0FAA0F;IAC1F,gBAAgB,IAAI,MAAM,CAAC,OAAO,CAAC;CACpC;AAKD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAoGtG"}
1
+ {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/observability.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAoB9C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,qBAAqB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC5B;;4GAEwG;IACxG,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvJ,GAAG,IAAI,MAAM,CAAC;IACd,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,OAAO,GAAG,SAAS,CAAC;IAC3D,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1F,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5G,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5E,0FAA0F;IAC1F,gBAAgB,IAAI,MAAM,CAAC,OAAO,CAAC;CACpC;AAKD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAiHtG"}
@@ -17,6 +17,15 @@ const tslib_1 = require("tslib");
17
17
  */
18
18
  const fs = tslib_1.__importStar(require("node:fs"));
19
19
  const path = tslib_1.__importStar(require("node:path"));
20
+ const busHygiene = (() => {
21
+ try {
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ return require('@lensmcp/node-instrumentation');
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ })();
20
29
  /**
21
30
  * The lens's OWN dashboard route project (the value `lens-children.ts` gives the `lensmcp.local/<key>/`
22
31
  * route). Traffic to it is the lens UI polling its own control-plane API (`/<key>/api/cluster|logs|snapshot`)
@@ -29,15 +38,28 @@ exports.LENS_DASHBOARD_PROJECT = '(lens-dashboard)';
29
38
  const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
30
39
  function createObservability(eventFile, opts) {
31
40
  const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
41
+ // The gateway narrates OTHER apps' traffic — an app stuck in a retry loop must not turn that
42
+ // narration into a bus flood (2026-07-28: `gateway-request:<project>` alone wrote thousands of
43
+ // lines/min). Per-fingerprint token bucket: first burst passes, then a trickle carrying
44
+ // `floodSuppressed` counts. Errors stay loud — the burst re-arms once the storm subsides.
45
+ const floodGuard = busHygiene?.createEventFloodGuard({ burst: 30, perSec: 5 });
32
46
  const emit = (severity, title, fingerprint, raw, flowCtx, busFile) => {
33
47
  const target = busFile ?? eventFile; // a registered workspace's service events go to ITS bus, not the daemon's
34
48
  try {
49
+ const admit = floodGuard ? floodGuard.admit(fingerprint) : { ok: true, suppressed: 0 };
50
+ if (!admit.ok)
51
+ return;
35
52
  fs.mkdirSync(path.dirname(target), { recursive: true });
36
- fs.appendFileSync(target, JSON.stringify({
53
+ const line = JSON.stringify({
37
54
  id: eid(), sessionId: 'pending', timestamp: Date.now(),
38
55
  source: 'gateway', category: 'cluster', severity,
39
- context: { sessionId: 'pending', ...flowCtx }, fingerprint, title, raw,
40
- }) + '\n');
56
+ context: { sessionId: 'pending', ...flowCtx }, fingerprint, title,
57
+ raw: admit.suppressed > 0 ? { ...raw, floodSuppressed: admit.suppressed } : raw,
58
+ }) + '\n';
59
+ // The gateway was the one bus writer with NO size cap — enforce the same rotation contract
60
+ // as every other producer (emit.ts / capture-runner), per target file.
61
+ busHygiene?.maybeRotateEventFile(target, line.length);
62
+ fs.appendFileSync(target, line);
41
63
  }
42
64
  catch { /* lens offline — never block traffic */ }
43
65
  };
@@ -7,9 +7,23 @@ export interface WorkspaceEntry {
7
7
  base: string;
8
8
  /** The gateway process that registered this workspace (owns :443 for it). Liveness is checked via this. */
9
9
  pid: number;
10
+ /**
11
+ * The registering process's start-time stamp (`ps -o lstart=`), captured at register. A pid alone
12
+ * can LIE: after an ungraceful gateway death the OS recycles the number, and `kill(pid, 0)` then
13
+ * vouches for a stranger (2026-07-28: tetros's dead-gateway pid came back as an nx run-executor and
14
+ * the chooser kept listing the workspace as live). Same pid + same start time = same process.
15
+ * Absent on legacy entries and on platforms without `ps` — liveness then falls back to kill(0).
16
+ */
17
+ procStamp?: string;
10
18
  /** ISO timestamp of the last register. */
11
19
  updatedAt: string;
12
20
  }
21
+ /**
22
+ * The start-time identity stamp for a pid (`''` when `ps` is unavailable — Windows, restricted
23
+ * environments — or the process is gone). Start time is immutable for the life of a process and
24
+ * survives in no recycled successor, which makes it the cheapest cross-platform-ish identity proof.
25
+ */
26
+ export declare function processStamp(pid: number): string;
13
27
  /** `~/.lensmcp/workspaces.json` — override the home dir via `LENSMCP_HOME` (tests). */
14
28
  export declare function registryPath(): string;
15
29
  export declare function readWorkspaces(file?: string): WorkspaceEntry[];
@@ -17,6 +31,10 @@ export declare function readWorkspaces(file?: string): WorkspaceEntry[];
17
31
  export declare function registerWorkspace(entry: WorkspaceEntry, file?: string): void;
18
32
  /** Drop this workspace — called on a graceful gateway stop. */
19
33
  export declare function unregisterWorkspace(key: string, file?: string): void;
20
- /** Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if not. */
21
- export declare function isWorkspaceLive(entry: Pick<WorkspaceEntry, 'pid'>): boolean;
34
+ /**
35
+ * Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if
36
+ * not — and when the entry carries a `procStamp`, the pid must ALSO still be the SAME process
37
+ * (recycled pids fail the start-time comparison instead of impersonating a live gateway).
38
+ */
39
+ export declare function isWorkspaceLive(entry: Pick<WorkspaceEntry, 'pid' | 'procStamp'>): boolean;
22
40
  //# sourceMappingURL=workspace-registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-registry.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/workspace-registry.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,cAAc;IAC7B,4FAA4F;IAC5F,GAAG,EAAE,MAAM,CAAC;IACZ,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,2GAA2G;IAC3G,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,uFAAuF;AACvF,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,cAAc,CAAC,IAAI,SAAiB,GAAG,cAAc,EAAE,CAYtE;AAWD,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,EAAE,IAAI,SAAiB,GAAG,IAAI,CAKpF;AAED,+DAA+D;AAC/D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,SAAiB,GAAG,IAAI,CAK5E;AAED,6GAA6G;AAC7G,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,GAAG,OAAO,CAQ3E"}
1
+ {"version":3,"file":"workspace-registry.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/workspace-registry.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC7B,4FAA4F;IAC5F,GAAG,EAAE,MAAM,CAAC;IACZ,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,2GAA2G;IAC3G,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWhD;AAED,uFAAuF;AACvF,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,cAAc,CAAC,IAAI,SAAiB,GAAG,cAAc,EAAE,CAYtE;AAWD,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,EAAE,IAAI,SAAiB,GAAG,IAAI,CAKpF;AAED,+DAA+D;AAC/D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,SAAiB,GAAG,IAAI,CAK5E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,WAAW,CAAC,GAAG,OAAO,CAUzF"}
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.processStamp = processStamp;
3
4
  exports.registryPath = registryPath;
4
5
  exports.readWorkspaces = readWorkspaces;
5
6
  exports.registerWorkspace = registerWorkspace;
@@ -16,9 +17,29 @@ const tslib_1 = require("tslib");
16
17
  * Concurrency: read-modify-write, best-effort. Writes are rare (start/stop only) and the file is tiny, so
17
18
  * a lost update at most drops one entry from the listing until the next start — never a correctness issue.
18
19
  */
20
+ const node_child_process_1 = require("node:child_process");
19
21
  const fs = tslib_1.__importStar(require("node:fs"));
20
22
  const os = tslib_1.__importStar(require("node:os"));
21
23
  const path = tslib_1.__importStar(require("node:path"));
24
+ /**
25
+ * The start-time identity stamp for a pid (`''` when `ps` is unavailable — Windows, restricted
26
+ * environments — or the process is gone). Start time is immutable for the life of a process and
27
+ * survives in no recycled successor, which makes it the cheapest cross-platform-ish identity proof.
28
+ */
29
+ function processStamp(pid) {
30
+ if (!pid || pid <= 0 || process.platform === 'win32')
31
+ return '';
32
+ try {
33
+ return (0, node_child_process_1.execFileSync)('ps', ['-p', String(pid), '-o', 'lstart='], {
34
+ encoding: 'utf8',
35
+ timeout: 2_000,
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).trim();
38
+ }
39
+ catch {
40
+ return '';
41
+ }
42
+ }
22
43
  /** `~/.lensmcp/workspaces.json` — override the home dir via `LENSMCP_HOME` (tests). */
23
44
  function registryPath() {
24
45
  const home = process.env['LENSMCP_HOME'] || os.homedir();
@@ -56,15 +77,23 @@ function registerWorkspace(entry, file = registryPath()) {
56
77
  function unregisterWorkspace(key, file = registryPath()) {
57
78
  writeWorkspaces(readWorkspaces(file).filter((e) => e.key !== key), file);
58
79
  }
59
- /** Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if not. */
80
+ /**
81
+ * Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if
82
+ * not — and when the entry carries a `procStamp`, the pid must ALSO still be the SAME process
83
+ * (recycled pids fail the start-time comparison instead of impersonating a live gateway).
84
+ */
60
85
  function isWorkspaceLive(entry) {
61
86
  if (!entry.pid || entry.pid <= 0)
62
87
  return false;
63
88
  try {
64
89
  process.kill(entry.pid, 0);
65
- return true; // alive (or EPERM below — exists but not ours)
66
90
  }
67
91
  catch (e) {
68
- return e.code === 'EPERM'; // EPERM = exists but not signalable; ESRCH = gone
92
+ if (e.code !== 'EPERM')
93
+ return false; // EPERM = exists but not signalable; ESRCH = gone
69
94
  }
95
+ if (!entry.procStamp)
96
+ return true; // legacy entry / no-ps platform — pid liveness is all we have
97
+ const now = processStamp(entry.pid);
98
+ return now === '' ? true : now === entry.procStamp; // '' = ps unavailable here — don't false-negative
70
99
  }
package/main.devserver.js CHANGED
@@ -1090,5 +1090,28 @@ else {
1090
1090
  setTimeout(() => process.exit(0), 700).unref();
1091
1091
  };
1092
1092
  ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => process.on(sig, () => void shutdown(sig)));
1093
+ // Gateway watchdog: the gateway kills pods via its spawned process group, but an UNGRACEFUL
1094
+ // gateway death (crash/SIGKILL) never runs that kill — the whole nx run → run-executor →
1095
+ // devserver chain reparents to launchd and squats its ports/sockets forever (the 2026-07-28
1096
+ // day-old orphan tree). We are layers below the gateway, so a ppid check can't see it — poll
1097
+ // the stamped pid instead and tear down through the SAME graceful path when it's gone. The
1098
+ // chain above us unwinds as our exit propagates. A pod run OUTSIDE a gateway (`nx serve`
1099
+ // directly) has no LENSMCP_GATEWAY_PID and is untouched.
1100
+ const gatewayPid = Number(process.env.LENSMCP_GATEWAY_PID ?? 0);
1101
+ if (Number.isInteger(gatewayPid) && gatewayPid > 0) {
1102
+ const watchdog = setInterval(() => {
1103
+ try {
1104
+ process.kill(gatewayPid, 0);
1105
+ }
1106
+ catch (e) {
1107
+ if (e.code === 'EPERM')
1108
+ return; // exists, just not signalable
1109
+ clearInterval(watchdog);
1110
+ console.error(`[parent] gateway (pid ${gatewayPid}) is gone — shutting the pod down to avoid an orphan tree.`);
1111
+ void shutdown('gateway-gone');
1112
+ }
1113
+ }, 5_000);
1114
+ watchdog.unref(); // never keeps an otherwise-done parent alive
1115
+ }
1093
1116
  })();
1094
1117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.16.29",
3
+ "version": "1.16.31",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.16.29",
69
- "@lensmcp/nx-plugin": "1.16.29",
68
+ "@lensmcp/node-instrumentation": "1.16.31",
69
+ "@lensmcp/nx-plugin": "1.16.31",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",