@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.
- package/executors/gateway/runtime/lens-children.d.ts +27 -5
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -1
- package/executors/gateway/runtime/lens-children.js +226 -29
- package/executors/gateway/runtime/lifecycle.d.ts +81 -2
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -1
- package/executors/gateway/runtime/lifecycle.js +201 -13
- package/executors/gateway/runtime/proxy.d.ts +4 -1
- package/executors/gateway/runtime/proxy.d.ts.map +1 -1
- package/executors/gateway/runtime/proxy.js +91 -14
- package/executors/gateway/runtime/scope.d.ts +12 -0
- package/executors/gateway/runtime/scope.d.ts.map +1 -1
- package/executors/gateway/runtime/scope.js +25 -0
- package/executors/gateway/runtime/server.d.ts +33 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -1
- package/executors/gateway/runtime/server.js +77 -3
- package/executors/gateway/runtime/types.d.ts +65 -0
- package/executors/gateway/runtime/types.d.ts.map +1 -1
- package/executors/gateway/runtime/types.js +56 -1
- package/package.json +3 -3
|
@@ -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;
|
|
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
|
-
|
|
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', () =>
|
|
35
|
-
|
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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
|
-
|
|
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
|
|
141
|
-
if (
|
|
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
|
-
|
|
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":"
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"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"}
|