@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.
@@ -2,12 +2,34 @@ import { type SpawnChild } from '@lensmcp/nx-plugin/lens-frontend';
2
2
  import type { WorkspaceFragment } from './route-registry';
3
3
  import type { GatewayRuntime, GatewayRuntimeOptions } from './types';
4
4
  import { type Observability } from './observability';
5
- /** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
6
- * know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
7
- * holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
8
- * we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
9
- * dashboard redirects to foodguard" bug). */
10
5
  export declare function probePortFree(port: number): Promise<boolean>;
6
+ /** True when something is LISTENING on `port` on loopback — the liveness signal the exit handler cannot
7
+ * give us ({@link LENS_PROBE_ENABLED}: a vite alive with zero LISTEN sockets).
8
+ *
9
+ * BOTH loopback families are tried before reporting a failure. Vite binds the NAME `localhost`, which can
10
+ * resolve to `::1` on a v6-preferring host — probing only `127.0.0.1` there would report every healthy dev
11
+ * server as wedged and SIGKILL the whole cluster in a loop. The v6 attempt only runs on the v4 failure
12
+ * path, so the healthy case stays one connect. */
13
+ export declare function probePortListening(port: number, timeoutMs?: number): Promise<boolean>;
14
+ /**
15
+ * Poll until `port` is actually BINDABLE, up to `timeoutMs`. Resolves `true` when it frees, `false` on
16
+ * timeout (the caller then spawns anyway and lets the normal heal handle the fallout).
17
+ *
18
+ * WHY a respawn must await this: a dying vite can hold its listener for seconds (open HMR websockets keep
19
+ * the server closing), so the fixed 1.5s heal delay raced it and the fresh child died on
20
+ * `listen EADDRINUSE` — a self-inflicted crash that burned a heal attempt and lengthened the outage
21
+ * (see {@link PORT_FREE_WAIT_MS}). Probes at least ONCE even with `timeoutMs: 0`, so the common already-free
22
+ * case costs a single probe and no delay.
23
+ */
24
+ export declare function waitForPortFree(port: number, timeoutMs: number, deps?: {
25
+ isPortFree?: (port: number) => Promise<boolean>;
26
+ intervalMs?: number;
27
+ }): Promise<boolean>;
28
+ /** The `--port <n>` / `--port=<n>` a vite child was launched with. `spawnLensFrontend` always PINS the port
29
+ * for a gateway-routed app (`--port <n> --strictPort`), so argv is the authoritative record of which port
30
+ * this child owns — the port the liveness probe checks and the port its respawn must wait to be free.
31
+ * `undefined` for an unpinned child (nothing to probe, nothing to wait for). */
32
+ export declare function viteArgPort(args: readonly string[]): number | undefined;
11
33
  export interface LensChildren {
12
34
  spawnManagedChild: SpawnChild;
13
35
  /** Boot the dashboard + MCP singletons and every `lens:true` app. Pushes the
@@ -1 +1 @@
1
- {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AASA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAI1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAuB,MAAM,SAAS,CAAC;AAQ1F,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E;;;;8CAI8C;AAC9C,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO5D;AAED,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;;gFAE4E;IAC5E,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;IACvF;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IACpE;gHAC4G;IAC5G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,cAAc,EAClB,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,qBAAqB,EAE9B,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GACzD,YAAY,CAmWd"}
1
+ {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AASA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAI1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAuB,MAAM,SAAS,CAAC;AAgB1F,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AA0B7E,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAalE;AAyBD;;;;;;mDAMmD;AACnD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAG1F;AAMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GAClF,OAAO,CAAC,OAAO,CAAC,CAUlB;AAED;;;iFAGiF;AACjF,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS,CAQvE;AAED,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;;gFAE4E;IAC5E,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;IACvF;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IACpE;gHAC4G;IAC5G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,cAAc,EAClB,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,qBAAqB,EAE9B,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GACzD,YAAY,CAmbd"}
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.probePortFree = probePortFree;
4
+ exports.probePortListening = probePortListening;
5
+ exports.waitForPortFree = waitForPortFree;
6
+ exports.viteArgPort = viteArgPort;
4
7
  exports.createLensChildren = createLensChildren;
5
8
  const tslib_1 = require("tslib");
6
9
  /**
@@ -27,14 +30,110 @@ const HEAL_HEALTHY_MS = 20_000; // ran at least this long ⇒ a FRESH crash ⇒
27
30
  * holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
28
31
  * we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
29
32
  * dashboard redirects to foodguard" bug). */
30
- function probePortFree(port) {
33
+ /** One bind attempt. `in-use` only for the errnos that actually mean "someone has it"; any other failure
34
+ * means that ADDRESS FAMILY is unavailable on this machine (no IPv6, for instance), which is not evidence
35
+ * either way and must never be reported as "taken" — that would make `claimFreePort` step up forever. */
36
+ function tryBind(port, host) {
31
37
  return new Promise((resolve) => {
32
38
  const probe = net.createServer();
33
39
  probe.unref();
34
- probe.once('error', () => resolve(false));
35
- probe.listen(port, () => probe.close(() => resolve(true)));
40
+ probe.once('error', (e) => {
41
+ const code = e.code ?? '';
42
+ resolve(code === 'EADDRINUSE' || code === 'EACCES' ? 'in-use' : 'unavailable');
43
+ });
44
+ probe.listen(host === undefined ? { port } : { port, host }, () => probe.close(() => resolve('free')));
45
+ });
46
+ }
47
+ async function probePortFree(port) {
48
+ // The WILDCARD bind alone is NOT enough, and that hole defeated the very purpose of this probe. MEASURED:
49
+ // with a holder on `127.0.0.1:P`, `listen({ port: P })` (the wildcard) reports **FREE** — a wildcard bind
50
+ // does not collide with a specific-address bind on macOS/BSD. Our children bind the NAME `localhost`
51
+ // (vite `--port N --strictPort`, the dashboard bundles), i.e. a LOOPBACK address — exactly the case the
52
+ // wildcard probe cannot see. So an orphaned vite holding `127.0.0.1:4200` looked free, `claimFreePort`
53
+ // handed 4200 straight back out, and the fresh child died on `listen EADDRINUSE` — the self-inflicted
54
+ // crash this probe exists to prevent (and what makes `waitForPortFree` meaningful rather than a no-op).
55
+ // Free means bindable on the wildcard AND on both loopback addresses; unavailable families are skipped.
56
+ for (const host of [undefined, '127.0.0.1', '::1']) {
57
+ if ((await tryBind(port, host)) === 'in-use')
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ /** A single-address TCP connect: does `host:port` ACCEPT right now? Connect-and-destroy, never an HTTP
63
+ * request — a WEDGED vite may accept then hang forever, so a `GET` would time out even where the listener
64
+ * is fine (and would pollute the dev server's log). Every timer/socket is unref'd: a probe must never hold
65
+ * the gateway process open. */
66
+ function probeConnect(host, port, timeoutMs) {
67
+ return new Promise((resolve) => {
68
+ let done = false;
69
+ const sock = net.createConnection({ host, port });
70
+ sock.unref();
71
+ const timer = setTimeout(() => finish(false), timeoutMs);
72
+ timer.unref?.();
73
+ function finish(ok) {
74
+ if (done)
75
+ return;
76
+ done = true;
77
+ clearTimeout(timer);
78
+ sock.destroy();
79
+ resolve(ok);
80
+ }
81
+ sock.once('connect', () => finish(true));
82
+ sock.once('error', () => finish(false));
36
83
  });
37
84
  }
85
+ /** True when something is LISTENING on `port` on loopback — the liveness signal the exit handler cannot
86
+ * give us ({@link LENS_PROBE_ENABLED}: a vite alive with zero LISTEN sockets).
87
+ *
88
+ * BOTH loopback families are tried before reporting a failure. Vite binds the NAME `localhost`, which can
89
+ * resolve to `::1` on a v6-preferring host — probing only `127.0.0.1` there would report every healthy dev
90
+ * server as wedged and SIGKILL the whole cluster in a loop. The v6 attempt only runs on the v4 failure
91
+ * path, so the healthy case stays one connect. */
92
+ async function probePortListening(port, timeoutMs = 1_000) {
93
+ if (await probeConnect('127.0.0.1', port, timeoutMs))
94
+ return true;
95
+ return probeConnect('::1', port, timeoutMs);
96
+ }
97
+ /** Unref'd sleep — a heal/backoff wait must never keep the gateway alive past `stop()`. */
98
+ const sleepUnref = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
99
+ /**
100
+ * Poll until `port` is actually BINDABLE, up to `timeoutMs`. Resolves `true` when it frees, `false` on
101
+ * timeout (the caller then spawns anyway and lets the normal heal handle the fallout).
102
+ *
103
+ * WHY a respawn must await this: a dying vite can hold its listener for seconds (open HMR websockets keep
104
+ * the server closing), so the fixed 1.5s heal delay raced it and the fresh child died on
105
+ * `listen EADDRINUSE` — a self-inflicted crash that burned a heal attempt and lengthened the outage
106
+ * (see {@link PORT_FREE_WAIT_MS}). Probes at least ONCE even with `timeoutMs: 0`, so the common already-free
107
+ * case costs a single probe and no delay.
108
+ */
109
+ async function waitForPortFree(port, timeoutMs, deps = {}) {
110
+ const isFree = deps.isPortFree ?? probePortFree;
111
+ const intervalMs = Math.max(1, deps.intervalMs ?? types_1.PORT_FREE_POLL_MS);
112
+ const deadline = Date.now() + Math.max(0, timeoutMs);
113
+ for (;;) {
114
+ if (await isFree(port))
115
+ return true;
116
+ const left = deadline - Date.now();
117
+ if (left <= 0)
118
+ return false;
119
+ await sleepUnref(Math.min(intervalMs, left));
120
+ }
121
+ }
122
+ /** The `--port <n>` / `--port=<n>` a vite child was launched with. `spawnLensFrontend` always PINS the port
123
+ * for a gateway-routed app (`--port <n> --strictPort`), so argv is the authoritative record of which port
124
+ * this child owns — the port the liveness probe checks and the port its respawn must wait to be free.
125
+ * `undefined` for an unpinned child (nothing to probe, nothing to wait for). */
126
+ function viteArgPort(args) {
127
+ const valid = (n) => (Number.isInteger(n) && n > 0 && n < 65_536 ? n : undefined);
128
+ for (let i = 0; i < args.length; i += 1) {
129
+ const a = args[i];
130
+ if (a === '--port')
131
+ return valid(Number(args[i + 1]));
132
+ if (a.startsWith('--port='))
133
+ return valid(Number(a.slice('--port='.length)));
134
+ }
135
+ return undefined;
136
+ }
38
137
  function createLensChildren(rt, obs, options,
39
138
  // Injectable OS-level port probe (unit tests stub it; real ports are machine-dependent).
40
139
  deps) {
@@ -43,10 +142,14 @@ deps) {
43
142
  const lensChildren = [];
44
143
  // Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
45
144
  // backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
145
+ // `portConflicts` counts CONSECUTIVE lost bind races (EADDRINUSE) — forgiven rather than charged to
146
+ // `attempts`, but capped so a permanently-squatted port still falls back to backoff.
46
147
  const lensHeal = new Map();
47
- // Zombie recycle: the live `lens:true` app VITE children (label 'vite') + when each spawned, so the lens
48
- // sweeper can restart one whose fs-watcher missed a post-spawn file add (the reported dashboard zombie).
49
- // The capture sidecar + the dashboard/MCP singletons are NOT tracked (they don't serve app source).
148
+ // Zombie recycle + LIVENESS probe: the live `lens:true` app VITE children (label 'vite') + when each
149
+ // spawned, its pinned `port`, and its run of consecutive probe failures. `port`/`probeFails` drive the
150
+ // wedge probe (alive but NOT listening); `spawnedAt` also drives the stale-source-set recycle. Both reset
151
+ // naturally on a respawn, which registers a FRESH handle. The capture sidecar + the dashboard/MCP
152
+ // singletons are NOT tracked (they don't serve app source).
50
153
  const viteChildren = new Map();
51
154
  // Per-project last-recycle time — the cooldown survives the kill→auto-heal→respawn (a new child handle).
52
155
  const viteRecycleAt = new Map();
@@ -101,9 +204,14 @@ deps) {
101
204
  lensChildren.push(child);
102
205
  labeledChildren.set(label, child); // the CURRENT handle for this label (a per-workspace dashboard reap targets it)
103
206
  // Track a `lens:true` app's vite dev server (label 'vite', or `<ws>:<proj>:vite` for a registered
104
- // workspace) for the zombie recycle. Its `LENSMCP_PROJECT` names the app; a fresh respawn re-adds here.
105
- if (label === 'vite' || label.endsWith(':vite'))
106
- viteChildren.set(child, { project: env?.['LENSMCP_PROJECT'] ?? 'app', spawnedAt });
207
+ // workspace) for the zombie recycle + the liveness probe. Its `LENSMCP_PROJECT` names the app and its
208
+ // argv pins the port; a fresh respawn re-adds here with a clean probe run.
209
+ const isVite = label === 'vite' || label.endsWith(':vite');
210
+ // The port THIS child owns (pinned `--port <n> --strictPort`): what the liveness probe checks and what
211
+ // the respawn must wait to be free. `undefined` for the dashboard/MCP singletons + an unpinned vite.
212
+ const ownPort = isVite ? viteArgPort(args) : undefined;
213
+ if (isVite)
214
+ viteChildren.set(child, { project: env?.['LENSMCP_PROJECT'] ?? 'app', spawnedAt, port: ownPort, probeFails: 0 });
107
215
  child.on('exit', (code, sig) => {
108
216
  const idx = lensChildren.indexOf(child);
109
217
  if (idx >= 0)
@@ -111,37 +219,126 @@ deps) {
111
219
  viteChildren.delete(child); // the auto-heal respawn re-registers a fresh handle with a new spawnedAt
112
220
  if (rt.stopped() || removedLabels.has(label))
113
221
  return; // shutting down, or this workspace unregistered — do NOT resurrect
114
- const heal = lensHeal.get(label) ?? { attempts: 0 };
115
- if (Date.now() - spawnedAt >= HEAL_HEALTHY_MS)
116
- heal.attempts = 0; // it was healthy fresh budget
117
- const delay = Math.min(HEAL_MAX_MS, HEAL_BASE_MS * 2 ** heal.attempts);
118
- heal.attempts += 1;
119
- lensHeal.set(label, heal);
120
- console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); auto-healing in ${Math.round(delay / 1000)}s (attempt ${heal.attempts}).`);
121
- const t = setTimeout(() => { if (!rt.stopped())
122
- spawnManagedChild(label, bin, args, env, optional, respawn, cwd); }, delay);
123
- t.unref?.();
222
+ const ranMs = Date.now() - spawnedAt;
223
+ const heal = lensHeal.get(label) ?? { attempts: 0, portConflicts: 0 };
224
+ // The heal is ASYNC from here: classifying a bind race and waiting for the port both need a probe.
225
+ // Every wait is unref'd and every resume re-checks stopped()/removedLabels, so shutdown still wins.
226
+ void (async () => {
227
+ // BIND RACE vs CRASH. A fast non-zero exit while ANOTHER process still holds our pinned port is
228
+ // vite's `--strictPort` losing an EADDRINUSE race with the child it replaced an environment
229
+ // condition, not a code fault. Charging it to `attempts` is what pushed the observed heal to
230
+ // "attempt 2" and doubled the outage; forgive it (bounded) and wait the port out instead.
231
+ const portHeld = ownPort !== undefined && !(await isPortFree(ownPort));
232
+ const raceLost = (0, lifecycle_1.isPortConflictExit)({ code, signal: sig, ranMs, portHeld }, { fastFailMs: types_1.CHILD_START_FAIL_MS });
233
+ if (raceLost && heal.portConflicts < types_1.MAX_PORT_CONFLICT_RETRIES) {
234
+ heal.portConflicts += 1; // `attempts` UNTOUCHED — the crash-loop budget is for real crashes
235
+ lensHeal.set(label, heal);
236
+ console.warn(`[gateway] lens child ${label} lost the :${ownPort} bind race (EADDRINUSE, code=${code}); waiting for the port, then retrying (port-retry ${heal.portConflicts}/${types_1.MAX_PORT_CONFLICT_RETRIES}; crash budget untouched).`);
237
+ }
238
+ else {
239
+ heal.portConflicts = 0; // a real exit ends the bind-race run
240
+ if (ranMs >= HEAL_HEALTHY_MS)
241
+ heal.attempts = 0; // it was healthy → fresh budget
242
+ const delay = Math.min(HEAL_MAX_MS, HEAL_BASE_MS * 2 ** heal.attempts);
243
+ heal.attempts += 1;
244
+ lensHeal.set(label, heal);
245
+ console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); auto-healing in ${Math.round(delay / 1000)}s (attempt ${heal.attempts}).`);
246
+ await sleepUnref(delay);
247
+ }
248
+ if (rt.stopped() || removedLabels.has(label))
249
+ return;
250
+ // NEVER respawn into the dying child's port. A closing vite can hold its listener for seconds (open
251
+ // HMR websockets), so the old fixed delay raced it → `listen EADDRINUSE` → a self-inflicted crash
252
+ // that burned a heal attempt and LENGTHENED the 502. Bounded: past the budget we spawn anyway and
253
+ // let the (now correctly-classified) heal take it from there.
254
+ if (ownPort !== undefined && !(await waitForPortFree(ownPort, types_1.PORT_FREE_WAIT_MS, { isPortFree, intervalMs: types_1.PORT_FREE_POLL_MS }))) {
255
+ console.warn(`[gateway] lens child ${label}: :${ownPort} still held after ${Math.round(types_1.PORT_FREE_WAIT_MS / 1000)}s — spawning anyway.`);
256
+ }
257
+ if (rt.stopped() || removedLabels.has(label))
258
+ return;
259
+ spawnManagedChild(label, bin, args, env, optional, respawn, cwd);
260
+ })().catch(() => undefined); // a heal must never surface as an unhandled rejection
124
261
  });
