@lensmcp/cluster 1.16.31 → 1.17.0

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.
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sourceSetSignature = sourceSetSignature;
4
+ exports.sourceScopeDirs = sourceScopeDirs;
4
5
  exports.podRecycleReason = podRecycleReason;
5
6
  exports.lensFrontendStale = lensFrontendStale;
7
+ exports.lensChildWedged = lensChildWedged;
8
+ exports.isPortConflictExit = isPortConflictExit;
6
9
  exports.createServiceLayer = createServiceLayer;
7
10
  const tslib_1 = require("tslib");
8
11
  /**
@@ -16,6 +19,7 @@ const http = tslib_1.__importStar(require("node:http"));
16
19
  const path = tslib_1.__importStar(require("node:path"));
17
20
  const node_util_1 = require("node:util");
18
21
  const discovery_1 = require("./discovery");
22
+ const scope_1 = require("./scope");
19
23
  const pod_env_1 = require("./pod-env");
20
24
  const types_1 = require("./types");
21
25
  /**
@@ -90,14 +94,19 @@ async function gitIgnoredPaths(root) {
90
94
  /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
91
95
  * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
92
96
  * 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. */
94
- async function sourceSetSignature(root) {
97
+ * files. Order-independent (readdir order irrelevant) and null-safe.
98
+ *
99
+ * `scopeDirs` (absolute, from {@link sourceScopeDirs}) narrows the walk to the subtrees a given child could
100
+ * actually resolve, instead of the whole monorepo — see that function for WHY. Omitted ⇒ the whole root
101
+ * (the original workspace-wide behavior, still used when a child's project root is unknown). Root-level
102
+ * loose files are always counted, so a `package.json`/`tsconfig.base.json` add reaches every scope. */
103
+ async function sourceSetSignature(root, scopeDirs, extraExclude) {
95
104
  let count = 0;
96
105
  let xor = 0;
97
106
  const ignored = await gitIgnoredPaths(root);
98
107
  // git speaks posix-relative; `path.relative` yields `\` on win32 — normalize before every lookup.
99
108
  const rel = (abs) => path.relative(root, abs).split(path.sep).join('/');
100
- const walk = async (dir) => {
109
+ const walk = async (dir, recurse = true) => {
101
110
  if (count > SCAN_FILE_CAP)
102
111
  return;
103
112
  let entries;
@@ -113,7 +122,9 @@ async function sourceSetSignature(root) {
113
122
  const name = e.name;
114
123
  const abs = path.join(dir, name);
115
124
  if (e.isDirectory()) {
116
- if (name.startsWith('.') || SRC_EXCLUDE.has(name) || name.endsWith('-devserver'))
125
+ if (!recurse)
126
+ continue; // scoped mode: root's own dirs are entered only via scopeDirs
127
+ if (name.startsWith('.') || SRC_EXCLUDE.has(name) || extraExclude?.has(name) || name.endsWith('-devserver'))
117
128
  continue;
118
129
  if (ignored.has(`${rel(abs)}/`))
119
130
  continue; // a gitignored output dir (foodguard's `artifacts/`)
@@ -130,9 +141,56 @@ async function sourceSetSignature(root) {
130
141
  }
131
142
  }
132
143
  };
133
- await walk(root);
144
+ if (scopeDirs && scopeDirs.length > 0) {
145
+ await walk(root, false); // root-level loose files only — its subdirs come from scopeDirs
146
+ // Sequential, not Promise.all: `count`/`xor` are shared accumulators, and the xor is
147
+ // order-independent anyway — so concurrency would buy nothing but interleaving risk.
148
+ for (const dir of scopeDirs)
149
+ await walk(dir);
150
+ }
151
+ else {
152
+ await walk(root);
153
+ }
134
154
  return `${count}:${(xor >>> 0).toString(36)}`;
135
155
  }
156
+ /**
157
+ * PURE: which subtrees a child of `projectRoot` must watch for a "file set changed" verdict.
158
+ *
159
+ * The staleness signal exists to catch a vite/pod whose fs-watcher MISSED a file add. But the signature was
160
+ * workspace-WIDE while its consumers are PER-CHILD, so adding a backend-only `server/apps/auth/**\/*.ts`
161
+ * — a file in no vite module graph — recycled every frontend vite too. Worked failure (foodguard,
162
+ * 2026-07-28): one session writing `server/apps/auth/src/modules/totp/**` added a `.ts` every ~15-20s and
163
+ * held `app.foodguard.local` in near-continuous 502s, 951 recycles in a session, none of them relevant to
164
+ * the dashboard.
165
+ *
166
+ * The scope is the child's OWN top-level bucket plus every bucket that hosts no OTHER gateway-managed
167
+ * project (`managedBuckets`). In a scope-first monorepo (`web/ server/ shared/ ai/ tools/`) that yields
168
+ * `web + shared + ai + tools` for a frontend and `server + shared + ai + tools` for a service: a
169
+ * `server/**` add no longer touches a frontend, while a `shared/contracts` add still reaches both — which
170
+ * is correct, since both import it. It deliberately OVER-includes (a frontend watches `ai/` it may never
171
+ * import) because a false recycle costs a cold start whereas a MISSED one costs a wedged dev server.
172
+ *
173
+ * Both args are absolute; the result is absolute and deduped. An empty result (the project root IS the
174
+ * workspace root, or it sits outside it) means "no narrowing" → the caller falls back to the whole root.
175
+ */
176
+ function sourceScopeDirs(workspaceRoot, projectRoot, managedBuckets, topLevelDirs, extraExclude) {
177
+ const bucketOf = (abs) => {
178
+ const r = path.relative(workspaceRoot, abs).split(path.sep).filter(Boolean);
179
+ return r.length === 0 || r[0].startsWith('..') ? '' : r[0];
180
+ };
181
+ const own = bucketOf(projectRoot);
182
+ if (!own)
183
+ return []; // project root === workspace root (or outside it) → cannot narrow
184
+ const managed = new Set(managedBuckets.filter(Boolean));
185
+ const keep = new Set([own]);
186
+ for (const d of topLevelDirs) {
187
+ if (d.startsWith('.') || SRC_EXCLUDE.has(d) || extraExclude?.has(d) || d.endsWith('-devserver'))
188
+ continue;
189
+ if (!managed.has(d))
190
+ keep.add(d); // a bucket owned by no managed project = shared/leaf → always watch
191
+ }
192
+ return [...keep].sort().map((d) => path.join(workspaceRoot, d));
193
+ }
136
194
  /**
137
195
  * PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
138
196
  * is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
@@ -174,6 +232,56 @@ function lensFrontendStale(spawnedAt, lastRecycleAt, sourceSetChangedAt, now, cf
174
232
  return false; // let an add/remove burst settle → one recycle
175
233
  return true;
176
234
  }
235
+ /**
236
+ * PURE decision: is this ALIVE lens-frontend child WEDGED — process running, port NOT listening?
237
+ *
238
+ * The third self-heal blind spot. `lensFrontendStale` catches a vite serving a stale FS view; the exit
239
+ * handler catches a vite that DIED. Neither sees a vite that is alive, has printed `VITE ready`, and has
240
+ * NO LISTEN socket — its event loop held open by the lens WS server / bridge sockets while its HTTP
241
+ * listener is gone. Nothing exits, so nothing auto-heals, and the app 502s until a human kills the pid
242
+ * (foodguard 2026-07-29 — see {@link LENS_PROBE_ENABLED}).
243
+ *
244
+ * The caller owns the socket I/O (a cheap TCP connect per tick) and accumulates `probeFails`; this owns
245
+ * WHEN that evidence justifies a kill. Three guards, each earning its place:
246
+ * - **grace** — a still-booting vite has no listener YET; killing it would be the outage, not the fix.
247
+ * - **N consecutive failures** (≥2, enforced here so even a misconfigured `failureThreshold: 1` can't
248
+ * single-blip recycle) — a momentary refusal is not a wedge.
249
+ * - **cooldown** — the same per-project window the two recycle paths share, so probe + staleness can
250
+ * never thrash one project between them.
251
+ * Returns the recycle reason or `null`. Kept pure (no process, no socket) so all four are unit-tested.
252
+ */
253
+ function lensChildWedged(child, lastRecycleAt, now, cfg) {
254
+ if (now - child.spawnedAt < cfg.graceMs)
255
+ return null; // still booting — it has no listener YET
256
+ if (now - lastRecycleAt < cfg.cooldownMs)
257
+ return null; // anti-thrash (shared with the staleness recycle)
258
+ if (child.probeFails < Math.max(2, cfg.failureThreshold))
259
+ return null; // one blip is never a wedge
260
+ return 'wedged-no-listener';
261
+ }
262
+ /**
263
+ * PURE decision: was this child's exit a lost BIND RACE (EADDRINUSE) rather than a crash?
264
+ *
265
+ * The auto-heal respawned on a fixed backoff while the child it replaced still held the pinned port, so
266
+ * vite's `--strictPort` exited code=1 on bind — and that self-inflicted exit consumed a heal attempt,
267
+ * pushing the backoff up and lengthening the very 502 it was healing (observed: `listen EADDRINUSE
268
+ * 127.0.0.1:50059` → `exited (code=1 sig=null); auto-healing in 3s (attempt 2)`). A bind race is an
269
+ * ENVIRONMENT condition, not a code fault, so it must not spend the crash-loop budget.
270
+ *
271
+ * The signature, from evidence we already have (stdio is `inherit`, so the child's stderr is unreadable):
272
+ * a non-zero exit, NOT from a signal (we killed it), FAST enough that it cannot have served traffic, while
273
+ * some OTHER process still holds the port. If the port is free by now the next spawn will simply succeed,
274
+ * so it is treated as a normal heal — the conservative direction.
275
+ */
276
+ function isPortConflictExit(exit, cfg) {
277
+ if (exit.signal)
278
+ return false; // we (or the OS) killed it — a recycle/reap, never a bind failure
279
+ if (exit.code === null || exit.code === 0)
280
+ return false; // a clean exit is not a start failure
281
+ if (exit.ranMs > cfg.fastFailMs)
282
+ return false; // it RAN ⇒ it held the port ⇒ cannot be a bind race
283
+ return exit.portHeld; // someone else still holds it ⇒ this exit lost the bind race
284
+ }
177
285
  function createServiceLayer(rt, obs) {
178
286
  const { emit } = obs;
179
287
  // Route a service's lifecycle events (starting/up/down/cold-start/…) to ITS OWNING workspace's bus —
@@ -535,23 +643,100 @@ function createServiceLayer(rt, obs) {
535
643
  settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS,
536
644
  };
537
645
  let prevSig;
646
+ const prevScopedSig = new Map(); // bucket → its last signature
538
647
  let scanInFlight = false;
539
648
  let lastScanAt = 0;
540
- // Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the shared
541
- // clock so pods that predate it recycle. Non-blocking the sweeper never awaits it.
649
+ /** The distinct top-level buckets owned by a gateway-managed project (services + `lens:true` apps) every
650
+ * OTHER bucket is shared/leaf and belongs in every scope. Recomputed per scan so a workspace registered
651
+ * after boot is picked up. */
652
+ const managedBuckets = () => {
653
+ const roots = rt.projectRoots ?? {};
654
+ const names = new Set(rt.services.map((s) => s.project));
655
+ for (const r of rt.routes)
656
+ if (r.lens)
657
+ names.add(r.lens.project);
658
+ const buckets = new Set();
659
+ for (const n of names) {
660
+ const pr = roots[n];
661
+ if (!pr)
662
+ continue;
663
+ const seg = path.relative(rt.root, pr).split(path.sep).filter(Boolean)[0];
664
+ if (seg && !seg.startsWith('..'))
665
+ buckets.add(seg);
666
+ }
667
+ return [...buckets];
668
+ };
669
+ /** project → the absolute dirs its child must watch, or `undefined` when it cannot be narrowed (unknown
670
+ * project root / no `projectsConfigurations`) → that child keeps the workspace-wide clock. */
671
+ const scopeFor = (project, topLevel, managed, extraExclude) => {
672
+ const pr = rt.projectRoots?.[project];
673
+ if (!pr)
674
+ return undefined;
675
+ const dirs = sourceScopeDirs(rt.root, pr, managed, topLevel, extraExclude);
676
+ return dirs.length > 0 ? dirs : undefined;
677
+ };
678
+ // Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the clocks so
679
+ // children that predate it recycle. Non-blocking — the sweeper never awaits it. Two tiers: the
680
+ // workspace-WIDE signature (the fallback + back-compat clock) and one SCOPED signature per managed
681
+ // project's bucket, so a `server/**` add no longer recycles a `web/**` vite (see `sourceScopeDirs`). One
682
+ // scan per bucket, not per project — sibling projects in a bucket share a scope, so `web/apps/dashboard`
683
+ // and `web/apps/authentication` cost a single walk between them.
542
684
  const maybeScanSourceSet = () => {
543
685
  if (scanInFlight || Date.now() - lastScanAt < types_1.POD_STALE_SCAN_MS)
544
686
  return;
545
687
  scanInFlight = true;
546
688
  lastScanAt = Date.now();
547
- void sourceSetSignature(rt.root)
548
- .then((sig) => {
549
- if (prevSig !== undefined && sig !== prevSig) {
689
+ void (async () => {
690
+ // Per-workspace `.lensmcp/config.json` sourceSet.exclude — re-read per scan so an
691
+ // edit takes effect without a gateway restart (one tiny fs read per scan tick).
692
+ const extraExclude = (0, scope_1.readSourceSetExclude)(rt.root);
693
+ const wide = await sourceSetSignature(rt.root, undefined, extraExclude);
694
+ if (prevSig !== undefined && wide !== prevSig) {
550
695
  rt.sourceSetChangedAt = Date.now();
551
696
  emit('info', 'source file set changed — stale pods recycle when idle', 'cluster-gateway', { kind: 'source-set-changed' });
552
697
  }
553
- prevSig = sig;
554
- })
698
+ prevSig = wide;
699
+ // Scoped tier. Skipped entirely without project roots (nothing to narrow by) — consumers then read
700
+ // the wide clock exactly as before.
701
+ if (!rt.projectRoots)
702
+ return;
703
+ let topLevel;
704
+ try {
705
+ topLevel = (await fs.promises.readdir(rt.root, { withFileTypes: true }))
706
+ .filter((e) => e.isDirectory()).map((e) => e.name);
707
+ }
708
+ catch {
709
+ return;
710
+ }
711
+ const managed = managedBuckets();
712
+ const projects = new Set(rt.services.map((s) => s.project));
713
+ for (const r of rt.routes)
714
+ if (r.lens)
715
+ projects.add(r.lens.project);
716
+ // bucket → the scope dirs + the projects sharing it (one walk per bucket).
717
+ const byBucket = new Map();
718
+ for (const project of projects) {
719
+ const dirs = scopeFor(project, topLevel, managed, extraExclude);
720
+ if (!dirs)
721
+ continue;
722
+ const key = dirs.join('|');
723
+ const entry = byBucket.get(key) ?? { dirs, projects: [] };
724
+ entry.projects.push(project);
725
+ byBucket.set(key, entry);
726
+ }
727
+ const now = Date.now();
728
+ for (const [key, { dirs, projects: sharing }] of byBucket) {
729
+ const sig = await sourceSetSignature(rt.root, dirs, extraExclude);
730
+ const prev = prevScopedSig.get(key);
731
+ prevScopedSig.set(key, sig);
732
+ if (prev === undefined || sig === prev)
733
+ continue;
734
+ rt.sourceSetChangedFor ??= new Map();
735
+ for (const p of sharing)
736
+ rt.sourceSetChangedFor.set(p, now);
737
+ emit('info', `source file set changed in ${sharing.join(', ')}'s scope`, 'cluster-gateway', { kind: 'source-set-changed', scoped: true, projects: sharing });
738
+ }
739
+ })()
555
740
  .catch(() => undefined)
556
741
  .finally(() => { scanInFlight = false; });
557
742
  };
@@ -595,7 +780,10 @@ function createServiceLayer(rt, obs) {
595
780
  // stale — neither exit-respawn nor wedge-recycle sees it. Idle-gated in podRecycleReason so a busy
596
781
  // pod is never interrupted.
597
782
  if (live && !coldStarting && types_1.POD_RECYCLE_ENABLED) {
598
- const reason = podRecycleReason(svc, Date.now(), rt.sourceSetChangedAt ?? 0, recycleCfg);
783
+ // Prefer this project's SCOPED clock (only the subtrees it can resolve); fall back to the
784
+ // workspace-wide one when the scan hasn't produced a scoped verdict for it.
785
+ const changedAt = rt.sourceSetChangedFor?.get(svc.project) ?? rt.sourceSetChangedAt ?? 0;
786
+ const reason = podRecycleReason(svc, Date.now(), changedAt, recycleCfg);
599
787
  if (reason)
600
788
  recyclePod(svc, reason);
601
789
  }
@@ -6,7 +6,10 @@ import type { ServiceLayer } from './lifecycle';
6
6
  export interface ProxyServer {
7
7
  web(req: http.IncomingMessage, res: http.ServerResponse, opts: Record<string, unknown>, cb: (err: Error) => void): void;
8
8
  ws(req: http.IncomingMessage, socket: stream.Duplex, head: Buffer, opts: Record<string, unknown>, cb: (err?: Error) => void): void;
9
- on(event: 'proxyRes', cb: (proxyRes: http.IncomingMessage, req: http.IncomingMessage) => void): void;
9
+ on(event: 'proxyRes', cb: (proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse) => void): void;
10
+ /** Emitted once the upstream ClientRequest has its socket — the only handle http-proxy gives us on the
11
+ * OUTBOUND request, and so the only place the edge-cancel propagation below can be wired. */
12
+ on(event: 'proxyReq', cb: (proxyReq: http.ClientRequest, req: http.IncomingMessage, res: http.ServerResponse) => void): void;
10
13
  close?(): void;
11
14
  }
12
15
  export interface ProxyLayer {
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAsTtG"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/H;kGAC8F;IAC9F,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,KAAK,IAAI,GAAG,IAAI,CAAC;IAC7H,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAuYtG"}
@@ -56,8 +56,46 @@ function createProxy(rt, obs, svcLayer) {
56
56
  // and leave the gateway-request trace open; the timeout fires the proxy
57
57
  // error path → 502 → res 'close' → inflight decrements + trace finishes.
58
58
  const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
59
+ /**
60
+ * EDGE-CANCEL PROPAGATION for the H1 (node-http-proxy) path — the H1 twin of `pipeUpstream`'s
61
+ * `onEdgeClose`, and a real leak fix.
62
+ *
63
+ * node-http-proxy 1.18.1 propagates a client cancel through exactly ONE hook:
64
+ * `req.on('aborted', () => proxyReq.abort())` (web-incoming.js). `'aborted'` is the event Node
65
+ * DEPRECATED in v16 in favour of `'close'`, and it does not fire here — MEASURED on Node 24 with the
66
+ * real pooled-agent setup below: on an edge cancel mid-response the upstream request was never aborted
67
+ * (`aborted` 0 times), the upstream response never closed, the upstream kept STREAMING forever into a
68
+ * socket nobody reads, and the pooled socket stayed permanently CHECKED OUT of the agent
69
+ * (`in-use=1, free=0`) — leaked, never reused, never released. Every cancelled request (an unmounted
70
+ * SSE stream, an HMR reload, a navigated-away fetch) leaked one upstream socket AND pinned the pod
71
+ * generating events for a client that is gone; enough of them walk the agent to `maxSockets`, after
72
+ * which new requests queue behind sockets that can never come free.
73
+ *
74
+ * So the cancel is wired here instead, on both halves of the upstream exchange:
75
+ * - `proxyReq` — cancel BEFORE the response arrives ⇒ destroy the outbound request.
76
+ * - `proxyRes` — cancel MID-response ⇒ destroy the upstream response.
77
+ * DESTROY, never release: a destroyed socket is dropped from the agent instead of returned to
78
+ * `freeSockets`, so a half-drained socket can never be picked up by a later request. Measured after the
79
+ * fix: upstream closed, `in-use=0, free=0`, and http-proxy's error callback NOT invoked (so this raises
80
+ * no spurious 502 and triggers no heal retry). Each listener is removed when its upstream half closes,
81
+ * so nothing accumulates on a retried request (see the leak note in `pipeUpstream`).
82
+ */
83
+ const propagateEdgeCancel = (upstream, res, completed) => {
84
+ const onEdgeClose = () => {
85
+ if (completed())
86
+ return; // the response finished normally — nothing to cancel
87
+ if (!upstream.destroyed)
88
+ upstream.destroy();
89
+ };
90
+ res.once('close', onEdgeClose);
91
+ upstream.once('close', () => res.removeListener('close', onEdgeClose));
92
+ };
93
+ proxy.on('proxyReq', (proxyReq, _req, res) => {
94
+ // `writableEnded` ⇒ we already flushed the whole edge response, so this 'close' is normal completion.
95
+ propagateEdgeCancel(proxyReq, res, () => res.writableEnded);
96
+ });
59
97
  // Surface WHICH pod served the request (round-robin made visible).
60
- proxy.on('proxyRes', (proxyRes, req) => {
98
+ proxy.on('proxyRes', (proxyRes, req, res) => {
61
99
  const pod = req.__lensmcpPod;
62
100
  if (pod)
63
101
  proxyRes.headers['x-lensmcp-pod'] = pod;
@@ -65,6 +103,10 @@ function createProxy(rt, obs, svcLayer) {
65
103
  // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
66
104
  delete proxyRes.headers['x-powered-by'];
67
105
  proxyRes.headers['server'] = 'LensMCP';
106
+ // A mid-response cancel must stop the upstream too: `proxyRes.pipe(res)` only UNPIPES when the edge
107
+ // dies (Node's `pipe` never destroys the source), which is what stranded the socket.
108
+ if (res)
109
+ propagateEdgeCancel(proxyRes, res, () => res.writableEnded);
68
110
  });
69
111
  // --- native HTTP/2 forwarder ------------------------------------------------
70
112
  // node-http-proxy is HTTP/1.1-only: it copies connection-specific headers onto the
@@ -114,8 +156,34 @@ function createProxy(rt, obs, svcLayer) {
114
156
  // `res.destroy(err)` below) emits 'error'; UNHANDLED, that throws and can
115
157
  // escalate to tear the whole h2 session down — the very "burst" symptom.
116
158
  // A no-op listener keeps the fault local to THIS stream. (Harmless on h1.)
117
- res.on('error', () => { });
159
+ //
160
+ // ONCE PER REQUEST, not per attempt. `pipeUpstream` is re-entered for every TCP heal RETRY (see
161
+ // `tryTcp`), and containment is a property of the STREAM, not of one upstream attempt — so a plain
162
+ // `res.on('error')` here accumulated one listener per retry. With an 8s heal window at 300ms steps
163
+ // that is ~26 listeners on a single response, which Node reported as
164
+ // `MaxListenersExceededWarning: 11 error listeners added to [Http2ServerResponse]` (and the twin for
165
+ // [Http2ServerRequest] via the `req.on('error')` below). Each leaked closure also RETAINS the
166
+ // request/response objects — a memory bug, not just log noise. The marker makes re-entry idempotent.
167
+ const guard = res;
168
+ if (!guard.__lensmcpErrContained) {
169
+ guard.__lensmcpErrContained = true;
170
+ res.on('error', () => { });
171
+ }
172
+ // Per-ATTEMPT listeners live on the SHARED per-request `req`/`res`, so each attempt must REMOVE its own
173
+ // on the way out — otherwise a heal retry (`tryTcp`) stacks one more of each per pass (the leak above).
174
+ // `upstream` is captured so the edge-close handler can be built (and detached) outside the response cb.
175
+ let upstream;
176
+ const onReqError = () => { upstreamReq.destroy(); };
177
+ // The client canceled: stop reading the upstream so its pooled keep-alive socket isn't left flowing
178
+ // (a half-drained socket is corrupt on the next reuse → would break a later request).
179
+ const onEdgeClose = () => { if (upstream && !upstream.destroyed)
180
+ upstream.destroy(); };
181
+ const detach = () => {
182
+ req.removeListener('error', onReqError);
183
+ res.removeListener('close', onEdgeClose);
184
+ };
118
185
  const upstreamReq = mod.request(opts, (upstreamRes) => {
186
+ upstream = upstreamRes;
119
187
  cbs.onResponse();
120
188
  if (res.headersSent || res.writableEnded || res.destroyed) {
121
189
  upstreamRes.destroy();
@@ -154,15 +222,17 @@ function createProxy(rt, obs, svcLayer) {
154
222
  // normal-completion 'close' a no-op. (An unhandled 'error' on upstreamRes would also throw.)
155
223
  const abortEdge = () => { if (!res.writableEnded && !res.destroyed)
156
224
  res.destroy(); };
157
- upstreamRes.on('error', abortEdge);
158
- upstreamRes.on('close', abortEdge);
159
- // The client canceled: stop reading the upstream so its pooled keep-alive socket isn't left flowing
160
- // (a half-drained socket is corrupt on the next reuse → would break a later request).
161
- res.on('close', () => { if (!upstreamRes.destroyed)
162
- upstreamRes.destroy(); });
225
+ upstreamRes.once('error', abortEdge);
226
+ upstreamRes.once('close', abortEdge);
227
+ res.on('close', onEdgeClose);
163
228
  });
164
- upstreamReq.on('error', (err) => cbs.onError(err));
165
- req.on('error', () => upstreamReq.destroy());
229
+ upstreamReq.once('error', (err) => cbs.onError(err));
230
+ // ClientRequest 'close' fires exactly once however the attempt ended (completed, errored, destroyed), so
231
+ // it is the one place that can un-register this attempt's listeners — and drop the closures retaining
232
+ // `req`/`res`/`upstream`. For a long-lived stream it fires only when the stream ends, so `onEdgeClose`
233
+ // stays armed for the whole stream (a mid-stream client cancel MUST still destroy the upstream).
234
+ upstreamReq.once('close', detach);
235
+ req.on('error', onReqError);
166
236
  // GET/HEAD/OPTIONS carry no body — end immediately; else stream the request body upstream.
167
237
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
168
238
  upstreamReq.end();
@@ -234,8 +304,11 @@ function createProxy(rt, obs, svcLayer) {
234
304
  pipeUpstream(tcpTarget, req, res, {
235
305
  onResponse: () => { },
236
306
  onError: (err) => {
237
- if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded) {
238
- setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
307
+ // `!res.destroyed`: once the CLIENT is gone there is nobody to heal for — retrying would hammer a
308
+ // recovering upstream for the rest of the window and then write an error into a dead stream.
309
+ if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
310
+ const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
311
+ t.unref?.(); // a pending heal retry must never keep the gateway process alive
239
312
  return;
240
313
  }
241
314
  // Past the response headers there's nothing to send an error body into — reset the stream.
@@ -352,10 +425,14 @@ function createProxy(rt, obs, svcLayer) {
352
425
  const tcpDeadline = Date.now() + types_1.UPSTREAM_HEAL_WINDOW_MS;
353
426
  const tryTcp = () => {
354
427
  proxy.web(req, res, tcpOpts, (err) => {
355
- if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded) {
356
- setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
428
+ // `!res.destroyed` see the h2 twin above: with the client gone there is nothing to heal for.
429
+ if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
430
+ const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
431
+ t.unref?.();
357
432
  return;
358
433
  }
434
+ if (res.destroyed)
435
+ return; // the client left — no stream to write an error into
359
436
  console.error(`[gateway] upstream ${route.project} (${String(tcpTarget)}):`, err.message);
360
437
  (0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
361
438
  });
@@ -11,6 +11,18 @@ export declare function readLensScope(root: string): {
11
11
  dashboardPort: number;
12
12
  basePath: string;
13
13
  };
14
+ /**
15
+ * Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
16
+ * `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
17
+ * must NOT treat as source. The scanner deliberately over-includes every
18
+ * unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
19
+ * like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
20
+ * can ever import — each add there flips the signature and recycles pods for
21
+ * nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
22
+ * dashboard; 475 recycles in one day were scope-C adds). Names only (no
23
+ * globs), matched exactly like the built-in excludes. Empty when unset.
24
+ */
25
+ export declare function readSourceSetExclude(root: string): Set<string>;
14
26
  export declare function lensKeyFrom(root: string): string;
15
27
  export declare function lensSlug(input: string): string;
16
28
  //# sourceMappingURL=scope.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/scope.ts"],"names":[],"mappings":"AAOA;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;yEAEyE;AACzE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C"}
1
+ {"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/scope.ts"],"names":[],"mappings":"AAOA;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;yEAEyE;AACzE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAa9D;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C"}
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.baseDomainOf = baseDomainOf;
4
4
  exports.readLensScope = readLensScope;
5
+ exports.readSourceSetExclude = readSourceSetExclude;
5
6
  exports.lensKeyFrom = lensKeyFrom;
6
7
  exports.lensSlug = lensSlug;
7
8
  const tslib_1 = require("tslib");
@@ -48,6 +49,30 @@ function readLensScope(root) {
48
49
  const key = lensKeyFrom(root);
49
50
  return { key, dashboardPort: 4321, basePath: '/' + key };
50
51
  }
52
+ /**
53
+ * Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
54
+ * `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
55
+ * must NOT treat as source. The scanner deliberately over-includes every
56
+ * unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
57
+ * like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
58
+ * can ever import — each add there flips the signature and recycles pods for
59
+ * nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
60
+ * dashboard; 475 recycles in one day were scope-C adds). Names only (no
61
+ * globs), matched exactly like the built-in excludes. Empty when unset.
62
+ */
63
+ function readSourceSetExclude(root) {
64
+ try {
65
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
66
+ const raw = cfg?.sourceSet?.exclude;
67
+ if (Array.isArray(raw)) {
68
+ return new Set(raw.filter((d) => typeof d === 'string' && d.length > 0 && !d.includes('/')));
69
+ }
70
+ }
71
+ catch {
72
+ /* no config — nothing extra to exclude */
73
+ }
74
+ return new Set();
75
+ }
51
76
  function lensKeyFrom(root) {
52
77
  try {
53
78
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
@@ -15,5 +15,38 @@ export declare function buildEdgeVerifierPool(getRoutes: () => Route[]): EdgeVer
15
15
  /** Client-abort errnos that are NORMAL front-door traffic (a closed tab, a sleeping laptop, a canceled
16
16
  * HMR socket) — dropped quietly. Anything else on an inbound socket is logged before the drop. */
17
17
  export declare const BENIGN_SOCKET_ERRNOS: ReadonlySet<string>;
18
+ /**
19
+ * PURE classification of Node's H1 `clientError` — what to answer, and whether it is worth reporting.
20
+ *
21
+ * The old handler answered a BARE, UNLOGGED `400 Bad Request` to every cause and threw `err` away. That is
22
+ * how a gateway-generated 400 became indistinguishable from an application 400: a dev sees `GET
23
+ * /api/…/artifacts → 400` in the browser while the service logged `200`, with nothing anywhere to say the
24
+ * front door invented it. (Worked failure, foodguard: spurious empty-bodied 400s on real API calls, which
25
+ * a dev-proxy hop forwards verbatim to the browser, so they look exactly like app bugs.) MEASURED causes on
26
+ * Node 24: `HPE_INVALID_METHOD`, `HPE_INVALID_HEADER_TOKEN`, `HPE_HEADER_OVERFLOW`, `HPE_INVALID_EOF_STATE`.
27
+ *
28
+ * Three corrections fall out of that list:
29
+ * - `HPE_HEADER_OVERFLOW` is **431**, not 400 — the request was well-formed, just too big.
30
+ * - `ERR_HTTP_REQUEST_TIMEOUT` is **408** (what Node's own default handler answers) — a slow client is not
31
+ * a malformed one, and calling it 400 sends a dev hunting a nonexistent parse bug.
32
+ * - `HPE_INVALID_EOF_STATE` means the peer half-closed MID-MESSAGE — the signature of a client that went
33
+ * away, and of the classic keep-alive REUSE RACE (a pooled client socket written to just as the server
34
+ * closes it, see `GATEWAY_KEEPALIVE_TIMEOUT_MS`). Writing a 400 into that socket is precisely what turns
35
+ * a harmless race into a client-visible 400, so it is treated like `ECONNRESET`: destroy, say nothing.
36
+ * Everything unrecognized keeps the previous behavior (400 + destroy), now logged.
37
+ */
38
+ export interface ClientErrorVerdict {
39
+ /** Status line to best-effort write, or `null` for "write nothing, just destroy". */
40
+ status: 400 | 408 | 431 | null;
41
+ /** Log + emit? A routine client abort is not worth a line (and a scanner must not be able to flood). */
42
+ report: boolean;
43
+ /** Short cause label for the log/event. */
44
+ cause: string;
45
+ }
46
+ export declare function classifyClientError(code: string | undefined, message: string): ClientErrorVerdict;
47
+ /** Per-cause log throttle for `clientError`: a desynced client or a port scanner can produce these in a
48
+ * tight loop, and the point is a dev NOTICING one — not drowning in thousands. One line per cause per
49
+ * window; the suppressed count rides the next admitted line. Exported for the unit test. */
50
+ export declare function createClientErrorLogGate(windowMs?: number): (cause: string) => boolean;
18
51
  export declare function startGateway(options: GatewayRuntimeOptions, context: GatewayContext): Promise<GatewayHandle>;
19
52
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA0BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED;mGACmG;AACnG,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAA8E,CAAC;AAkBpI,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA8RxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA2BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED;mGACmG;AACnG,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAA8E,CAAC;AAEpI;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,kBAAkB;IACjC,qFAAqF;IACrF,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,CAAC;IAC/B,wGAAwG;IACxG,MAAM,EAAE,OAAO,CAAC;IAChB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,kBAAkB,CAQjG;AAQD;;6FAE6F;AAC7F,wBAAgB,wBAAwB,CAAC,QAAQ,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAStF;AAkBD,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAsUxB"}