@lensmcp/cluster 1.18.3 → 1.18.6
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/basic-ssl.d.ts +0 -1
- package/basic-ssl.js +1 -241
- package/build-scope-patterns.d.ts +0 -1
- package/build-scope-patterns.js +1 -40
- package/create-webpack-dev.d.ts +0 -1
- package/create-webpack-dev.js +1 -186
- package/create-webpack-prod.d.ts +0 -1
- package/create-webpack-prod.js +1 -169
- package/executors/build/build.impl.d.ts +0 -1
- package/executors/build/build.impl.js +1 -98
- package/executors/gateway/gateway-errors.d.ts +0 -1
- package/executors/gateway/gateway-errors.js +1 -43
- package/executors/gateway/gateway.impl.d.ts +0 -1
- package/executors/gateway/gateway.impl.js +1 -53
- package/executors/gateway/gateway.lib.d.ts +0 -1
- package/executors/gateway/gateway.lib.js +1 -29
- package/executors/gateway/health-check.d.ts +0 -1
- package/executors/gateway/health-check.js +1 -66
- package/executors/gateway/jwks-verify.d.ts +0 -1
- package/executors/gateway/jwks-verify.js +1 -121
- package/executors/gateway/main.prod-gateway.d.ts +0 -1
- package/executors/gateway/main.prod-gateway.js +2 -573
- package/executors/gateway/main.rollout.d.ts +0 -1
- package/executors/gateway/main.rollout.js +11 -117
- package/executors/gateway/manifest.d.ts +0 -1
- package/executors/gateway/manifest.js +1 -374
- package/executors/gateway/metrics.d.ts +0 -1
- package/executors/gateway/metrics.js +1 -56
- package/executors/gateway/otel-tracing.d.ts +0 -1
- package/executors/gateway/otel-tracing.js +1 -74
- package/executors/gateway/prod-gateway.lib.d.ts +0 -1
- package/executors/gateway/prod-gateway.lib.js +1 -22
- package/executors/gateway/prod-runtime/access-log.d.ts +0 -1
- package/executors/gateway/prod-runtime/access-log.js +1 -24
- package/executors/gateway/prod-runtime/app.d.ts +0 -1
- package/executors/gateway/prod-runtime/app.js +1 -123
- package/executors/gateway/prod-runtime/auth.d.ts +0 -1
- package/executors/gateway/prod-runtime/auth.js +1 -51
- package/executors/gateway/prod-runtime/cors.d.ts +0 -1
- package/executors/gateway/prod-runtime/cors.js +1 -40
- package/executors/gateway/prod-runtime/edge.d.ts +0 -1
- package/executors/gateway/prod-runtime/edge.js +1 -65
- package/executors/gateway/prod-runtime/handler.d.ts +0 -1
- package/executors/gateway/prod-runtime/handler.js +1 -226
- package/executors/gateway/prod-runtime/hooks.d.ts +0 -1
- package/executors/gateway/prod-runtime/hooks.js +1 -42
- package/executors/gateway/prod-runtime/observability.d.ts +0 -1
- package/executors/gateway/prod-runtime/observability.js +1 -125
- package/executors/gateway/prod-runtime/rollout.d.ts +0 -1
- package/executors/gateway/prod-runtime/rollout.js +1 -103
- package/executors/gateway/prod-runtime/routing.d.ts +0 -1
- package/executors/gateway/prod-runtime/routing.js +1 -40
- package/executors/gateway/prod-runtime/server.d.ts +0 -1
- package/executors/gateway/prod-runtime/server.js +1 -79
- package/executors/gateway/prod-runtime/trust.d.ts +0 -1
- package/executors/gateway/prod-runtime/trust.js +1 -32
- package/executors/gateway/prod-runtime/types.d.ts +0 -1
- package/executors/gateway/prod-runtime/types.js +1 -2
- package/executors/gateway/prod-runtime/upgrade.d.ts +0 -1
- package/executors/gateway/prod-runtime/upgrade.js +4 -116
- package/executors/gateway/prod-runtime/upstream.d.ts +0 -1
- package/executors/gateway/prod-runtime/upstream.js +1 -21
- package/executors/gateway/providers-prod.d.ts +0 -1
- package/executors/gateway/providers-prod.js +1 -232
- package/executors/gateway/rate-limit.d.ts +0 -1
- package/executors/gateway/rate-limit.js +2 -75
- package/executors/gateway/registry-source.d.ts +0 -1
- package/executors/gateway/registry-source.js +1 -131
- package/executors/gateway/rollout-ops.d.ts +0 -1
- package/executors/gateway/rollout-ops.js +2 -167
- package/executors/gateway/runtime/auth.d.ts +0 -1
- package/executors/gateway/runtime/auth.js +1 -64
- package/executors/gateway/runtime/chooser.d.ts +0 -1
- package/executors/gateway/runtime/chooser.js +12 -45
- package/executors/gateway/runtime/control.d.ts +0 -1
- package/executors/gateway/runtime/control.js +1 -128
- package/executors/gateway/runtime/dev-auth.d.ts +0 -1
- package/executors/gateway/runtime/dev-auth.js +1 -108
- package/executors/gateway/runtime/discovery.d.ts +0 -1
- package/executors/gateway/runtime/discovery.js +1 -123
- package/executors/gateway/runtime/edge.d.ts +0 -1
- package/executors/gateway/runtime/edge.js +1 -47
- package/executors/gateway/runtime/handler.d.ts +0 -1
- package/executors/gateway/runtime/handler.js +1 -183
- package/executors/gateway/runtime/hooks.d.ts +0 -1
- package/executors/gateway/runtime/hooks.js +1 -55
- package/executors/gateway/runtime/lens-children.d.ts +0 -1
- package/executors/gateway/runtime/lens-children.js +1 -651
- package/executors/gateway/runtime/lifecycle.d.ts +0 -1
- package/executors/gateway/runtime/lifecycle.js +3 -842
- package/executors/gateway/runtime/observability.d.ts +0 -1
- package/executors/gateway/runtime/observability.js +2 -148
- package/executors/gateway/runtime/pod-env.d.ts +0 -1
- package/executors/gateway/runtime/pod-env.js +2 -89
- package/executors/gateway/runtime/proxy.d.ts +0 -1
- package/executors/gateway/runtime/proxy.js +1 -457
- package/executors/gateway/runtime/route-registry.d.ts +0 -1
- package/executors/gateway/runtime/route-registry.js +1 -72
- package/executors/gateway/runtime/scope.d.ts +0 -1
- package/executors/gateway/runtime/scope.js +1 -117
- package/executors/gateway/runtime/server.d.ts +0 -1
- package/executors/gateway/runtime/server.js +3 -487
- package/executors/gateway/runtime/service-keys.d.ts +0 -1
- package/executors/gateway/runtime/service-keys.js +1 -49
- package/executors/gateway/runtime/types.d.ts +0 -1
- package/executors/gateway/runtime/types.js +1 -151
- package/executors/gateway/runtime/upgrade.d.ts +0 -1
- package/executors/gateway/runtime/upgrade.js +1 -71
- package/executors/gateway/runtime/workspace-registry.d.ts +0 -1
- package/executors/gateway/runtime/workspace-registry.js +1 -99
- package/executors/gateway/ssrf-guard.d.ts +0 -1
- package/executors/gateway/ssrf-guard.js +1 -190
- package/executors/serve/serve.impl.d.ts +0 -1
- package/executors/serve/serve.impl.js +1 -280
- package/executors/trust/trust.impl.d.ts +0 -1
- package/executors/trust/trust.impl.js +4 -162
- package/gateway.d.ts +0 -1
- package/gateway.js +1 -35
- package/index.d.ts +0 -1
- package/index.js +1 -16
- package/main.devserver.d.ts +0 -1
- package/main.devserver.js +10 -1117
- package/package.json +4 -3
- package/tsgo-check-plugin.d.ts +0 -1
- package/tsgo-check-plugin.js +4 -364
- package/typecheck-bus.d.ts +0 -1
- package/typecheck-bus.js +4 -256
- package/basic-ssl.d.ts.map +0 -1
- package/build-scope-patterns.d.ts.map +0 -1
- package/create-webpack-dev.d.ts.map +0 -1
- package/create-webpack-prod.d.ts.map +0 -1
- package/executors/build/build.impl.d.ts.map +0 -1
- package/executors/gateway/gateway-errors.d.ts.map +0 -1
- package/executors/gateway/gateway.impl.d.ts.map +0 -1
- package/executors/gateway/gateway.lib.d.ts.map +0 -1
- package/executors/gateway/health-check.d.ts.map +0 -1
- package/executors/gateway/jwks-verify.d.ts.map +0 -1
- package/executors/gateway/main.prod-gateway.d.ts.map +0 -1
- package/executors/gateway/main.rollout.d.ts.map +0 -1
- package/executors/gateway/manifest.d.ts.map +0 -1
- package/executors/gateway/metrics.d.ts.map +0 -1
- package/executors/gateway/otel-tracing.d.ts.map +0 -1
- package/executors/gateway/prod-gateway.lib.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/access-log.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/app.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/auth.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/cors.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/edge.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/handler.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/hooks.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/observability.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/rollout.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/routing.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/server.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/trust.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/types.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/upgrade.d.ts.map +0 -1
- package/executors/gateway/prod-runtime/upstream.d.ts.map +0 -1
- package/executors/gateway/providers-prod.d.ts.map +0 -1
- package/executors/gateway/rate-limit.d.ts.map +0 -1
- package/executors/gateway/registry-source.d.ts.map +0 -1
- package/executors/gateway/rollout-ops.d.ts.map +0 -1
- package/executors/gateway/runtime/auth.d.ts.map +0 -1
- package/executors/gateway/runtime/chooser.d.ts.map +0 -1
- package/executors/gateway/runtime/control.d.ts.map +0 -1
- package/executors/gateway/runtime/dev-auth.d.ts.map +0 -1
- package/executors/gateway/runtime/discovery.d.ts.map +0 -1
- package/executors/gateway/runtime/edge.d.ts.map +0 -1
- package/executors/gateway/runtime/handler.d.ts.map +0 -1
- package/executors/gateway/runtime/hooks.d.ts.map +0 -1
- package/executors/gateway/runtime/lens-children.d.ts.map +0 -1
- package/executors/gateway/runtime/lifecycle.d.ts.map +0 -1
- package/executors/gateway/runtime/observability.d.ts.map +0 -1
- package/executors/gateway/runtime/pod-env.d.ts.map +0 -1
- package/executors/gateway/runtime/proxy.d.ts.map +0 -1
- package/executors/gateway/runtime/route-registry.d.ts.map +0 -1
- package/executors/gateway/runtime/scope.d.ts.map +0 -1
- package/executors/gateway/runtime/server.d.ts.map +0 -1
- package/executors/gateway/runtime/service-keys.d.ts.map +0 -1
- package/executors/gateway/runtime/types.d.ts.map +0 -1
- package/executors/gateway/runtime/upgrade.d.ts.map +0 -1
- package/executors/gateway/runtime/workspace-registry.d.ts.map +0 -1
- package/executors/gateway/ssrf-guard.d.ts.map +0 -1
- package/executors/serve/serve.impl.d.ts.map +0 -1
- package/executors/trust/trust.impl.d.ts.map +0 -1
- package/gateway.d.ts.map +0 -1
- package/index.d.ts.map +0 -1
- package/main.devserver.d.ts.map +0 -1
- package/tsgo-check-plugin.d.ts.map +0 -1
- package/typecheck-bus.d.ts.map +0 -1
|
@@ -1,842 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
exports.sourceScopeDirs = sourceScopeDirs;
|
|
5
|
-
exports.podRecycleReason = podRecycleReason;
|
|
6
|
-
exports.lensFrontendStale = lensFrontendStale;
|
|
7
|
-
exports.lensChildWedged = lensChildWedged;
|
|
8
|
-
exports.isPortConflictExit = isPortConflictExit;
|
|
9
|
-
exports.createServiceLayer = createServiceLayer;
|
|
10
|
-
const tslib_1 = require("tslib");
|
|
11
|
-
/**
|
|
12
|
-
* Service lifecycle: scale-from-zero, autoscale, idle kill. The gateway is the
|
|
13
|
-
* load balancer in front of each pod service's unix-socket pool and owns the
|
|
14
|
-
* pods' serverless lifecycle (eager/minPods/maxPods/idleKillSec).
|
|
15
|
-
*/
|
|
16
|
-
const node_child_process_1 = require("node:child_process");
|
|
17
|
-
const fs = tslib_1.__importStar(require("node:fs"));
|
|
18
|
-
const http = tslib_1.__importStar(require("node:http"));
|
|
19
|
-
const path = tslib_1.__importStar(require("node:path"));
|
|
20
|
-
const node_util_1 = require("node:util");
|
|
21
|
-
const discovery_1 = require("./discovery");
|
|
22
|
-
const scope_1 = require("./scope");
|
|
23
|
-
const pod_env_1 = require("./pod-env");
|
|
24
|
-
const types_1 = require("./types");
|
|
25
|
-
/**
|
|
26
|
-
* The east-west env a pod needs to call ANOTHER service's `internal.<host>` route (memberships, settings,
|
|
27
|
-
* plan): (1) its OWN per-service key — presented as `x-api-key`; the gateway maps it back to the caller and
|
|
28
|
-
* stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the
|
|
29
|
-
* MACHINE-LEVEL dev CA (`~/.lensmcp/ca`), so the pod's OUTBOUND `fetch('https://internal.<host>')` clears
|
|
30
|
-
* TLS — and keeps clearing it across daemon handovers, since every daemon on the machine signs with that
|
|
31
|
-
* same anchor (a per-workspace path here once broke pods that outlived a guest→own-daemon switch). Without
|
|
32
|
-
* these the call fails (connect/TLS or a gateway 401) and tenant-token minting can't resolve the role.
|
|
33
|
-
* NOT `INTERNAL_API_KEY`/`LENSMCP_INTERNAL_TOKEN` (those would flip ON the inbound gateway-trust guard) —
|
|
34
|
-
* a DEDICATED name a service opts into for OUTBOUND only, leaving inbound dev-standalone behavior unchanged.
|
|
35
|
-
*/
|
|
36
|
-
function eastWestEnv(rt, project) {
|
|
37
|
-
const env = {};
|
|
38
|
-
const key = rt.serviceKeys[project];
|
|
39
|
-
if (key)
|
|
40
|
-
env.LENSMCP_SERVICE_KEY = key;
|
|
41
|
-
if (rt.https) {
|
|
42
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy like server.ts (cert deps)
|
|
43
|
-
const basicSsl = require('../../../basic-ssl');
|
|
44
|
-
const caPath = basicSsl.caCertPath();
|
|
45
|
-
if (fs.existsSync(caPath))
|
|
46
|
-
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
47
|
-
}
|
|
48
|
-
return env;
|
|
49
|
-
}
|
|
50
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
51
|
-
// --- zombie-pod recycle: source-file-SET signature -------------------------------------------------------
|
|
52
|
-
// A pod goes "stale" when the set of source files CHANGES (a file added/removed) after it spawned and its
|
|
53
|
-
// degraded watcher missed it. We detect a set change — NOT a content change — by a cheap order-independent
|
|
54
|
-
// signature over source file PATHS (count + xor of per-path hashes): adding/removing a path flips the
|
|
55
|
-
// signature; editing a file's CONTENTS does not, so ordinary saves never trigger a recycle.
|
|
56
|
-
// RUNTIME OUTPUT IS NOT SOURCE. The hardcoded list below can only ever name the conventional output dirs;
|
|
57
|
-
// a workspace whose app WRITES into some other gitignored dir under the root would otherwise read every
|
|
58
|
-
// write as "a dev added a source file" and recycle every pod. Worked failure (foodguard, 2026-07-28): the
|
|
59
|
-
// engine's local artifact store defaults to `./artifacts` at the workspace root, so each run's
|
|
60
|
-
// `publish_pack` added pack-manifest + source-snapshot `.json` → the set flipped → the sweeper SIGTERMed all
|
|
61
|
-
// four vite pods → a ~12-18s `gateway.unavailable` 502 window per run (201 dashboard recycles in one
|
|
62
|
-
// session, and it crossed workspaces: the sweeper's viteChildren span every REGISTERED workspace while the
|
|
63
|
-
// scan only walks the daemon host's root). So the set is filtered by git's OWN ignore verdict too.
|
|
64
|
-
const SRC_EXCLUDE = new Set(['node_modules', 'dist', 'tmp', 'coverage', 'build', 'out', 'target']);
|
|
65
|
-
const SRC_EXT = new Set([
|
|
66
|
-
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.sass', '.less',
|
|
67
|
-
'.json', '.html', '.htm', '.vue', '.svelte', '.mdx',
|
|
68
|
-
]);
|
|
69
|
-
const SCAN_FILE_CAP = 100_000;
|
|
70
|
-
function fnv1a(s) {
|
|
71
|
-
let h = 2166136261;
|
|
72
|
-
for (let i = 0; i < s.length; i += 1) {
|
|
73
|
-
h ^= s.charCodeAt(i);
|
|
74
|
-
h = Math.imul(h, 16777619);
|
|
75
|
-
}
|
|
76
|
-
return h >>> 0;
|
|
77
|
-
}
|
|
78
|
-
const execFileP = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
79
|
-
/** The repo-relative paths git IGNORES — directories carry a trailing `/` (git's `--directory` collapses a
|
|
80
|
-
* wholly-ignored dir), files do not. ONE `git ls-files` per scan (~80ms on a 2.6k-file workspace, off the
|
|
81
|
-
* event loop) buys exact gitignore semantics — nested `.gitignore`, `.git/info/exclude`, global excludes —
|
|
82
|
-
* with no new dependency and no hand-rolled glob matcher. A partially-ignored dir isn't collapsed, so its
|
|
83
|
-
* individual ignored files are listed and filtered per-file instead. Fails OPEN to an empty set (git absent,
|
|
84
|
-
* not a repo, timeout) → the hardcoded `SRC_EXCLUDE` list alone still applies, exactly as before. */
|
|
85
|
-
async function gitIgnoredPaths(root) {
|
|
86
|
-
try {
|
|
87
|
-
const { stdout } = await execFileP('git', ['ls-files', '--others', '--directory', '--ignored', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 32 * 1024 * 1024, timeout: 10_000, windowsHide: true });
|
|
88
|
-
return new Set(stdout.split('\0').filter(Boolean)); // -z → NUL-separated, never git's quoted-path form
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
return new Set();
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
/** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
|
|
95
|
-
* (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
|
|
96
|
-
* GITIGNORED path (runtime output is not source — see `SRC_EXCLUDE`), and only counts source-extension
|
|
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) {
|
|
104
|
-
let count = 0;
|
|
105
|
-
let xor = 0;
|
|
106
|
-
const ignored = await gitIgnoredPaths(root);
|
|
107
|
-
// git speaks posix-relative; `path.relative` yields `\` on win32 — normalize before every lookup.
|
|
108
|
-
const rel = (abs) => path.relative(root, abs).split(path.sep).join('/');
|
|
109
|
-
const walk = async (dir, recurse = true) => {
|
|
110
|
-
if (count > SCAN_FILE_CAP)
|
|
111
|
-
return;
|
|
112
|
-
let entries;
|
|
113
|
-
try {
|
|
114
|
-
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
for (const e of entries) {
|
|
120
|
-
if (count > SCAN_FILE_CAP)
|
|
121
|
-
return;
|
|
122
|
-
const name = e.name;
|
|
123
|
-
const abs = path.join(dir, name);
|
|
124
|
-
if (e.isDirectory()) {
|
|
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'))
|
|
128
|
-
continue;
|
|
129
|
-
if (ignored.has(`${rel(abs)}/`))
|
|
130
|
-
continue; // a gitignored output dir (foodguard's `artifacts/`)
|
|
131
|
-
await walk(abs);
|
|
132
|
-
}
|
|
133
|
-
else if (e.isFile()) {
|
|
134
|
-
const dot = name.lastIndexOf('.');
|
|
135
|
-
if (dot <= 0 || !SRC_EXT.has(name.slice(dot)))
|
|
136
|
-
continue;
|
|
137
|
-
if (ignored.has(rel(abs)))
|
|
138
|
-
continue; // a gitignored file in a tracked dir (generated reports)
|
|
139
|
-
count += 1;
|
|
140
|
-
xor ^= fnv1a(abs);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
};
|
|
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
|
-
}
|
|
154
|
-
return `${count}:${(xor >>> 0).toString(36)}`;
|
|
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
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
|
|
196
|
-
* is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
|
|
197
|
-
* pod is never interrupted) and past its per-service cooldown. Then it recycles if the source file SET
|
|
198
|
-
* changed after it spawned (`stale-source-set`) or as a plain age backstop (`max-age`). Returns `null` when
|
|
199
|
-
* it must be left alone. Kept pure (no `ServiceCtl` mutation, no fs) so the sweeper's logic is unit-tested
|
|
200
|
-
* without spawning a process. `sourceSetChangedAt` is 0 until the gateway first observes a set change.
|
|
201
|
-
*/
|
|
202
|
-
function podRecycleReason(svc, now, sourceSetChangedAt, cfg) {
|
|
203
|
-
if (!svc.proc || svc.state !== 'up' || svc.spawnAt == null)
|
|
204
|
-
return null; // only a live, up, spawned pod
|
|
205
|
-
if (svc.inflight > 0)
|
|
206
|
-
return null; // never mid-request
|
|
207
|
-
if (now - svc.lastUsed < cfg.settleMs)
|
|
208
|
-
return null; // wait for an inter-request lull
|
|
209
|
-
if (svc.lastStaleRecycleAt != null && now - svc.lastStaleRecycleAt < cfg.cooldownMs)
|
|
210
|
-
return null; // anti-thrash
|
|
211
|
-
if (sourceSetChangedAt > 0 && sourceSetChangedAt - svc.spawnAt > cfg.graceMs)
|
|
212
|
-
return 'stale-source-set';
|
|
213
|
-
if (cfg.maxAgeMs > 0 && now - svc.spawnAt > cfg.maxAgeMs)
|
|
214
|
-
return 'max-age';
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* PURE decision for a LENS-FRONTEND vite child (a `lens:true` app's dev server — the reported zombie). It
|
|
219
|
-
* has no in-flight/idle signal like a pod, so it recycles ONLY on a settled source-file-SET change that
|
|
220
|
-
* POST-dates its spawn (the precise "watcher missed a new file" trigger) — never a blind max-age (which
|
|
221
|
-
* could interrupt active work). `killing` it lets the manager's `on('exit')` auto-heal respawn a fresh vite
|
|
222
|
-
* that re-scans the FS. Kept pure + exported so the recycle logic is unit-tested without a process.
|
|
223
|
-
*/
|
|
224
|
-
function lensFrontendStale(spawnedAt, lastRecycleAt, sourceSetChangedAt, now, cfg) {
|
|
225
|
-
if (now - lastRecycleAt < cfg.cooldownMs)
|
|
226
|
-
return false; // anti-thrash on a source-churn burst
|
|
227
|
-
if (sourceSetChangedAt <= 0)
|
|
228
|
-
return false; // no set change observed yet
|
|
229
|
-
if (sourceSetChangedAt - spawnedAt <= cfg.graceMs)
|
|
230
|
-
return false; // this vite already postdates the change
|
|
231
|
-
if (now - sourceSetChangedAt < cfg.settleMs)
|
|
232
|
-
return false; // let an add/remove burst settle → one recycle
|
|
233
|
-
return true;
|
|
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
|
-
}
|
|
285
|
-
function createServiceLayer(rt, obs) {
|
|
286
|
-
const { emit } = obs;
|
|
287
|
-
// Route a service's lifecycle events (starting/up/down/cold-start/…) to ITS OWNING workspace's bus —
|
|
288
|
-
// mirrors the pod runtime-event routing below (svc.wsKey === rt.wsKey ? rt.eventFile : <root>/.lensmcp).
|
|
289
|
-
// The daemon's own services stay on rt.eventFile; a REGISTERED workspace's go to its own bus, so each
|
|
290
|
-
// hosted dashboard's cluster view (/<key>/api/cluster) is scoped to ITS workspace, not the daemon's
|
|
291
|
-
// (foodguard/cluster was empty because every service event went to the daemon's tetros bus). Two
|
|
292
|
-
// workspaces can share a service NAME (both have `auth`), so this MUST key off svc.wsKey, not the project.
|
|
293
|
-
const serviceBus = (svc) => svc.wsKey === rt.wsKey ? undefined : path.join(svc.root, '.lensmcp', 'events.jsonl');
|
|
294
|
-
const emitSvc = (svc, severity, title, fingerprint, raw) => emit(severity, title, fingerprint, { wsKey: svc.wsKey, ...raw }, undefined, serviceBus(svc));
|
|
295
|
-
const scanNow = (pool) => { pool.scannedAt = 0; return (0, discovery_1.pickSock)(pool, rt.scanTtlMs) ? pool.socks.length : 0; };
|
|
296
|
-
/**
|
|
297
|
-
* FOREIGN-GENERATION REAPER (the respawn deadlock, foodguard 2026-07-26): nx records a
|
|
298
|
-
* running continuous task in its workspace-data DB, and a NEW `nx run <svc>:serve-hmr`
|
|
299
|
-
* invocation WAITS on that record ("Waiting for <task> in another nx process") — even with
|
|
300
|
-
* NX_DAEMON=false. When the record's holder is an ORPHANED runner (a replaced daemon's
|
|
301
|
-
* tree, a half-killed generation), every respawn parks forever and `ensureUp` hangs each
|
|
302
|
-
* request with no log. Before spawning, kill any nx runner for THIS target that this
|
|
303
|
-
* gateway does not own — the runner is the detached group leader, so `kill(-pgid)` reaps
|
|
304
|
-
* its whole devserver tree with it. One `ps` exec; a clean cold start is a no-op.
|
|
305
|
-
*/
|
|
306
|
-
const reapForeignRunners = (svc, target) => {
|
|
307
|
-
let out;
|
|
308
|
-
try {
|
|
309
|
-
out = (0, node_child_process_1.execFileSync)('ps', ['ax', '-ww', '-o', 'pid=,pgid=,command='], { encoding: 'utf8', timeout: 5_000 });
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
return 0;
|
|
313
|
-
}
|
|
314
|
-
const ownPid = svc.proc?.pid;
|
|
315
|
-
let reaped = 0;
|
|
316
|
-
for (const line of out.split('\n')) {
|
|
317
|
-
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
318
|
-
if (!m)
|
|
319
|
-
continue;
|
|
320
|
-
const pid = Number(m[1]);
|
|
321
|
-
const pgid = Number(m[2]);
|
|
322
|
-
const cmd = m[3];
|
|
323
|
-
if (!cmd.includes(`run ${target}`))
|
|
324
|
-
continue; // only THIS service's runners
|
|
325
|
-
// Only nx runners (direct nx.js entry or the yarn/npx shim) — never a stray grep/editor.
|
|
326
|
-
if (!/\bnx(\.js)?\b/.test(cmd))
|
|
327
|
-
continue;
|
|
328
|
-
// A direct-entry runner carries its workspace's nx path — scope to OUR workspace root so a
|
|
329
|
-
// same-named project in another workspace is never touched. (The yarn-shim fallback has no
|
|
330
|
-
// path in argv; the target match alone bounds it.)
|
|
331
|
-
if (cmd.includes('/node_modules/nx/') && !cmd.includes(svc.root))
|
|
332
|
-
continue;
|
|
333
|
-
if (pid === process.pid || pid === ownPid)
|
|
334
|
-
continue;
|
|
335
|
-
if (ownPid !== undefined && pgid === ownPid)
|
|
336
|
-
continue; // our own detached tree
|
|
337
|
-
try {
|
|
338
|
-
process.kill(-pgid, 'SIGKILL');
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
try {
|
|
342
|
-
process.kill(pid, 'SIGKILL');
|
|
343
|
-
}
|
|
344
|
-
catch { /* already gone */ }
|
|
345
|
-
}
|
|
346
|
-
reaped += 1;
|
|
347
|
-
}
|
|
348
|
-
if (reaped > 0) {
|
|
349
|
-
console.log(`[gateway] ${target}: reaped ${reaped} stale runner tree(s) — a foreign generation held the nx task lock`);
|
|
350
|
-
emitSvc(svc, 'warning', `stale-runner reap: ${svc.project} (${reaped} foreign tree(s))`, `cluster-service:${svc.project}`, { kind: 'stale-runner-reap', project: svc.project, reaped });
|
|
351
|
-
}
|
|
352
|
-
return reaped;
|
|
353
|
-
};
|
|
354
|
-
const spawnService = (svc) => {
|
|
355
|
-
if (rt.stopped())
|
|
356
|
-
return; // never spawn a process after the gateway is told to stop
|
|
357
|
-
const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
|
|
358
|
-
const min = Math.max(1, svc.decl.minPods ?? 1);
|
|
359
|
-
reapForeignRunners(svc, target); // clear nx's running-tasks lock BEFORE the spawn can park on it
|
|
360
|
-
svc.lockBlockedAt = undefined;
|
|
361
|
-
console.log(`[gateway] starting ${target} (pods=${min})…`);
|
|
362
|
-
emitSvc(svc, 'info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
|
|
363
|
-
svc.state = 'starting';
|
|
364
|
-
svc.spawnAt = Date.now();
|
|
365
|
-
// childCount goes as a CLI option, NOT env: nx executes tasks via its
|
|
366
|
-
// daemon, whose environment is not the gateway's — env vars get lost.
|
|
367
|
-
// Invoke the workspace's nx entry DIRECTLY (node + nx/dist/bin/nx.js) instead of through the
|
|
368
|
-
// `yarn nx` shim: the shim is a ~40MB node process that does nothing after the handoff yet
|
|
369
|
-
// lives for the service's whole life — ~600MB across a 15-service cluster. Resolved per
|
|
370
|
-
// svc.root so each hosted workspace runs its own nx; falls back to `yarn nx` when the entry
|
|
371
|
-
// isn't where node_modules linkers put it (pnp/odd layouts).
|
|
372
|
-
const nxEntry = path.join(svc.root, 'node_modules', 'nx', 'dist', 'bin', 'nx.js');
|
|
373
|
-
const [spawnCmd, spawnArgs] = fs.existsSync(nxEntry)
|
|
374
|
-
? [process.execPath, [nxEntry, 'run', target, `--childCount=${min}`]]
|
|
375
|
-
: ['yarn', ['nx', 'run', target, `--childCount=${min}`]];
|
|
376
|
-
const proc = (0, node_child_process_1.spawn)(spawnCmd, spawnArgs, {
|
|
377
|
-
// The service's OWN workspace root — `rt.root` for the daemon's own workspace, the registered
|
|
378
|
-
// workspace's root for a second one (so ONE daemon hosts several workspaces' devservers — model (A)).
|
|
379
|
-
cwd: svc.root,
|
|
380
|
-
detached: true,
|
|
381
|
-
// NX_DAEMON=false keeps the serve task IN this spawned process group.
|
|
382
|
-
// With the daemon on, Nx hands the long-running task to the persistent
|
|
383
|
-
// daemon (a DIFFERENT group), so the later process-group kill reaps only
|
|
384
|
-
// the `yarn nx` shim and orphans the devserver + pod tree — the classic
|
|
385
|
-
// "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
|
|
386
|
-
// avoids the env-loss the daemon was relied on for.) NX_DAEMON=false also
|
|
387
|
-
// means env vars DO reach the pod, so the east-west key + CA propagate.
|
|
388
|
-
// LENSMCP_WS_KEY (the SERVICE's owning workspace) namespaces its pod sock dir
|
|
389
|
-
// ($TMPDIR/<wsKey>/<service>-devserver) — the namespaced pool the daemon scans for this fragment.
|
|
390
|
-
// LENSMCP_EVENT_FILE points a REGISTERED workspace's pods at ITS OWN bus (<root>/.lensmcp/events.jsonl),
|
|
391
|
-
// so its dashboard shows its live traces, not the daemon's. The daemon's own services keep rt.eventFile
|
|
392
|
-
// exactly (svc.root === rt.root), so single-workspace behavior is unchanged.
|
|
393
|
-
env: {
|
|
394
|
-
// FOREIGN-POD ENV SCRUB (pod-env.ts): a registered workspace's pod must NOT inherit the daemon
|
|
395
|
-
// host's dotenv-loaded env (nx never overrides an already-set var, so foodguard's PG_URL/REDIS_URL
|
|
396
|
-
// riding the daemon once pointed tetros's auth+iam at foodguard's stores). The daemon's own
|
|
397
|
-
// workspace keeps the full inherit — its pods reload the same files anyway.
|
|
398
|
-
...(0, pod_env_1.podBaseEnv)(process.env, rt.root, svc.root),
|
|
399
|
-
NX_DAEMON: 'false',
|
|
400
|
-
// NX_ISOLATE_PLUGINS=false: with the daemon off (above), EVERY serve client builds the
|
|
401
|
-
// project graph itself — and with plugin isolation on (nx's default) each client spawns
|
|
402
|
-
// 8 plugin-worker processes (~50MB each) that stay ALIVE for the life of the service:
|
|
403
|
-
// ~400MB of nx machinery per ~50MB service pod, ~4-5GB across a 13-service cluster
|
|
404
|
-
// (measured tetros 2026-07-25). Isolation-off loads plugins in-process: the graph is
|
|
405
|
-
// identical and zero workers spawn (nx itself runs isolation-off in sandboxes/WASM —
|
|
406
|
-
// a supported mode). NOTE this is authoritative for spawned clients: nx's dotenv load
|
|
407
|
-
// never overrides an already-set env var, so a workspace .env cannot flip it back on.
|
|
408
|
-
NX_ISOLATE_PLUGINS: 'false',
|
|
409
|
-
...(svc.wsKey ? { LENSMCP_WS_KEY: svc.wsKey } : {}),
|
|
410
|
-
LENSMCP_EVENT_FILE: svc.wsKey === rt.wsKey ? rt.eventFile : path.join(svc.root, '.lensmcp', 'events.jsonl'),
|
|
411
|
-
// Rides the env inherit down the whole nx chain (nx run → run-executor → serve fork) into
|
|
412
|
-
// main.devserver, whose gateway watchdog self-exits the pod tree when this pid dies. Without
|
|
413
|
-
// it an UNGRACEFUL gateway death (crash/SIGKILL — no process-group kill runs) orphans the
|
|
414
|
-
// entire chain under launchd forever (2026-07-28: a day-old tetros pod tree at PPID 1).
|
|
415
|
-
LENSMCP_GATEWAY_PID: String(process.pid),
|
|
416
|
-
...eastWestEnv(rt, svc.project),
|
|
417
|
-
},
|
|
418
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
419
|
-
});
|
|
420
|
-
svc.lastErrors = [];
|
|
421
|
-
svc.buildFailed = false;
|
|
422
|
-
const sniff = (d) => {
|
|
423
|
-
const text = d.toString();
|
|
424
|
-
for (const line of text.split('\n')) {
|
|
425
|
-
if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
|
|
426
|
-
// eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
|
|
427
|
-
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
428
|
-
svc.lastErrors.push(clean);
|
|
429
|
-
if (svc.lastErrors.length > 12)
|
|
430
|
-
svc.lastErrors.shift();
|
|
431
|
-
svc.buildFailed = true;
|
|
432
|
-
// THROTTLE: a crash-looping pod re-prints the same error line on every restart. Emit the
|
|
433
|
-
// 'service error' event only when the error line CHANGES or after the throttle window —
|
|
434
|
-
// otherwise the bus (and the lens log) floods with identical errors many times a second.
|
|
435
|
-
const now = Date.now();
|
|
436
|
-
if (clean !== svc.lastErrorEmitSig || now - (svc.lastErrorEmitAt ?? 0) >= types_1.SERVICE_ERROR_THROTTLE_MS) {
|
|
437
|
-
svc.lastErrorEmitSig = clean;
|
|
438
|
-
svc.lastErrorEmitAt = now;
|
|
439
|
-
emitSvc(svc, 'error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: clean });
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
|
|
443
|
-
svc.buildFailed = false;
|
|
444
|
-
// LOCK-BREAK: nx prints exactly this when a FOREIGN runner holds the running-tasks
|
|
445
|
-
// record for our target — this spawn will park forever and every request would hang
|
|
446
|
-
// through ensureUp with no further log. Cure it once per spawn: kill our parked
|
|
447
|
-
// waiter, reap the foreign tree, and respawn clean.
|
|
448
|
-
if (!svc.lockBlockedAt && /Waiting for .+ in another nx process/.test(line)) {
|
|
449
|
-
svc.lockBlockedAt = Date.now();
|
|
450
|
-
console.log(`[gateway] ${target}: spawn blocked by a stale nx runner — breaking the lock + respawning`);
|
|
451
|
-
emitSvc(svc, 'warning', `nx task lock: ${svc.project} blocked by a stale runner — auto-breaking`, `cluster-service:${svc.project}`, { kind: 'nx-lock-break', project: svc.project });
|
|
452
|
-
setTimeout(() => {
|
|
453
|
-
if (rt.stopped() || !svc.lockBlockedAt)
|
|
454
|
-
return;
|
|
455
|
-
killSpawned(svc, 'parked on a foreign nx task lock — recycling', { announce: false }); // a recycle: `nx-lock-break` above is the report
|
|
456
|
-
reapForeignRunners(svc, target);
|
|
457
|
-
setTimeout(() => {
|
|
458
|
-
if (!rt.stopped() && !svc.proc && svc.state === 'down')
|
|
459
|
-
spawnService(svc);
|
|
460
|
-
}, 1_500).unref?.();
|
|
461
|
-
}, 250).unref?.();
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
proc.stdout?.on('data', (d) => { if (rt.stopped())
|
|
466
|
-
return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
|
|
467
|
-
proc.stderr?.on('data', (d) => { if (rt.stopped())
|
|
468
|
-
return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
|
|
469
|
-
proc.on('exit', (code) => {
|
|
470
|
-
svc.proc = undefined;
|
|
471
|
-
svc.state = 'down';
|
|
472
|
-
if (rt.stopped())
|
|
473
|
-
return; // gateway is tearing down — its dying services' exit chatter is noise
|
|
474
|
-
console.log(`[gateway] ${target} exited (${code})`);
|
|
475
|
-
emitSvc(svc, code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
|
|
476
|
-
// SELF-HEAL: respawn an UNEXPECTEDLY-crashed pod so a transient failure recovers with no manual
|
|
477
|
-
// restart. A clean exit (code 0) or a sniffed BUILD failure (a code error → a fix, not a transient
|
|
478
|
-
// crash) is left down — scale-from-zero (ensureUp) still spawns it on the next request. A crash-loop
|
|
479
|
-
// guard (≤ SERVICE_MAX_RESTARTS within the window, exponential backoff) stops a broken service
|
|
480
|
-
// hot-looping the CPU.
|
|
481
|
-
if (code === 0 || svc.buildFailed)
|
|
482
|
-
return;
|
|
483
|
-
const now = Date.now();
|
|
484
|
-
svc.restarts = (svc.restarts ?? []).filter((t) => now - t < types_1.SERVICE_RESTART_WINDOW_MS);
|
|
485
|
-
if (svc.restarts.length >= types_1.SERVICE_MAX_RESTARTS) {
|
|
486
|
-
emitSvc(svc, 'error', `service crash-looping: ${svc.project} — ${types_1.SERVICE_MAX_RESTARTS}+ restarts/min, not auto-respawning (the next request retries)`, `cluster-service:${svc.project}`, { kind: 'service-crash-loop', project: svc.project });
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
svc.restarts.push(now);
|
|
490
|
-
const backoffMs = Math.min(8000, 500 * 2 ** (svc.restarts.length - 1)); // 0.5 → 1 → 2 → 4 → 8s
|
|
491
|
-
setTimeout(() => {
|
|
492
|
-
if (rt.stopped() || svc.proc || svc.state !== 'down')
|
|
493
|
-
return; // a request already respawned it, or teardown
|
|
494
|
-
console.log(`[gateway] self-heal: respawning ${svc.project} (restart ${svc.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`);
|
|
495
|
-
emitSvc(svc, 'info', `self-heal: respawning ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-respawn', project: svc.project, attempt: svc.restarts.length });
|
|
496
|
-
spawnService(svc);
|
|
497
|
-
}, backoffMs);
|
|
498
|
-
});
|
|
499
|
-
svc.proc = proc;
|
|
500
|
-
};
|
|
501
|
-
/**
|
|
502
|
-
* The RECOVERY edge — the counterpart to `service-down`/`service-recycle`/`pod-recycle`.
|
|
503
|
-
*
|
|
504
|
-
* The gateway used to announce a service going UP from exactly two places, both inside
|
|
505
|
-
* `ensureUp`: the adopt fast-path and the cold-start wait loop. But the proxy only calls
|
|
506
|
-
* `ensureUp` when `pickSock` came back EMPTY — so a service that self-healed on its own (the
|
|
507
|
-
* exit auto-heal respawn, a recycled pod re-binding, an externally-started devserver) was never
|
|
508
|
-
* announced. Its `svc.state` stayed `'starting'` and the lens fold behind `process://cluster`
|
|
509
|
-
* latched on the last DEGRADATION forever: `agent://current-status` reported `failing` while the
|
|
510
|
-
* app served every request (foodguard 2026-07-31 — four services `starting`, `up: []`, 20×200,
|
|
511
|
-
* 0×502).
|
|
512
|
-
*
|
|
513
|
-
* So the transition lives here, callable from every place that observes a live pod, and the
|
|
514
|
-
* evidence is the SAME socket scan the wedge detector uses (`live = scanNow(pool) > 0`) — the
|
|
515
|
-
* detector that clears `wedgedSince` must also be the one that records `up`, or the two disagree.
|
|
516
|
-
* Idempotent, so the per-request call costs one property compare.
|
|
517
|
-
*/
|
|
518
|
-
const markUp = (svc, how) => {
|
|
519
|
-
if (svc.state === 'up')
|
|
520
|
-
return false;
|
|
521
|
-
svc.state = 'up';
|
|
522
|
-
const pods = svc.pool.socks.length;
|
|
523
|
-
emitSvc(svc, 'info', `service up: ${svc.project} (${pods} pods, ${how})`, `cluster-service:${svc.project}`, { kind: 'service-up', project: svc.project, pods, adopted: how === 'adopted' });
|
|
524
|
-
return true;
|
|
525
|
-
};
|
|
526
|
-
const ensureUp = async (svc) => {
|
|
527
|
-
if (scanNow(svc.pool) > 0) {
|
|
528
|
-
markUp(svc, 'adopted');
|
|
529
|
-
return true;
|
|
530
|
-
}
|
|
531
|
-
if (rt.stopped())
|
|
532
|
-
return false;
|
|
533
|
-
if (svc.state !== 'starting' && !svc.proc)
|
|
534
|
-
spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
|
|
535
|
-
const deadline = Date.now() + rt.startupTimeoutMs;
|
|
536
|
-
const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
|
|
537
|
-
while (!rt.stopped() && Date.now() < deadline) {
|
|
538
|
-
await sleep(Math.min(700, rt.startupTimeoutMs / 4));
|
|
539
|
-
if (svc.buildFailed && Date.now() > failFastAt)
|
|
540
|
-
return false;
|
|
541
|
-
if (scanNow(svc.pool) > 0) {
|
|
542
|
-
svc.state = 'up';
|
|
543
|
-
svc.buildFailed = false;
|
|
544
|
-
const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
|
|
545
|
-
console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
|
|
546
|
-
emitSvc(svc, 'info', `cold start: ${svc.project} (${svc.pool.socks.length} pods, ${durationMs ?? '?'}ms)`, `cluster-service:${svc.project}`, { kind: 'cold-start', project: svc.project, pods: svc.pool.socks.length, durationMs });
|
|
547
|
-
return true;
|
|
548
|
-
}
|
|
549
|
-
if (svc.state === 'down')
|
|
550
|
-
return false; // spawn died
|
|
551
|
-
}
|
|
552
|
-
return false;
|
|
553
|
-
};
|
|
554
|
-
const scaleUp = (svc) => {
|
|
555
|
-
const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
|
|
556
|
-
const cur = svc.pool.socks.length;
|
|
557
|
-
const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
|
|
558
|
-
if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
|
|
559
|
-
return;
|
|
560
|
-
if (Date.now() - svc.lastScaleAt < 5000)
|
|
561
|
-
return;
|
|
562
|
-
svc.lastScaleAt = Date.now();
|
|
563
|
-
const mod = admin.startsWith('https')
|
|
564
|
-
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
565
|
-
require('node:https')
|
|
566
|
-
: http;
|
|
567
|
-
const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
|
|
568
|
-
r.on('error', () => undefined);
|
|
569
|
-
r.end(JSON.stringify({ pods: cur + 1 }));
|
|
570
|
-
console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
|
|
571
|
-
emitSvc(svc, 'info', `autoscale: ${svc.project} ${cur} → ${cur + 1} pods`, `cluster-service:${svc.project}`, { kind: 'autoscale', project: svc.project, from: cur, to: cur + 1, inflight: svc.inflight });
|
|
572
|
-
};
|
|
573
|
-
// Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
|
|
574
|
-
//
|
|
575
|
-
// `announce: false` for a caller that is TEARING THE TREE DOWN AS PART OF A RECYCLE. This function
|
|
576
|
-
// is the shared teardown for two different intents — "park this service, it's idle" and "kill this
|
|
577
|
-
// service, it's broken" — and the `idle-kill` event only describes the first. A recycle caller has
|
|
578
|
-
// ALREADY announced the real reason (`service-recycle` / `pod-recycle` / `service-crash-loop`), and
|
|
579
|
-
// an `idle-kill` on top would OVERWRITE that fault in the lens fold with a benign `idle` — the
|
|
580
|
-
// crash-loop and wedge reports would vanish milliseconds after they were raised. It also read wrong
|
|
581
|
-
// in the log ("idle kill: auth (wedged: 0 live pods — recycling)").
|
|
582
|
-
const killSpawned = (svc, why, opts) => {
|
|
583
|
-
if (!svc.proc)
|
|
584
|
-
return;
|
|
585
|
-
console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
|
|
586
|
-
if (opts?.announce !== false) {
|
|
587
|
-
emitSvc(svc, 'info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
|
|
588
|
-
}
|
|
589
|
-
const dying = svc.proc;
|
|
590
|
-
// Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
|
|
591
|
-
// buffered stdout/stderr as it exits, and those `sniff` callbacks would
|
|
592
|
-
// keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
|
|
593
|
-
// if a fresh request re-spawns this same svc while the corpse still flushes.
|
|
594
|
-
try {
|
|
595
|
-
dying.stdout?.removeAllListeners('data');
|
|
596
|
-
dying.stderr?.removeAllListeners('data');
|
|
597
|
-
dying.removeAllListeners('exit');
|
|
598
|
-
}
|
|
599
|
-
catch { /* ignore */ }
|
|
600
|
-
try {
|
|
601
|
-
process.kill(-dying.pid, 'SIGTERM');
|
|
602
|
-
}
|
|
603
|
-
catch {
|
|
604
|
-
try {
|
|
605
|
-
dying.kill('SIGTERM');
|
|
606
|
-
}
|
|
607
|
-
catch { /* gone */ }
|
|
608
|
-
}
|
|
609
|
-
svc.proc = undefined;
|
|
610
|
-
svc.state = 'down';
|
|
611
|
-
// children die with the tree but their sock FILES survive — remove them so the canvas
|
|
612
|
-
// and the proxy never see ghost pods. OWNER-AWARE: a sock whose `.owner` pidfile names a
|
|
613
|
-
// LIVE process belongs to a NEWER generation binding concurrently — leave it (the
|
|
614
|
-
// devserver's bind-steal owns the path now; unlinking it here recreates the vanished-sock
|
|
615
|
-
// wedge this sweep exists to prevent).
|
|
616
|
-
setTimeout(() => {
|
|
617
|
-
try {
|
|
618
|
-
for (const f of fs.readdirSync(svc.pool.dir)) {
|
|
619
|
-
if (!f.endsWith('.sock'))
|
|
620
|
-
continue;
|
|
621
|
-
const sockPath = path.join(svc.pool.dir, f);
|
|
622
|
-
try {
|
|
623
|
-
const owner = Number(fs.readFileSync(`${sockPath}.owner`, 'utf8').trim());
|
|
624
|
-
if (Number.isFinite(owner) && owner > 0) {
|
|
625
|
-
try {
|
|
626
|
-
process.kill(owner, 0); // throws when the owner is dead
|
|
627
|
-
continue; // a LIVE foreign owner — a newer generation's pod; keep it
|
|
628
|
-
}
|
|
629
|
-
catch { /* dead owner → sweep */ }
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
catch { /* no owner file (legacy) → sweep */ }
|
|
633
|
-
try {
|
|
634
|
-
fs.unlinkSync(sockPath);
|
|
635
|
-
}
|
|
636
|
-
catch { /* gone */ }
|
|
637
|
-
try {
|
|
638
|
-
fs.unlinkSync(`${sockPath}.owner`);
|
|
639
|
-
}
|
|
640
|
-
catch { /* gone */ }
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
catch { /* dir gone */ }
|
|
644
|
-
svc.pool.socks = [];
|
|
645
|
-
svc.pool.scannedAt = 0;
|
|
646
|
-
}, 1200).unref?.();
|
|
647
|
-
};
|
|
648
|
-
// SELF-HEAL a WEDGED service: its gateway-spawned parent is ALIVE but 0 pods are live — an HMR hot-swap
|
|
649
|
-
// (or the proxy wedge/connection evict) unlinked the child socket, and `ensureUp` won't respawn because
|
|
650
|
-
// its `!svc.proc` guard sees the alive parent (the "ghost-socket zombie" — the evict→respawn gap). Kill
|
|
651
|
-
// the wedged tree (killSpawned clears svc.proc + unlinks stale socks) then respawn a fresh pod. A
|
|
652
|
-
// crash-loop cap (shared with the exit-self-heal) stops a genuinely-broken service hot-looping.
|
|
653
|
-
const recycleWedged = (svc) => {
|
|
654
|
-
const now = Date.now();
|
|
655
|
-
svc.restarts = (svc.restarts ?? []).filter((t) => now - t < types_1.SERVICE_RESTART_WINDOW_MS);
|
|
656
|
-
if (svc.restarts.length >= types_1.SERVICE_MAX_RESTARTS) {
|
|
657
|
-
emitSvc(svc, 'error', `service crash-looping: ${svc.project} — wedged ${types_1.SERVICE_MAX_RESTARTS}+×/min, not auto-recycling (the next request retries)`, `cluster-service:${svc.project}`, { kind: 'service-crash-loop', project: svc.project });
|
|
658
|
-
killSpawned(svc, 'wedged (crash-loop cap) — leaving down for the next request', { announce: false }); // svc.proc cleared → scale-from-zero can retry
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
svc.restarts.push(now);
|
|
662
|
-
console.log(`[gateway] self-heal: recycling WEDGED ${svc.project} (live parent, 0 live pods; ${svc.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`);
|
|
663
|
-
emitSvc(svc, 'warning', `self-heal: recycling wedged ${svc.project} (0 live pods)`, `cluster-service:${svc.project}`, { kind: 'service-recycle', project: svc.project, attempt: svc.restarts.length });
|
|
664
|
-
killSpawned(svc, 'wedged: 0 live pods — recycling', { announce: false }); // kills the tree + unlinks stale socks (+1200ms) + svc.proc=undefined + state='down'
|
|
665
|
-
// A wedge is unexpected — bring it straight back (don't wait for a request). Respawn AFTER killSpawned's
|
|
666
|
-
// deferred sock cleanup (1200ms) so the fresh child binds a clean socket path.
|
|
667
|
-
setTimeout(() => {
|
|
668
|
-
if (!rt.stopped() && !svc.proc && svc.state === 'down')
|
|
669
|
-
spawnService(svc);
|
|
670
|
-
}, 1500).unref?.();
|
|
671
|
-
};
|
|
672
|
-
// --- zombie-pod recycle: source-set-change tracking + a fresh-pod recycle for a live-but-stale pod ------
|
|
673
|
-
const recycleCfg = {
|
|
674
|
-
maxAgeMs: types_1.POD_MAX_AGE_MS, graceMs: types_1.POD_STALE_GRACE_MS,
|
|
675
|
-
settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS,
|
|
676
|
-
};
|
|
677
|
-
let prevSig;
|
|
678
|
-
const prevScopedSig = new Map(); // bucket → its last signature
|
|
679
|
-
let scanInFlight = false;
|
|
680
|
-
let lastScanAt = 0;
|
|
681
|
-
/** The distinct top-level buckets owned by a gateway-managed project (services + `lens:true` apps) — every
|
|
682
|
-
* OTHER bucket is shared/leaf and belongs in every scope. Recomputed per scan so a workspace registered
|
|
683
|
-
* after boot is picked up. */
|
|
684
|
-
const managedBuckets = () => {
|
|
685
|
-
const roots = rt.projectRoots ?? {};
|
|
686
|
-
const names = new Set(rt.services.map((s) => s.project));
|
|
687
|
-
for (const r of rt.routes)
|
|
688
|
-
if (r.lens)
|
|
689
|
-
names.add(r.lens.project);
|
|
690
|
-
const buckets = new Set();
|
|
691
|
-
for (const n of names) {
|
|
692
|
-
const pr = roots[n];
|
|
693
|
-
if (!pr)
|
|
694
|
-
continue;
|
|
695
|
-
const seg = path.relative(rt.root, pr).split(path.sep).filter(Boolean)[0];
|
|
696
|
-
if (seg && !seg.startsWith('..'))
|
|
697
|
-
buckets.add(seg);
|
|
698
|
-
}
|
|
699
|
-
return [...buckets];
|
|
700
|
-
};
|
|
701
|
-
/** project → the absolute dirs its child must watch, or `undefined` when it cannot be narrowed (unknown
|
|
702
|
-
* project root / no `projectsConfigurations`) → that child keeps the workspace-wide clock. */
|
|
703
|
-
const scopeFor = (project, topLevel, managed, extraExclude) => {
|
|
704
|
-
const pr = rt.projectRoots?.[project];
|
|
705
|
-
if (!pr)
|
|
706
|
-
return undefined;
|
|
707
|
-
const dirs = sourceScopeDirs(rt.root, pr, managed, topLevel, extraExclude);
|
|
708
|
-
return dirs.length > 0 ? dirs : undefined;
|
|
709
|
-
};
|
|
710
|
-
// Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the clocks so
|
|
711
|
-
// children that predate it recycle. Non-blocking — the sweeper never awaits it. Two tiers: the
|
|
712
|
-
// workspace-WIDE signature (the fallback + back-compat clock) and one SCOPED signature per managed
|
|
713
|
-
// project's bucket, so a `server/**` add no longer recycles a `web/**` vite (see `sourceScopeDirs`). One
|
|
714
|
-
// scan per bucket, not per project — sibling projects in a bucket share a scope, so `web/apps/dashboard`
|
|
715
|
-
// and `web/apps/authentication` cost a single walk between them.
|
|
716
|
-
const maybeScanSourceSet = () => {
|
|
717
|
-
if (scanInFlight || Date.now() - lastScanAt < types_1.POD_STALE_SCAN_MS)
|
|
718
|
-
return;
|
|
719
|
-
scanInFlight = true;
|
|
720
|
-
lastScanAt = Date.now();
|
|
721
|
-
void (async () => {
|
|
722
|
-
// Per-workspace `.lensmcp/config.json` sourceSet.exclude — re-read per scan so an
|
|
723
|
-
// edit takes effect without a gateway restart (one tiny fs read per scan tick).
|
|
724
|
-
const extraExclude = (0, scope_1.readSourceSetExclude)(rt.root);
|
|
725
|
-
const wide = await sourceSetSignature(rt.root, undefined, extraExclude);
|
|
726
|
-
if (prevSig !== undefined && wide !== prevSig) {
|
|
727
|
-
rt.sourceSetChangedAt = Date.now();
|
|
728
|
-
emit('info', 'source file set changed — stale pods recycle when idle', 'cluster-gateway', { kind: 'source-set-changed' });
|
|
729
|
-
}
|
|
730
|
-
prevSig = wide;
|
|
731
|
-
// Scoped tier. Skipped entirely without project roots (nothing to narrow by) — consumers then read
|
|
732
|
-
// the wide clock exactly as before.
|
|
733
|
-
if (!rt.projectRoots)
|
|
734
|
-
return;
|
|
735
|
-
let topLevel;
|
|
736
|
-
try {
|
|
737
|
-
topLevel = (await fs.promises.readdir(rt.root, { withFileTypes: true }))
|
|
738
|
-
.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
739
|
-
}
|
|
740
|
-
catch {
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
const managed = managedBuckets();
|
|
744
|
-
const projects = new Set(rt.services.map((s) => s.project));
|
|
745
|
-
for (const r of rt.routes)
|
|
746
|
-
if (r.lens)
|
|
747
|
-
projects.add(r.lens.project);
|
|
748
|
-
// bucket → the scope dirs + the projects sharing it (one walk per bucket).
|
|
749
|
-
const byBucket = new Map();
|
|
750
|
-
for (const project of projects) {
|
|
751
|
-
const dirs = scopeFor(project, topLevel, managed, extraExclude);
|
|
752
|
-
if (!dirs)
|
|
753
|
-
continue;
|
|
754
|
-
const key = dirs.join('|');
|
|
755
|
-
const entry = byBucket.get(key) ?? { dirs, projects: [] };
|
|
756
|
-
entry.projects.push(project);
|
|
757
|
-
byBucket.set(key, entry);
|
|
758
|
-
}
|
|
759
|
-
const now = Date.now();
|
|
760
|
-
for (const [key, { dirs, projects: sharing }] of byBucket) {
|
|
761
|
-
const sig = await sourceSetSignature(rt.root, dirs, extraExclude);
|
|
762
|
-
const prev = prevScopedSig.get(key);
|
|
763
|
-
prevScopedSig.set(key, sig);
|
|
764
|
-
if (prev === undefined || sig === prev)
|
|
765
|
-
continue;
|
|
766
|
-
rt.sourceSetChangedFor ??= new Map();
|
|
767
|
-
for (const p of sharing)
|
|
768
|
-
rt.sourceSetChangedFor.set(p, now);
|
|
769
|
-
emit('info', `source file set changed in ${sharing.join(', ')}'s scope`, 'cluster-gateway', { kind: 'source-set-changed', scoped: true, projects: sharing });
|
|
770
|
-
}
|
|
771
|
-
})()
|
|
772
|
-
.catch(() => undefined)
|
|
773
|
-
.finally(() => { scanInFlight = false; });
|
|
774
|
-
};
|
|
775
|
-
// Recycle a live-but-STALE-VIEW pod (alive + reachable, yet its watcher missed post-spawn file adds).
|
|
776
|
-
// Kill it → scale-from-zero (or eager respawn) → a fresh pod re-scans the FS. NOT counted against the
|
|
777
|
-
// crash-loop cap (staleness ≠ crash); the per-service cooldown ({@link podRecycleReason}) stops thrash.
|
|
778
|
-
const recyclePod = (svc, reason) => {
|
|
779
|
-
svc.lastStaleRecycleAt = Date.now();
|
|
780
|
-
console.log(`[gateway] zombie recycle: ${svc.project} (${reason}) — stale fs view; fresh pod incoming`);
|
|
781
|
-
emitSvc(svc, 'warning', `zombie recycle: ${svc.project} (${reason})`, `cluster-service:${svc.project}`, { kind: 'pod-recycle', project: svc.project, reason });
|
|
782
|
-
const eager = !!svc.decl.eager;
|
|
783
|
-
killSpawned(svc, `${reason} — recycling stale pod`, { announce: false }); // clears proc, state='down', unlinks socks (+1200ms)
|
|
784
|
-
if (eager)
|
|
785
|
-
setTimeout(() => { if (!rt.stopped() && !svc.proc && svc.state === 'down')
|
|
786
|
-
spawnService(svc); }, 1500).unref?.();
|
|
787
|
-
};
|
|
788
|
-
const startSweeper = () => {
|
|
789
|
-
const sweeper = setInterval(() => {
|
|
790
|
-
if (rt.stopped())
|
|
791
|
-
return;
|
|
792
|
-
if (types_1.POD_RECYCLE_ENABLED)
|
|
793
|
-
maybeScanSourceSet();
|
|
794
|
-
for (const svc of rt.services) {
|
|
795
|
-
if (rt.stopped() || !svc.proc) {
|
|
796
|
-
svc.wedgedSince = undefined;
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
const idle = svc.decl.idleKillSec;
|
|
800
|
-
// Idle kill (unchanged): scale a lazy service to zero after its idle window.
|
|
801
|
-
if (idle && svc.inflight === 0 && Date.now() - svc.lastUsed > idle * 1000) {
|
|
802
|
-
killSpawned(svc, `idle ${idle}s`);
|
|
803
|
-
svc.wedgedSince = undefined;
|
|
804
|
-
continue;
|
|
805
|
-
}
|
|
806
|
-
// WEDGE detection: a live parent with 0 live pods that is NOT legitimately cold-starting is wedged.
|
|
807
|
-
const coldStarting = svc.state === 'starting' && svc.spawnAt !== undefined && Date.now() - svc.spawnAt < rt.startupTimeoutMs;
|
|
808
|
-
const live = scanNow(svc.pool) > 0;
|
|
809
|
-
if (live || coldStarting) {
|
|
810
|
-
svc.wedgedSince = undefined;
|
|
811
|
-
// RECOVERY EDGE. `live` is the exact evidence that just cleared `wedgedSince`, so it must
|
|
812
|
-
// also record the service as UP — otherwise the gateway silently forgives a wedge while
|
|
813
|
-
// the lens fold stays latched on it. This is the path that heals a service which came
|
|
814
|
-
// back WITHOUT a request (the exit auto-heal respawn), since nothing else calls `ensureUp`
|
|
815
|
-
// once a pod socket exists. Idempotent: after the first tick it is a property compare.
|
|
816
|
-
if (live)
|
|
817
|
-
markUp(svc, 'observed');
|
|
818
|
-
// ZOMBIE recycle: a live pod (live parent + live socket, answers requests) whose fs view went
|
|
819
|
-
// stale — neither exit-respawn nor wedge-recycle sees it. Idle-gated in podRecycleReason so a busy
|
|
820
|
-
// pod is never interrupted.
|
|
821
|
-
if (live && !coldStarting && types_1.POD_RECYCLE_ENABLED) {
|
|
822
|
-
// Prefer this project's SCOPED clock (only the subtrees it can resolve); fall back to the
|
|
823
|
-
// workspace-wide one when the scan hasn't produced a scoped verdict for it.
|
|
824
|
-
const changedAt = rt.sourceSetChangedFor?.get(svc.project) ?? rt.sourceSetChangedAt ?? 0;
|
|
825
|
-
const reason = podRecycleReason(svc, Date.now(), changedAt, recycleCfg);
|
|
826
|
-
if (reason)
|
|
827
|
-
recyclePod(svc, reason);
|
|
828
|
-
}
|
|
829
|
-
continue;
|
|
830
|
-
}
|
|
831
|
-
svc.wedgedSince ??= Date.now();
|
|
832
|
-
if (Date.now() - svc.wedgedSince > types_1.WEDGE_RECYCLE_GRACE_MS) {
|
|
833
|
-
svc.wedgedSince = undefined;
|
|
834
|
-
recycleWedged(svc);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
}, rt.sweepMs);
|
|
838
|
-
sweeper.unref();
|
|
839
|
-
return sweeper;
|
|
840
|
-
};
|
|
841
|
-
return { scanNow, spawnService, ensureUp, scaleUp, killSpawned, markUp, startSweeper };
|
|
842
|
-
}
|
|
1
|
+
"use strict";var N=Object.defineProperty;var j=(t,c)=>N(t,"name",{value:c,configurable:!0});var I=Object.defineProperty,s=j((t,c)=>I(t,"name",{value:c,configurable:!0}),"s");Object.defineProperty(exports,"__esModule",{value:!0}),exports.sourceSetSignature=sourceSetSignature,exports.sourceScopeDirs=sourceScopeDirs,exports.podRecycleReason=podRecycleReason,exports.lensFrontendStale=lensFrontendStale,exports.lensChildWedged=lensChildWedged,exports.isPortConflictExit=isPortConflictExit,exports.createServiceLayer=createServiceLayer;const tslib_1=require("tslib"),node_child_process_1=require("node:child_process"),fs=tslib_1.__importStar(require("node:fs")),http=tslib_1.__importStar(require("node:http")),path=tslib_1.__importStar(require("node:path")),node_util_1=require("node:util"),discovery_1=require("./discovery"),scope_1=require("./scope"),pod_env_1=require("./pod-env"),types_1=require("./types");function eastWestEnv(t,c){const l={},p=t.serviceKeys[c];if(p&&(l.LENSMCP_SERVICE_KEY=p),t.https){const a=require("../../../basic-ssl").caCertPath();fs.existsSync(a)&&(l.NODE_EXTRA_CA_CERTS=a)}return l}j(eastWestEnv,"eastWestEnv"),s(eastWestEnv,"eastWestEnv");const sleep=s(t=>new Promise(c=>setTimeout(c,t)),"sleep"),SRC_EXCLUDE=new Set(["node_modules","dist","tmp","coverage","build","out","target"]),SRC_EXT=new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".css",".scss",".sass",".less",".json",".html",".htm",".vue",".svelte",".mdx"]),SCAN_FILE_CAP=1e5;function fnv1a(t){let c=2166136261;for(let l=0;l<t.length;l+=1)c^=t.charCodeAt(l),c=Math.imul(c,16777619);return c>>>0}j(fnv1a,"fnv1a"),s(fnv1a,"fnv1a");const execFileP=(0,node_util_1.promisify)(node_child_process_1.execFile);async function gitIgnoredPaths(t){try{const{stdout:c}=await execFileP("git",["ls-files","--others","--directory","--ignored","--exclude-standard","-z"],{cwd:t,maxBuffer:33554432,timeout:1e4,windowsHide:!0});return new Set(c.split("\0").filter(Boolean))}catch{return new Set}}j(gitIgnoredPaths,"gitIgnoredPaths"),s(gitIgnoredPaths,"gitIgnoredPaths");async function sourceSetSignature(t,c,l){let p=0,a=0;const v=await gitIgnoredPaths(t),_=s(f=>path.relative(t,f).split(path.sep).join("/"),"rel"),S=s(async(f,k=!0)=>{if(p>SCAN_FILE_CAP)return;let M;try{M=await fs.promises.readdir(f,{withFileTypes:!0})}catch{return}for(const y of M){if(p>SCAN_FILE_CAP)return;const m=y.name,A=path.join(f,m);if(y.isDirectory()){if(!k||m.startsWith(".")||SRC_EXCLUDE.has(m)||l?.has(m)||m.endsWith("-devserver")||v.has(`${_(A)}/`))continue;await S(A)}else if(y.isFile()){const R=m.lastIndexOf(".");if(R<=0||!SRC_EXT.has(m.slice(R))||v.has(_(A)))continue;p+=1,a^=fnv1a(A)}}},"walk");if(c&&c.length>0){await S(t,!1);for(const f of c)await S(f)}else await S(t);return`${p}:${(a>>>0).toString(36)}`}j(sourceSetSignature,"sourceSetSignature"),s(sourceSetSignature,"sourceSetSignature");function sourceScopeDirs(t,c,l,p,a){const v=s(f=>{const k=path.relative(t,f).split(path.sep).filter(Boolean);return k.length===0||k[0].startsWith("..")?"":k[0]},"bucketOf")(c);if(!v)return[];const _=new Set(l.filter(Boolean)),S=new Set([v]);for(const f of p)f.startsWith(".")||SRC_EXCLUDE.has(f)||a?.has(f)||f.endsWith("-devserver")||_.has(f)||S.add(f);return[...S].sort().map(f=>path.join(t,f))}j(sourceScopeDirs,"sourceScopeDirs"),s(sourceScopeDirs,"sourceScopeDirs");function podRecycleReason(t,c,l,p){return!t.proc||t.state!=="up"||t.spawnAt==null||t.inflight>0||c-t.lastUsed<p.settleMs||t.lastStaleRecycleAt!=null&&c-t.lastStaleRecycleAt<p.cooldownMs?null:l>0&&l-t.spawnAt>p.graceMs?"stale-source-set":p.maxAgeMs>0&&c-t.spawnAt>p.maxAgeMs?"max-age":null}j(podRecycleReason,"podRecycleReason"),s(podRecycleReason,"podRecycleReason");function lensFrontendStale(t,c,l,p,a){return!(p-c<a.cooldownMs||l<=0||l-t<=a.graceMs||p-l<a.settleMs)}j(lensFrontendStale,"lensFrontendStale"),s(lensFrontendStale,"lensFrontendStale");function lensChildWedged(t,c,l,p){return l-t.spawnedAt<p.graceMs||l-c<p.cooldownMs||t.probeFails<Math.max(2,p.failureThreshold)?null:"wedged-no-listener"}j(lensChildWedged,"lensChildWedged"),s(lensChildWedged,"lensChildWedged");function isPortConflictExit(t,c){return t.signal||t.code===null||t.code===0||t.ranMs>c.fastFailMs?!1:t.portHeld}j(isPortConflictExit,"isPortConflictExit"),s(isPortConflictExit,"isPortConflictExit");function createServiceLayer(t,c){const{emit:l}=c,p=s(e=>e.wsKey===t.wsKey?void 0:path.join(e.root,".lensmcp","events.jsonl"),"serviceBus"),a=s((e,o,r,n,i)=>l(o,r,n,{wsKey:e.wsKey,...i},void 0,p(e)),"emitSvc"),v=s(e=>(e.scannedAt=0,(0,discovery_1.pickSock)(e,t.scanTtlMs)?e.socks.length:0),"scanNow"),_=s((e,o)=>{let r;try{r=(0,node_child_process_1.execFileSync)("ps",["ax","-ww","-o","pid=,pgid=,command="],{encoding:"utf8",timeout:5e3})}catch{return 0}const n=e.proc?.pid;let i=0;for(const d of r.split(`
|
|
2
|
+
`)){const w=d.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);if(!w)continue;const g=Number(w[1]),u=Number(w[2]),h=w[3];if(h.includes(`run ${o}`)&&/\bnx(\.js)?\b/.test(h)&&!(h.includes("/node_modules/nx/")&&!h.includes(e.root))&&!(g===process.pid||g===n)&&!(n!==void 0&&u===n)){try{process.kill(-u,"SIGKILL")}catch{try{process.kill(g,"SIGKILL")}catch{}}i+=1}}return i>0&&(console.log(`[gateway] ${o}: reaped ${i} stale runner tree(s) \u2014 a foreign generation held the nx task lock`),a(e,"warning",`stale-runner reap: ${e.project} (${i} foreign tree(s))`,`cluster-service:${e.project}`,{kind:"stale-runner-reap",project:e.project,reaped:i})),i},"reapForeignRunners"),S=s(e=>{if(t.stopped())return;const o=`${e.project}:${e.decl.serveTarget??"serve-hmr"}`,r=Math.max(1,e.decl.minPods??1);_(e,o),e.lockBlockedAt=void 0,console.log(`[gateway] starting ${o} (pods=${r})\u2026`),a(e,"info",`service starting: ${e.project} (${r} pod${r>1?"s":""})`,`cluster-service:${e.project}`,{kind:"service-starting",project:e.project,pods:r}),e.state="starting",e.spawnAt=Date.now();const n=path.join(e.root,"node_modules","nx","dist","bin","nx.js"),[i,d]=fs.existsSync(n)?[process.execPath,[n,"run",o,`--childCount=${r}`]]:["yarn",["nx","run",o,`--childCount=${r}`]],w=(0,node_child_process_1.spawn)(i,d,{cwd:e.root,detached:!0,env:{...(0,pod_env_1.podBaseEnv)(process.env,t.root,e.root),NX_DAEMON:"false",NX_ISOLATE_PLUGINS:"false",...e.wsKey?{LENSMCP_WS_KEY:e.wsKey}:{},LENSMCP_EVENT_FILE:e.wsKey===t.wsKey?t.eventFile:path.join(e.root,".lensmcp","events.jsonl"),LENSMCP_GATEWAY_PID:String(process.pid),...eastWestEnv(t,e.project)},stdio:["ignore","pipe","pipe"]});e.lastErrors=[],e.buildFailed=!1;const g=s(u=>{const h=u.toString();for(const E of h.split(`
|
|
3
|
+
`)){if(/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(E)){const $=E.replace(/\x1b\[[0-9;]*m/g,"").trim();e.lastErrors.push($),e.lastErrors.length>12&&e.lastErrors.shift(),e.buildFailed=!0;const x=Date.now();($!==e.lastErrorEmitSig||x-(e.lastErrorEmitAt??0)>=types_1.SERVICE_ERROR_THROTTLE_MS)&&(e.lastErrorEmitSig=$,e.lastErrorEmitAt=x,a(e,"error",`service error: ${e.project}`,`cluster-service:${e.project}`,{kind:"service-error",project:e.project,line:$}))}/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(E)&&(e.buildFailed=!1),!e.lockBlockedAt&&/Waiting for .+ in another nx process/.test(E)&&(e.lockBlockedAt=Date.now(),console.log(`[gateway] ${o}: spawn blocked by a stale nx runner \u2014 breaking the lock + respawning`),a(e,"warning",`nx task lock: ${e.project} blocked by a stale runner \u2014 auto-breaking`,`cluster-service:${e.project}`,{kind:"nx-lock-break",project:e.project}),setTimeout(()=>{t.stopped()||!e.lockBlockedAt||(y(e,"parked on a foreign nx task lock \u2014 recycling",{announce:!1}),_(e,o),setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.())},250).unref?.())}},"sniff");w.stdout?.on("data",u=>{t.stopped()||(g(u),process.stdout.write(`[${e.project}] ${u}`))}),w.stderr?.on("data",u=>{t.stopped()||(g(u),process.stderr.write(`[${e.project}] ${u}`))}),w.on("exit",u=>{if(e.proc=void 0,e.state="down",t.stopped()||(console.log(`[gateway] ${o} exited (${u})`),a(e,u===0?"info":"warning",`service down: ${e.project} (exit ${u})`,`cluster-service:${e.project}`,{kind:"service-down",project:e.project,code:u}),u===0||e.buildFailed))return;const h=Date.now();if(e.restarts=(e.restarts??[]).filter($=>h-$<types_1.SERVICE_RESTART_WINDOW_MS),e.restarts.length>=types_1.SERVICE_MAX_RESTARTS){a(e,"error",`service crash-looping: ${e.project} \u2014 ${types_1.SERVICE_MAX_RESTARTS}+ restarts/min, not auto-respawning (the next request retries)`,`cluster-service:${e.project}`,{kind:"service-crash-loop",project:e.project});return}e.restarts.push(h);const E=Math.min(8e3,500*2**(e.restarts.length-1));setTimeout(()=>{t.stopped()||e.proc||e.state!=="down"||(console.log(`[gateway] self-heal: respawning ${e.project} (restart ${e.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`),a(e,"info",`self-heal: respawning ${e.project}`,`cluster-service:${e.project}`,{kind:"service-respawn",project:e.project,attempt:e.restarts.length}),S(e))},E)}),e.proc=w},"spawnService"),f=s((e,o)=>{if(e.state==="up")return!1;e.state="up";const r=e.pool.socks.length;return a(e,"info",`service up: ${e.project} (${r} pods, ${o})`,`cluster-service:${e.project}`,{kind:"service-up",project:e.project,pods:r,adopted:o==="adopted"}),!0},"markUp"),k=s(async e=>{if(v(e.pool)>0)return f(e,"adopted"),!0;if(t.stopped())return!1;e.state!=="starting"&&!e.proc&&S(e);const o=Date.now()+t.startupTimeoutMs,r=Date.now()+12e3;for(;!t.stopped()&&Date.now()<o;){if(await sleep(Math.min(700,t.startupTimeoutMs/4)),e.buildFailed&&Date.now()>r)return!1;if(v(e.pool)>0){e.state="up",e.buildFailed=!1;const n=e.spawnAt?Date.now()-e.spawnAt:void 0;return console.log(`[gateway] ${e.project} up (${e.pool.socks.length} pods${n?`, cold start ${n}ms`:""})`),a(e,"info",`cold start: ${e.project} (${e.pool.socks.length} pods, ${n??"?"}ms)`,`cluster-service:${e.project}`,{kind:"cold-start",project:e.project,pods:e.pool.socks.length,durationMs:n}),!0}if(e.state==="down")return!1}return!1},"ensureUp"),M=s(e=>{const o=e.decl.maxPods??e.decl.minPods??1,r=e.pool.socks.length,n=e.decl.adminUrl??(e.decl.port?`http://localhost:${e.decl.port}`:void 0);if(!n||r===0||r>=o||e.inflight<=r||Date.now()-e.lastScaleAt<5e3)return;e.lastScaleAt=Date.now();const i=(n.startsWith("https")?require("node:https"):http).request(`${n}/webpack/scale`,{method:"POST",rejectUnauthorized:!1},d=>d.resume());i.on("error",()=>{}),i.end(JSON.stringify({pods:r+1})),console.log(`[gateway] autoscale ${e.project}: ${r} \u2192 ${r+1} pods (inflight ${e.inflight})`),a(e,"info",`autoscale: ${e.project} ${r} \u2192 ${r+1} pods`,`cluster-service:${e.project}`,{kind:"autoscale",project:e.project,from:r,to:r+1,inflight:e.inflight})},"scaleUp"),y=s((e,o,r)=>{if(!e.proc)return;console.log(`[gateway] ${e.project} ${o} \u2014 scaling to zero.`),r?.announce!==!1&&a(e,"info",`idle kill: ${e.project} (${o})`,`cluster-service:${e.project}`,{kind:"idle-kill",project:e.project,why:o});const n=e.proc;try{n.stdout?.removeAllListeners("data"),n.stderr?.removeAllListeners("data"),n.removeAllListeners("exit")}catch{}try{process.kill(-n.pid,"SIGTERM")}catch{try{n.kill("SIGTERM")}catch{}}e.proc=void 0,e.state="down",setTimeout(()=>{try{for(const i of fs.readdirSync(e.pool.dir)){if(!i.endsWith(".sock"))continue;const d=path.join(e.pool.dir,i);try{const w=Number(fs.readFileSync(`${d}.owner`,"utf8").trim());if(Number.isFinite(w)&&w>0)try{process.kill(w,0);continue}catch{}}catch{}try{fs.unlinkSync(d)}catch{}try{fs.unlinkSync(`${d}.owner`)}catch{}}}catch{}e.pool.socks=[],e.pool.scannedAt=0},1200).unref?.()},"killSpawned"),m=s(e=>{const o=Date.now();if(e.restarts=(e.restarts??[]).filter(r=>o-r<types_1.SERVICE_RESTART_WINDOW_MS),e.restarts.length>=types_1.SERVICE_MAX_RESTARTS){a(e,"error",`service crash-looping: ${e.project} \u2014 wedged ${types_1.SERVICE_MAX_RESTARTS}+\xD7/min, not auto-recycling (the next request retries)`,`cluster-service:${e.project}`,{kind:"service-crash-loop",project:e.project}),y(e,"wedged (crash-loop cap) \u2014 leaving down for the next request",{announce:!1});return}e.restarts.push(o),console.log(`[gateway] self-heal: recycling WEDGED ${e.project} (live parent, 0 live pods; ${e.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`),a(e,"warning",`self-heal: recycling wedged ${e.project} (0 live pods)`,`cluster-service:${e.project}`,{kind:"service-recycle",project:e.project,attempt:e.restarts.length}),y(e,"wedged: 0 live pods \u2014 recycling",{announce:!1}),setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.()},"recycleWedged"),A={maxAgeMs:types_1.POD_MAX_AGE_MS,graceMs:types_1.POD_STALE_GRACE_MS,settleMs:types_1.POD_RECYCLE_SETTLE_MS,cooldownMs:types_1.POD_STALE_RECYCLE_COOLDOWN_MS};let R;const D=new Map;let C=!1,T=0;const b=s(()=>{const e=t.projectRoots??{},o=new Set(t.services.map(n=>n.project));for(const n of t.routes)n.lens&&o.add(n.lens.project);const r=new Set;for(const n of o){const i=e[n];if(!i)continue;const d=path.relative(t.root,i).split(path.sep).filter(Boolean)[0];d&&!d.startsWith("..")&&r.add(d)}return[...r]},"managedBuckets"),L=s((e,o,r,n)=>{const i=t.projectRoots?.[e];if(!i)return;const d=sourceScopeDirs(t.root,i,r,o,n);return d.length>0?d:void 0},"scopeFor"),P=s(()=>{C||Date.now()-T<types_1.POD_STALE_SCAN_MS||(C=!0,T=Date.now(),(async()=>{const e=(0,scope_1.readSourceSetExclude)(t.root),o=await sourceSetSignature(t.root,void 0,e);if(R!==void 0&&o!==R&&(t.sourceSetChangedAt=Date.now(),l("info","source file set changed \u2014 stale pods recycle when idle","cluster-gateway",{kind:"source-set-changed"})),R=o,!t.projectRoots)return;let r;try{r=(await fs.promises.readdir(t.root,{withFileTypes:!0})).filter(g=>g.isDirectory()).map(g=>g.name)}catch{return}const n=b(),i=new Set(t.services.map(g=>g.project));for(const g of t.routes)g.lens&&i.add(g.lens.project);const d=new Map;for(const g of i){const u=L(g,r,n,e);if(!u)continue;const h=u.join("|"),E=d.get(h)??{dirs:u,projects:[]};E.projects.push(g),d.set(h,E)}const w=Date.now();for(const[g,{dirs:u,projects:h}]of d){const E=await sourceSetSignature(t.root,u,e),$=D.get(g);if(D.set(g,E),!($===void 0||E===$)){t.sourceSetChangedFor??=new Map;for(const x of h)t.sourceSetChangedFor.set(x,w);l("info",`source file set changed in ${h.join(", ")}'s scope`,"cluster-gateway",{kind:"source-set-changed",scoped:!0,projects:h})}}})().catch(()=>{}).finally(()=>{C=!1}))},"maybeScanSourceSet"),F=s((e,o)=>{e.lastStaleRecycleAt=Date.now(),console.log(`[gateway] zombie recycle: ${e.project} (${o}) \u2014 stale fs view; fresh pod incoming`),a(e,"warning",`zombie recycle: ${e.project} (${o})`,`cluster-service:${e.project}`,{kind:"pod-recycle",project:e.project,reason:o});const r=!!e.decl.eager;y(e,`${o} \u2014 recycling stale pod`,{announce:!1}),r&&setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.()},"recyclePod");return{scanNow:v,spawnService:S,ensureUp:k,scaleUp:M,killSpawned:y,markUp:f,startSweeper:s(()=>{const e=setInterval(()=>{if(!t.stopped()){types_1.POD_RECYCLE_ENABLED&&P();for(const o of t.services){if(t.stopped()||!o.proc){o.wedgedSince=void 0;continue}const r=o.decl.idleKillSec;if(r&&o.inflight===0&&Date.now()-o.lastUsed>r*1e3){y(o,`idle ${r}s`),o.wedgedSince=void 0;continue}const n=o.state==="starting"&&o.spawnAt!==void 0&&Date.now()-o.spawnAt<t.startupTimeoutMs,i=v(o.pool)>0;if(i||n){if(o.wedgedSince=void 0,i&&f(o,"observed"),i&&!n&&types_1.POD_RECYCLE_ENABLED){const d=t.sourceSetChangedFor?.get(o.project)??t.sourceSetChangedAt??0,w=podRecycleReason(o,Date.now(),d,A);w&&F(o,w)}continue}o.wedgedSince??=Date.now(),Date.now()-o.wedgedSince>types_1.WEDGE_RECYCLE_GRACE_MS&&(o.wedgedSince=void 0,m(o))}}},t.sweepMs);return e.unref(),e},"startSweeper")}}j(createServiceLayer,"createServiceLayer"),s(createServiceLayer,"createServiceLayer");
|