125
262
  return child;
126
263
  };
127
- // ZOMBIE recycle for lens frontends: restart a `lens:true` app's vite whose fs-watcher went stale it is
128
- // ALIVE + reachable yet MISSED files created after it spawned (500 `Failed to resolve import` for a file
129
- // that exists; the reported 17.7h dashboard pod). The shared `rt.sourceSetChangedAt` (stamped by the
130
- // lifecycle sweeper's source-set scan) drives it: a vite that spawned before a settled set change is
131
- // killed this manager's own `on('exit')` auto-heal respawns a fresh vite that re-scans the FS. Only on a
132
- // set CHANGE (add/remove), never a content save, and never a blind max-age (no idle signal for a frontend).
264
+ // LIVENESS probe for lens frontends: kill a vite that is ALIVE but NOT LISTENING (the permanent-502 wedge
265
+ // see LENS_PROBE_ENABLED). One cheap TCP connect per tracked child per tick; the pure `lensChildWedged`
266
+ // owns the verdict (grace + N consecutive failures + the shared per-project cooldown), this owns the I/O.
267
+ // A confirmed wedge gets SIGKILL deliberately NOT SIGTERM: graceful shutdown is exactly what wedged (the
268
+ // event loop is pinned by the lens WS server / bridge sockets), so a SIGTERM can hang and leave the app
269
+ // 502ing forever. The kill lands on the proven exit→auto-heal path, which respawns after waiting the port
270
+ // out. `probeInFlight` keeps a slow round from overlapping the next tick.
271
+ let probeInFlight = false;
272
+ const probeLensChildren = async (cfg) => {
273
+ if (probeInFlight)
274
+ return;
275
+ probeInFlight = true;
276
+ try {
277
+ for (const [child, meta] of [...viteChildren]) {
278
+ if (rt.stopped())
279
+ return;
280
+ if (meta.port === undefined)
281
+ continue; // unpinned port ⇒ nothing to probe
282
+ if (child.exitCode !== null || child.signalCode !== null)
283
+ continue; // already dying — the exit heal owns it
284
+ if (Date.now() - meta.spawnedAt < cfg.graceMs) {
285
+ meta.probeFails = 0;
286
+ continue;
287
+ } // still booting
288
+ if (await probePortListening(meta.port, types_1.LENS_PROBE_TIMEOUT_MS)) {
289
+ meta.probeFails = 0;
290
+ continue;
291
+ }
292
+ meta.probeFails += 1;
293
+ const reason = (0, lifecycle_1.lensChildWedged)(meta, viteRecycleAt.get(meta.project) ?? 0, Date.now(), cfg);
294
+ if (!reason)
295
+ continue; // failing, but not yet N-in-a-row / still in cooldown
296
+ viteRecycleAt.set(meta.project, Date.now());
297
+ meta.probeFails = 0;
298
+ console.error(`[gateway] wedge recycle: lens app ${meta.project} is ALIVE but NOT LISTENING on :${meta.port} after ${Math.max(2, cfg.failureThreshold)} probes — SIGKILL; a fresh vite is coming.`);
299
+ emit('warning', `wedge recycle: ${meta.project} (${reason})`, `cluster-lens-app:${meta.project}`, { kind: 'pod-recycle', project: meta.project, reason, port: meta.port });
300
+ try {
301
+ child.kill('SIGKILL');
302
+ }
303
+ catch { /* already gone — the exit auto-heal will respawn */ }
304
+ }
305
+ }
306
+ finally {
307
+ probeInFlight = false;
308
+ }
309
+ };
310
+ // The lens-frontend sweeper drives BOTH self-heals over the tracked vite children:
311
+ // 1. the LIVENESS probe above — alive but not listening (a permanent 502, invisible to the exit handler);
312
+ // 2. ZOMBIE recycle — a vite whose fs-watcher went stale: ALIVE + reachable yet MISSING files created
313
+ // after it spawned (500 `Failed to resolve import` for a file that exists; the reported 17.7h
314
+ // dashboard pod). The shared `rt.sourceSetChangedAt` (stamped by the lifecycle sweeper's source-set
315
+ // scan) drives it: a vite that spawned before a settled set change is killed → this manager's own
316
+ // `on('exit')` auto-heal respawns a fresh vite that re-scans the FS. Only on a set CHANGE
317
+ // (add/remove), never a content save, and never a blind max-age (no idle signal for a frontend).
133
318
  const startLensSweeper = () => {
134
- if (!types_1.POD_RECYCLE_ENABLED || lensSweeper)
319
+ // Either heal alone justifies the sweeper: a user who disabled the staleness recycle (its churn was a
320
+ // known problem) must still get a wedged 502 healed, so each branch is gated on its OWN switch.
321
+ if (lensSweeper || (!types_1.POD_RECYCLE_ENABLED && !types_1.LENS_PROBE_ENABLED))
135
322
  return;
136
323
  const cfg = { graceMs: types_1.POD_STALE_GRACE_MS, settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS };
324
+ const probeCfg = { graceMs: types_1.LENS_PROBE_GRACE_MS, failureThreshold: types_1.LENS_PROBE_FAILS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS };
137
325
  lensSweeper = setInterval(() => {
138
- if (rt.stopped())
326
+ if (rt.stopped() || viteChildren.size === 0)
327
+ return;
328
+ // The liveness probe is INDEPENDENT of the source-set clock — a wedge has nothing to do with the file
329
+ // set, so it must run before (and regardless of) the staleness early-return below.
330
+ if (types_1.LENS_PROBE_ENABLED)
331
+ void probeLensChildren(probeCfg);
332
+ if (!types_1.POD_RECYCLE_ENABLED)
139
333
  return;
140
- const changedAt = rt.sourceSetChangedAt ?? 0;
141
- if (changedAt <= 0 || viteChildren.size === 0)
334
+ const wideChangedAt = rt.sourceSetChangedAt ?? 0;
335
+ if (wideChangedAt <= 0)
142
336
  return;
143
337
  const now = Date.now();
144
338
  for (const [child, meta] of viteChildren) {
339
+ // Prefer this app's SCOPED clock — the file set within the subtrees ITS vite can resolve. On the wide
340
+ // clock a backend-only `.ts` add anywhere in the monorepo restarted every frontend (`sourceScopeDirs`).
341
+ const changedAt = rt.sourceSetChangedFor?.get(meta.project) ?? wideChangedAt;
145
342
  const last = viteRecycleAt.get(meta.project) ?? 0;
146
343
  if (!(0, lifecycle_1.lensFrontendStale)(meta.spawnedAt, last, changedAt, now, cfg))
147
344
  continue;
@@ -3,8 +3,34 @@ import type { Observability } from './observability';
3
3
  /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
4
4
  * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
5
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. */
7
- export declare function sourceSetSignature(root: string): Promise<string>;
6
+ * files. Order-independent (readdir order irrelevant) and null-safe.
7
+ *
8
+ * `scopeDirs` (absolute, from {@link sourceScopeDirs}) narrows the walk to the subtrees a given child could
9
+ * actually resolve, instead of the whole monorepo — see that function for WHY. Omitted ⇒ the whole root
10
+ * (the original workspace-wide behavior, still used when a child's project root is unknown). Root-level
11
+ * loose files are always counted, so a `package.json`/`tsconfig.base.json` add reaches every scope. */
12
+ export declare function sourceSetSignature(root: string, scopeDirs?: readonly string[], extraExclude?: ReadonlySet<string>): Promise<string>;
13
+ /**
14
+ * PURE: which subtrees a child of `projectRoot` must watch for a "file set changed" verdict.
15
+ *
16
+ * The staleness signal exists to catch a vite/pod whose fs-watcher MISSED a file add. But the signature was
17
+ * workspace-WIDE while its consumers are PER-CHILD, so adding a backend-only `server/apps/auth/**\/*.ts`
18
+ * — a file in no vite module graph — recycled every frontend vite too. Worked failure (foodguard,
19
+ * 2026-07-28): one session writing `server/apps/auth/src/modules/totp/**` added a `.ts` every ~15-20s and
20
+ * held `app.foodguard.local` in near-continuous 502s, 951 recycles in a session, none of them relevant to
21
+ * the dashboard.
22
+ *
23
+ * The scope is the child's OWN top-level bucket plus every bucket that hosts no OTHER gateway-managed
24
+ * project (`managedBuckets`). In a scope-first monorepo (`web/ server/ shared/ ai/ tools/`) that yields
25
+ * `web + shared + ai + tools` for a frontend and `server + shared + ai + tools` for a service: a
26
+ * `server/**` add no longer touches a frontend, while a `shared/contracts` add still reaches both — which
27
+ * is correct, since both import it. It deliberately OVER-includes (a frontend watches `ai/` it may never
28
+ * import) because a false recycle costs a cold start whereas a MISSED one costs a wedged dev server.
29
+ *
30
+ * Both args are absolute; the result is absolute and deduped. An empty result (the project root IS the
31
+ * workspace root, or it sits outside it) means "no narrowing" → the caller falls back to the whole root.
32
+ */
33
+ export declare function sourceScopeDirs(workspaceRoot: string, projectRoot: string, managedBuckets: readonly string[], topLevelDirs: readonly string[], extraExclude?: ReadonlySet<string>): string[];
8
34
  export interface PodRecycleConfig {
9
35
  maxAgeMs: number;
10
36
  graceMs: number;
@@ -32,6 +58,59 @@ export declare function lensFrontendStale(spawnedAt: number, lastRecycleAt: numb
32
58
  settleMs: number;
33
59
  cooldownMs: number;
34
60
  }): boolean;
61
+ /** Config for the lens-child LIVENESS decision ({@link lensChildWedged}). */
62
+ export interface LensChildProbeConfig {
63
+ /** Spawn grace: no verdict until the child has had this long to BIND its port (a cold vite boot). */
64
+ graceMs: number;
65
+ /** CONSECUTIVE probe failures required. Floored at 2 — one blip must never recycle. */
66
+ failureThreshold: number;
67
+ /** Per-project anti-thrash window after any recycle (shared with {@link lensFrontendStale}). */
68
+ cooldownMs: number;
69
+ }
70
+ /**
71
+ * PURE decision: is this ALIVE lens-frontend child WEDGED — process running, port NOT listening?
72
+ *
73
+ * The third self-heal blind spot. `lensFrontendStale` catches a vite serving a stale FS view; the exit
74
+ * handler catches a vite that DIED. Neither sees a vite that is alive, has printed `VITE ready`, and has
75
+ * NO LISTEN socket — its event loop held open by the lens WS server / bridge sockets while its HTTP
76
+ * listener is gone. Nothing exits, so nothing auto-heals, and the app 502s until a human kills the pid
77
+ * (foodguard 2026-07-29 — see {@link LENS_PROBE_ENABLED}).
78
+ *
79
+ * The caller owns the socket I/O (a cheap TCP connect per tick) and accumulates `probeFails`; this owns
80
+ * WHEN that evidence justifies a kill. Three guards, each earning its place:
81
+ * - **grace** — a still-booting vite has no listener YET; killing it would be the outage, not the fix.
82
+ * - **N consecutive failures** (≥2, enforced here so even a misconfigured `failureThreshold: 1` can't
83
+ * single-blip recycle) — a momentary refusal is not a wedge.
84
+ * - **cooldown** — the same per-project window the two recycle paths share, so probe + staleness can
85
+ * never thrash one project between them.
86
+ * Returns the recycle reason or `null`. Kept pure (no process, no socket) so all four are unit-tested.
87
+ */
88
+ export declare function lensChildWedged(child: {
89
+ spawnedAt: number;
90
+ probeFails: number;
91
+ }, lastRecycleAt: number, now: number, cfg: LensChildProbeConfig): 'wedged-no-listener' | null;
92
+ /**
93
+ * PURE decision: was this child's exit a lost BIND RACE (EADDRINUSE) rather than a crash?
94
+ *
95
+ * The auto-heal respawned on a fixed backoff while the child it replaced still held the pinned port, so
96
+ * vite's `--strictPort` exited code=1 on bind — and that self-inflicted exit consumed a heal attempt,
97
+ * pushing the backoff up and lengthening the very 502 it was healing (observed: `listen EADDRINUSE
98
+ * 127.0.0.1:50059` → `exited (code=1 sig=null); auto-healing in 3s (attempt 2)`). A bind race is an
99
+ * ENVIRONMENT condition, not a code fault, so it must not spend the crash-loop budget.
100
+ *
101
+ * The signature, from evidence we already have (stdio is `inherit`, so the child's stderr is unreadable):
102
+ * a non-zero exit, NOT from a signal (we killed it), FAST enough that it cannot have served traffic, while
103
+ * some OTHER process still holds the port. If the port is free by now the next spawn will simply succeed,
104
+ * so it is treated as a normal heal — the conservative direction.
105
+ */
106
+ export declare function isPortConflictExit(exit: {
107
+ code: number | null;
108
+ signal: NodeJS.Signals | string | null;
109
+ ranMs: number;
110
+ portHeld: boolean;
111
+ }, cfg: {
112
+ fastFailMs: number;
113
+ }): boolean;
35
114
  export interface ServiceLayer {
36
115
  scanNow(pool: SockPool): number;
37
116
  spawnService(svc: ServiceCtl): void;
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"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"}