@lensmcp/cluster 1.16.30 → 1.16.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/executors/gateway/runtime/lens-children.js +2 -2
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -1
- package/executors/gateway/runtime/lifecycle.js +6 -1
- package/executors/gateway/runtime/observability.d.ts.map +1 -1
- package/executors/gateway/runtime/observability.js +25 -3
- package/executors/gateway/runtime/workspace-registry.d.ts +20 -2
- package/executors/gateway/runtime/workspace-registry.d.ts.map +1 -1
- package/executors/gateway/runtime/workspace-registry.js +32 -3
- package/main.devserver.js +23 -0
- package/package.json +3 -3
|
@@ -186,7 +186,7 @@ deps) {
|
|
|
186
186
|
rt.rebuildRoutes();
|
|
187
187
|
// Publish this workspace to the GLOBAL registry so the root chooser at `${lensHost}/` lists it
|
|
188
188
|
// (and marks it live while this gateway runs). Best-effort — a registry write never blocks serving.
|
|
189
|
-
(0, workspace_registry_1.registerWorkspace)({ key: scope.key, root: rt.root, base: lensBase, pid: process.pid, updatedAt: new Date().toISOString() });
|
|
189
|
+
(0, workspace_registry_1.registerWorkspace)({ key: scope.key, root: rt.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
|
|
190
190
|
console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at https://${lensHost}${lensBase}/)`);
|
|
191
191
|
emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort, base: lensBase });
|
|
192
192
|
spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
|
|
@@ -271,7 +271,7 @@ deps) {
|
|
|
271
271
|
rt.registry.appendRoutes(fragment.wsKey, [{ host: lensHost, prefix: lensBase, project: observability_1.LENS_DASHBOARD_PROJECT, target: `http://localhost:${port}` }]);
|
|
272
272
|
// Publish the hosted workspace to the GLOBAL chooser registry (keyed on the DAEMON's pid — the daemon is
|
|
273
273
|
// what keeps it live), so `lensmcp.local/` lists it. Reaped on unregister below.
|
|
274
|
-
(0, workspace_registry_1.registerWorkspace)({ key: fragment.wsKey, root: fragment.root, base: lensBase, pid: process.pid, updatedAt: new Date().toISOString() });
|
|
274
|
+
(0, workspace_registry_1.registerWorkspace)({ key: fragment.wsKey, root: fragment.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
|
|
275
275
|
console.log(`[gateway] hosting dashboard for workspace '${fragment.wsKey}' → http://localhost:${port} (routed at https://${lensHost}${lensBase}/)`);
|
|
276
276
|
emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${port}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port, base: lensBase });
|
|
277
277
|
spawnManagedChild(label, process.execPath, [bundle], {
|
|
@@ -1 +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,
|
|
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"}
|
|
@@ -196,7 +196,7 @@ function createServiceLayer(rt, obs) {
|
|
|
196
196
|
* its whole devserver tree with it. One `ps` exec; a clean cold start is a no-op.
|
|
197
197
|
*/
|
|
198
198
|
const reapForeignRunners = (svc, target) => {
|
|
199
|
-
let out
|
|
199
|
+
let out;
|
|
200
200
|
try {
|
|
201
201
|
out = (0, node_child_process_1.execFileSync)('ps', ['ax', '-ww', '-o', 'pid=,pgid=,command='], { encoding: 'utf8', timeout: 5_000 });
|
|
202
202
|
}
|
|
@@ -300,6 +300,11 @@ function createServiceLayer(rt, obs) {
|
|
|
300
300
|
NX_ISOLATE_PLUGINS: 'false',
|
|
301
301
|
...(svc.wsKey ? { LENSMCP_WS_KEY: svc.wsKey } : {}),
|
|
302
302
|
LENSMCP_EVENT_FILE: svc.wsKey === rt.wsKey ? rt.eventFile : path.join(svc.root, '.lensmcp', 'events.jsonl'),
|
|
303
|
+
// Rides the env inherit down the whole nx chain (nx run → run-executor → serve fork) into
|
|
304
|
+
// main.devserver, whose gateway watchdog self-exits the pod tree when this pid dies. Without
|
|
305
|
+
// it an UNGRACEFUL gateway death (crash/SIGKILL — no process-group kill runs) orphans the
|
|
306
|
+
// entire chain under launchd forever (2026-07-28: a day-old tetros pod tree at PPID 1).
|
|
307
|
+
LENSMCP_GATEWAY_PID: String(process.pid),
|
|
303
308
|
...eastWestEnv(rt, svc.project),
|
|
304
309
|
},
|
|
305
310
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/observability.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/observability.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAoB9C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,qBAAqB,CAAC;AAEzD,MAAM,WAAW,aAAa;IAC5B;;4GAEwG;IACxG,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvJ,GAAG,IAAI,MAAM,CAAC;IACd,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,OAAO,GAAG,SAAS,CAAC;IAC3D,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1F,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5G,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5E,0FAA0F;IAC1F,gBAAgB,IAAI,MAAM,CAAC,OAAO,CAAC;CACpC;AAKD,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAiHtG"}
|
|
@@ -17,6 +17,15 @@ const tslib_1 = require("tslib");
|
|
|
17
17
|
*/
|
|
18
18
|
const fs = tslib_1.__importStar(require("node:fs"));
|
|
19
19
|
const path = tslib_1.__importStar(require("node:path"));
|
|
20
|
+
const busHygiene = (() => {
|
|
21
|
+
try {
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
23
|
+
return require('@lensmcp/node-instrumentation');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
20
29
|
/**
|
|
21
30
|
* The lens's OWN dashboard route project (the value `lens-children.ts` gives the `lensmcp.local/<key>/`
|
|
22
31
|
* route). Traffic to it is the lens UI polling its own control-plane API (`/<key>/api/cluster|logs|snapshot`)
|
|
@@ -29,15 +38,28 @@ exports.LENS_DASHBOARD_PROJECT = '(lens-dashboard)';
|
|
|
29
38
|
const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
|
|
30
39
|
function createObservability(eventFile, opts) {
|
|
31
40
|
const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
|
|
41
|
+
// The gateway narrates OTHER apps' traffic — an app stuck in a retry loop must not turn that
|
|
42
|
+
// narration into a bus flood (2026-07-28: `gateway-request:<project>` alone wrote thousands of
|
|
43
|
+
// lines/min). Per-fingerprint token bucket: first burst passes, then a trickle carrying
|
|
44
|
+
// `floodSuppressed` counts. Errors stay loud — the burst re-arms once the storm subsides.
|
|
45
|
+
const floodGuard = busHygiene?.createEventFloodGuard({ burst: 30, perSec: 5 });
|
|
32
46
|
const emit = (severity, title, fingerprint, raw, flowCtx, busFile) => {
|
|
33
47
|
const target = busFile ?? eventFile; // a registered workspace's service events go to ITS bus, not the daemon's
|
|
34
48
|
try {
|
|
49
|
+
const admit = floodGuard ? floodGuard.admit(fingerprint) : { ok: true, suppressed: 0 };
|
|
50
|
+
if (!admit.ok)
|
|
51
|
+
return;
|
|
35
52
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
36
|
-
|
|
53
|
+
const line = JSON.stringify({
|
|
37
54
|
id: eid(), sessionId: 'pending', timestamp: Date.now(),
|
|
38
55
|
source: 'gateway', category: 'cluster', severity,
|
|
39
|
-
context: { sessionId: 'pending', ...flowCtx }, fingerprint, title,
|
|
40
|
-
|
|
56
|
+
context: { sessionId: 'pending', ...flowCtx }, fingerprint, title,
|
|
57
|
+
raw: admit.suppressed > 0 ? { ...raw, floodSuppressed: admit.suppressed } : raw,
|
|
58
|
+
}) + '\n';
|
|
59
|
+
// The gateway was the one bus writer with NO size cap — enforce the same rotation contract
|
|
60
|
+
// as every other producer (emit.ts / capture-runner), per target file.
|
|
61
|
+
busHygiene?.maybeRotateEventFile(target, line.length);
|
|
62
|
+
fs.appendFileSync(target, line);
|
|
41
63
|
}
|
|
42
64
|
catch { /* lens offline — never block traffic */ }
|
|
43
65
|
};
|
|
@@ -7,9 +7,23 @@ export interface WorkspaceEntry {
|
|
|
7
7
|
base: string;
|
|
8
8
|
/** The gateway process that registered this workspace (owns :443 for it). Liveness is checked via this. */
|
|
9
9
|
pid: number;
|
|
10
|
+
/**
|
|
11
|
+
* The registering process's start-time stamp (`ps -o lstart=`), captured at register. A pid alone
|
|
12
|
+
* can LIE: after an ungraceful gateway death the OS recycles the number, and `kill(pid, 0)` then
|
|
13
|
+
* vouches for a stranger (2026-07-28: tetros's dead-gateway pid came back as an nx run-executor and
|
|
14
|
+
* the chooser kept listing the workspace as live). Same pid + same start time = same process.
|
|
15
|
+
* Absent on legacy entries and on platforms without `ps` — liveness then falls back to kill(0).
|
|
16
|
+
*/
|
|
17
|
+
procStamp?: string;
|
|
10
18
|
/** ISO timestamp of the last register. */
|
|
11
19
|
updatedAt: string;
|
|
12
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The start-time identity stamp for a pid (`''` when `ps` is unavailable — Windows, restricted
|
|
23
|
+
* environments — or the process is gone). Start time is immutable for the life of a process and
|
|
24
|
+
* survives in no recycled successor, which makes it the cheapest cross-platform-ish identity proof.
|
|
25
|
+
*/
|
|
26
|
+
export declare function processStamp(pid: number): string;
|
|
13
27
|
/** `~/.lensmcp/workspaces.json` — override the home dir via `LENSMCP_HOME` (tests). */
|
|
14
28
|
export declare function registryPath(): string;
|
|
15
29
|
export declare function readWorkspaces(file?: string): WorkspaceEntry[];
|
|
@@ -17,6 +31,10 @@ export declare function readWorkspaces(file?: string): WorkspaceEntry[];
|
|
|
17
31
|
export declare function registerWorkspace(entry: WorkspaceEntry, file?: string): void;
|
|
18
32
|
/** Drop this workspace — called on a graceful gateway stop. */
|
|
19
33
|
export declare function unregisterWorkspace(key: string, file?: string): void;
|
|
20
|
-
/**
|
|
21
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if
|
|
36
|
+
* not — and when the entry carries a `procStamp`, the pid must ALSO still be the SAME process
|
|
37
|
+
* (recycled pids fail the start-time comparison instead of impersonating a live gateway).
|
|
38
|
+
*/
|
|
39
|
+
export declare function isWorkspaceLive(entry: Pick<WorkspaceEntry, 'pid' | 'procStamp'>): boolean;
|
|
22
40
|
//# sourceMappingURL=workspace-registry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-registry.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/workspace-registry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"workspace-registry.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/workspace-registry.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC7B,4FAA4F;IAC5F,GAAG,EAAE,MAAM,CAAC;IACZ,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,2GAA2G;IAC3G,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWhD;AAED,uFAAuF;AACvF,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,cAAc,CAAC,IAAI,SAAiB,GAAG,cAAc,EAAE,CAYtE;AAWD,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,EAAE,IAAI,SAAiB,GAAG,IAAI,CAKpF;AAED,+DAA+D;AAC/D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,SAAiB,GAAG,IAAI,CAK5E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,WAAW,CAAC,GAAG,OAAO,CAUzF"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.processStamp = processStamp;
|
|
3
4
|
exports.registryPath = registryPath;
|
|
4
5
|
exports.readWorkspaces = readWorkspaces;
|
|
5
6
|
exports.registerWorkspace = registerWorkspace;
|
|
@@ -16,9 +17,29 @@ const tslib_1 = require("tslib");
|
|
|
16
17
|
* Concurrency: read-modify-write, best-effort. Writes are rare (start/stop only) and the file is tiny, so
|
|
17
18
|
* a lost update at most drops one entry from the listing until the next start — never a correctness issue.
|
|
18
19
|
*/
|
|
20
|
+
const node_child_process_1 = require("node:child_process");
|
|
19
21
|
const fs = tslib_1.__importStar(require("node:fs"));
|
|
20
22
|
const os = tslib_1.__importStar(require("node:os"));
|
|
21
23
|
const path = tslib_1.__importStar(require("node:path"));
|
|
24
|
+
/**
|
|
25
|
+
* The start-time identity stamp for a pid (`''` when `ps` is unavailable — Windows, restricted
|
|
26
|
+
* environments — or the process is gone). Start time is immutable for the life of a process and
|
|
27
|
+
* survives in no recycled successor, which makes it the cheapest cross-platform-ish identity proof.
|
|
28
|
+
*/
|
|
29
|
+
function processStamp(pid) {
|
|
30
|
+
if (!pid || pid <= 0 || process.platform === 'win32')
|
|
31
|
+
return '';
|
|
32
|
+
try {
|
|
33
|
+
return (0, node_child_process_1.execFileSync)('ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: 2_000,
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
}).trim();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
22
43
|
/** `~/.lensmcp/workspaces.json` — override the home dir via `LENSMCP_HOME` (tests). */
|
|
23
44
|
function registryPath() {
|
|
24
45
|
const home = process.env['LENSMCP_HOME'] || os.homedir();
|
|
@@ -56,15 +77,23 @@ function registerWorkspace(entry, file = registryPath()) {
|
|
|
56
77
|
function unregisterWorkspace(key, file = registryPath()) {
|
|
57
78
|
writeWorkspaces(readWorkspaces(file).filter((e) => e.key !== key), file);
|
|
58
79
|
}
|
|
59
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Is the gateway that registered this workspace still alive? `process.kill(pid, 0)` throws ESRCH if
|
|
82
|
+
* not — and when the entry carries a `procStamp`, the pid must ALSO still be the SAME process
|
|
83
|
+
* (recycled pids fail the start-time comparison instead of impersonating a live gateway).
|
|
84
|
+
*/
|
|
60
85
|
function isWorkspaceLive(entry) {
|
|
61
86
|
if (!entry.pid || entry.pid <= 0)
|
|
62
87
|
return false;
|
|
63
88
|
try {
|
|
64
89
|
process.kill(entry.pid, 0);
|
|
65
|
-
return true; // alive (or EPERM below — exists but not ours)
|
|
66
90
|
}
|
|
67
91
|
catch (e) {
|
|
68
|
-
|
|
92
|
+
if (e.code !== 'EPERM')
|
|
93
|
+
return false; // EPERM = exists but not signalable; ESRCH = gone
|
|
69
94
|
}
|
|
95
|
+
if (!entry.procStamp)
|
|
96
|
+
return true; // legacy entry / no-ps platform — pid liveness is all we have
|
|
97
|
+
const now = processStamp(entry.pid);
|
|
98
|
+
return now === '' ? true : now === entry.procStamp; // '' = ps unavailable here — don't false-negative
|
|
70
99
|
}
|
package/main.devserver.js
CHANGED
|
@@ -1090,5 +1090,28 @@ else {
|
|
|
1090
1090
|
setTimeout(() => process.exit(0), 700).unref();
|
|
1091
1091
|
};
|
|
1092
1092
|
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => process.on(sig, () => void shutdown(sig)));
|
|
1093
|
+
// Gateway watchdog: the gateway kills pods via its spawned process group, but an UNGRACEFUL
|
|
1094
|
+
// gateway death (crash/SIGKILL) never runs that kill — the whole nx run → run-executor →
|
|
1095
|
+
// devserver chain reparents to launchd and squats its ports/sockets forever (the 2026-07-28
|
|
1096
|
+
// day-old orphan tree). We are layers below the gateway, so a ppid check can't see it — poll
|
|
1097
|
+
// the stamped pid instead and tear down through the SAME graceful path when it's gone. The
|
|
1098
|
+
// chain above us unwinds as our exit propagates. A pod run OUTSIDE a gateway (`nx serve`
|
|
1099
|
+
// directly) has no LENSMCP_GATEWAY_PID and is untouched.
|
|
1100
|
+
const gatewayPid = Number(process.env.LENSMCP_GATEWAY_PID ?? 0);
|
|
1101
|
+
if (Number.isInteger(gatewayPid) && gatewayPid > 0) {
|
|
1102
|
+
const watchdog = setInterval(() => {
|
|
1103
|
+
try {
|
|
1104
|
+
process.kill(gatewayPid, 0);
|
|
1105
|
+
}
|
|
1106
|
+
catch (e) {
|
|
1107
|
+
if (e.code === 'EPERM')
|
|
1108
|
+
return; // exists, just not signalable
|
|
1109
|
+
clearInterval(watchdog);
|
|
1110
|
+
console.error(`[parent] gateway (pid ${gatewayPid}) is gone — shutting the pod down to avoid an orphan tree.`);
|
|
1111
|
+
void shutdown('gateway-gone');
|
|
1112
|
+
}
|
|
1113
|
+
}, 5_000);
|
|
1114
|
+
watchdog.unref(); // never keeps an otherwise-done parent alive
|
|
1115
|
+
}
|
|
1093
1116
|
})();
|
|
1094
1117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.31",
|
|
4
4
|
"description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@lensmcp/node-instrumentation": "1.16.
|
|
69
|
-
"@lensmcp/nx-plugin": "1.16.
|
|
68
|
+
"@lensmcp/node-instrumentation": "1.16.31",
|
|
69
|
+
"@lensmcp/nx-plugin": "1.16.31",
|
|
70
70
|
"fork-ts-checker-webpack-plugin": "^9.0.0",
|
|
71
71
|
"glob": "^11.0.0",
|
|
72
72
|
"http-proxy": "^1.18.0",
|