@camstack/server 1.2.267 → 1.2.269
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/dist/api/trpc/ws-slow-consumer-guard.js +166 -0
- package/dist/core/addon/addon-registry.service.js +76 -136
- package/dist/core/addon/forked-custom-actions.js +63 -0
- package/dist/core/moleculer/moleculer.service.js +31 -0
- package/dist/core/update-availability-emitter.js +6 -0
- package/dist/core/updates/update-check-scheduler.js +3 -0
- package/dist/core/updates/wrapper-update-checker.js +63 -0
- package/dist/main.js +33 -1
- package/dist/manual-boot.js +22 -0
- package/dist/server-root/index.js +131 -1
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WsSlowConsumerGuard = exports.WS_SLOW_CONSUMER_POLL_MS = exports.WS_SLOW_CONSUMER_GRACE_MS = exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES = void 0;
|
|
4
|
+
exports.wsConnectionFacts = wsConnectionFacts;
|
|
5
|
+
const client_ip_js_1 = require("./client-ip.js");
|
|
6
|
+
/** Queued bytes above which a client is a slow consumer. */
|
|
7
|
+
exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
/** How long the queue may stay above the bound before the socket is terminated. */
|
|
9
|
+
exports.WS_SLOW_CONSUMER_GRACE_MS = 30_000;
|
|
10
|
+
/** How often every connection's queue is read. A getter per socket; free. */
|
|
11
|
+
exports.WS_SLOW_CONSUMER_POLL_MS = 5_000;
|
|
12
|
+
/** WebSocket close code for a socket that was never closed cleanly. */
|
|
13
|
+
const ABNORMAL_CLOSURE = 1006;
|
|
14
|
+
const UNIDENTIFIED = '(unidentified)';
|
|
15
|
+
class WsSlowConsumerGuard {
|
|
16
|
+
connections = new Set();
|
|
17
|
+
bySocket = new WeakMap();
|
|
18
|
+
logger;
|
|
19
|
+
maxBufferedBytes;
|
|
20
|
+
graceMs;
|
|
21
|
+
pollMs;
|
|
22
|
+
now;
|
|
23
|
+
timer = null;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.logger = options.logger;
|
|
26
|
+
this.maxBufferedBytes = options.maxBufferedBytes ?? exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES;
|
|
27
|
+
this.graceMs = options.graceMs ?? exports.WS_SLOW_CONSUMER_GRACE_MS;
|
|
28
|
+
this.pollMs = options.pollMs ?? exports.WS_SLOW_CONSUMER_POLL_MS;
|
|
29
|
+
this.now = options.now ?? (() => Date.now());
|
|
30
|
+
}
|
|
31
|
+
/** Watch one connection until it closes. */
|
|
32
|
+
attach(socket, facts) {
|
|
33
|
+
const connection = {
|
|
34
|
+
socket,
|
|
35
|
+
facts,
|
|
36
|
+
openedAt: this.now(),
|
|
37
|
+
principal: UNIDENTIFIED,
|
|
38
|
+
overSince: null,
|
|
39
|
+
peakBufferedBytes: 0,
|
|
40
|
+
terminated: false,
|
|
41
|
+
};
|
|
42
|
+
this.connections.add(connection);
|
|
43
|
+
this.bySocket.set(socket, connection);
|
|
44
|
+
socket.once('close', (code, reason) => {
|
|
45
|
+
this.connections.delete(connection);
|
|
46
|
+
this.logger.info('tRPC WS connection closed', {
|
|
47
|
+
meta: {
|
|
48
|
+
...this.describe(connection),
|
|
49
|
+
code,
|
|
50
|
+
reason: reason.length > 0 ? reason.toString('utf8') : '',
|
|
51
|
+
closedBy: connection.terminated
|
|
52
|
+
? 'slow-consumer-guard'
|
|
53
|
+
: code === ABNORMAL_CLOSURE
|
|
54
|
+
? 'peer gone or keep-alive PING unanswered'
|
|
55
|
+
: 'peer',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Name the connection's principal once the WS context resolved it. */
|
|
61
|
+
identify(socket, principal) {
|
|
62
|
+
const connection = this.bySocket.get(socket);
|
|
63
|
+
if (connection !== undefined)
|
|
64
|
+
connection.principal = principal;
|
|
65
|
+
}
|
|
66
|
+
/** Read every queue once. Exposed for the spec; production runs it on {@link start}. */
|
|
67
|
+
poll() {
|
|
68
|
+
const at = this.now();
|
|
69
|
+
for (const connection of this.connections) {
|
|
70
|
+
try {
|
|
71
|
+
this.judge(connection, at);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
// A diagnostic must never take down what it guards; say so instead.
|
|
75
|
+
this.logger.warn('tRPC WS slow-consumer guard could not read a socket', {
|
|
76
|
+
meta: { ...this.describe(connection), error: errorMessage(err) },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
start() {
|
|
82
|
+
if (this.timer !== null)
|
|
83
|
+
return;
|
|
84
|
+
this.timer = setInterval(() => this.poll(), this.pollMs);
|
|
85
|
+
this.timer.unref?.();
|
|
86
|
+
}
|
|
87
|
+
stop() {
|
|
88
|
+
if (this.timer === null)
|
|
89
|
+
return;
|
|
90
|
+
clearInterval(this.timer);
|
|
91
|
+
this.timer = null;
|
|
92
|
+
}
|
|
93
|
+
judge(connection, at) {
|
|
94
|
+
if (connection.terminated)
|
|
95
|
+
return;
|
|
96
|
+
const buffered = connection.socket.bufferedAmount;
|
|
97
|
+
if (buffered > connection.peakBufferedBytes)
|
|
98
|
+
connection.peakBufferedBytes = buffered;
|
|
99
|
+
if (buffered <= this.maxBufferedBytes) {
|
|
100
|
+
if (connection.overSince !== null) {
|
|
101
|
+
// It came back: say so, so a merely slow client is visible and not confused
|
|
102
|
+
// with the ones that are terminated.
|
|
103
|
+
this.logger.info('tRPC WS client drained a backlog that had crossed the bound', {
|
|
104
|
+
meta: {
|
|
105
|
+
...this.describe(connection),
|
|
106
|
+
overForMs: at - connection.overSince,
|
|
107
|
+
bufferedBytes: buffered,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
connection.overSince = null;
|
|
111
|
+
connection.peakBufferedBytes = buffered;
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
connection.overSince ??= at;
|
|
116
|
+
const overForMs = at - connection.overSince;
|
|
117
|
+
if (overForMs < this.graceMs)
|
|
118
|
+
return;
|
|
119
|
+
connection.terminated = true;
|
|
120
|
+
this.logger.warn('tRPC WS client disconnected — slow consumer: it stopped reading and its send queue ' +
|
|
121
|
+
'stayed above the bound for the whole grace period (a backgrounded viewer keeps the ' +
|
|
122
|
+
'socket open with its JS suspended; the queue lived in hub-main old_space)', {
|
|
123
|
+
meta: {
|
|
124
|
+
...this.describe(connection),
|
|
125
|
+
bufferedBytes: buffered,
|
|
126
|
+
overForMs,
|
|
127
|
+
boundBytes: this.maxBufferedBytes,
|
|
128
|
+
graceMs: this.graceMs,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
connection.socket.terminate();
|
|
132
|
+
}
|
|
133
|
+
describe(connection) {
|
|
134
|
+
return {
|
|
135
|
+
ip: connection.facts.ip ?? UNIDENTIFIED,
|
|
136
|
+
userAgent: connection.facts.userAgent ?? UNIDENTIFIED,
|
|
137
|
+
principal: connection.principal,
|
|
138
|
+
bytesRead: safeCount(connection.facts.bytesRead),
|
|
139
|
+
bytesWritten: safeCount(connection.facts.bytesWritten),
|
|
140
|
+
peakBufferedBytes: connection.peakBufferedBytes,
|
|
141
|
+
connectionAgeMs: this.now() - connection.openedAt,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.WsSlowConsumerGuard = WsSlowConsumerGuard;
|
|
146
|
+
function safeCount(read) {
|
|
147
|
+
try {
|
|
148
|
+
return read();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// A destroyed socket may have dropped its handle; null is "unknown", never 0.
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function errorMessage(err) {
|
|
156
|
+
return err instanceof Error ? err.message : String(err);
|
|
157
|
+
}
|
|
158
|
+
/** The facts a real `ws` upgrade request provides. */
|
|
159
|
+
function wsConnectionFacts(req) {
|
|
160
|
+
return {
|
|
161
|
+
ip: (0, client_ip_js_1.extractClientIp)(req),
|
|
162
|
+
userAgent: (0, client_ip_js_1.extractUserAgent)(req),
|
|
163
|
+
bytesRead: () => req.socket.bytesRead,
|
|
164
|
+
bytesWritten: () => req.socket.bytesWritten,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -43,7 +43,6 @@ const node_crypto_1 = require("node:crypto");
|
|
|
43
43
|
const fs = __importStar(require("node:fs"));
|
|
44
44
|
const os = __importStar(require("node:os"));
|
|
45
45
|
const path = __importStar(require("node:path"));
|
|
46
|
-
const node_url_1 = require("node:url");
|
|
47
46
|
const system_1 = require("@camstack/system");
|
|
48
47
|
const types_1 = require("@camstack/types");
|
|
49
48
|
const client_1 = require("@trpc/client");
|
|
@@ -53,8 +52,8 @@ const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
|
|
|
53
52
|
const device_meta_mirror_js_1 = require("./device-meta-mirror.js");
|
|
54
53
|
const integration_visibility_js_1 = require("./integration-visibility.js");
|
|
55
54
|
const package_dir_utils_1 = require("./package-dir-utils");
|
|
55
|
+
const forked_custom_actions_js_1 = require("./forked-custom-actions.js");
|
|
56
56
|
const prune_misplaced_addons_js_1 = require("./prune-misplaced-addons.js");
|
|
57
|
-
const require_cache_js_1 = require("./require-cache.js");
|
|
58
57
|
const runner_convergence_1 = require("./runner-convergence");
|
|
59
58
|
const runner_spawn_fanout_js_1 = require("./runner-spawn-fanout.js");
|
|
60
59
|
/**
|
|
@@ -269,10 +268,14 @@ class AddonRegistryService {
|
|
|
269
268
|
// through this from the `api.addons.custom` tRPC procedure.
|
|
270
269
|
customActionRegistry = new system_1.CustomActionRegistry();
|
|
271
270
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
271
|
+
* Fingerprint of the custom-action catalogs each UDS child last DESCRIBED
|
|
272
|
+
* (keyed by child id), and the addon ids registered from that description.
|
|
273
|
+
* A re-register carrying the same catalogs is a no-op with no line; a child
|
|
274
|
+
* whose new description drops an addon has that addon's actions withdrawn.
|
|
275
|
+
* See {@link applyChildCustomActions}.
|
|
274
276
|
*/
|
|
275
|
-
|
|
277
|
+
childCatalogFingerprints = new Map();
|
|
278
|
+
childCatalogAddons = new Map();
|
|
276
279
|
/**
|
|
277
280
|
* AddonIds whose group-runner disconnect is operator-initiated (update /
|
|
278
281
|
* restart / uninstall). The Moleculer `$node.disconnected` handler skips
|
|
@@ -2375,6 +2378,11 @@ class AddonRegistryService {
|
|
|
2375
2378
|
wireCapabilityConsumers() {
|
|
2376
2379
|
if (!this.capabilityRegistry)
|
|
2377
2380
|
return;
|
|
2381
|
+
// A forked runner's custom-action catalogs arrive in its register frame,
|
|
2382
|
+
// not as a capability event — a non-device runner's post-init register
|
|
2383
|
+
// carries the same caps as its pre-init one, so no `provider-registered`
|
|
2384
|
+
// event marks the moment the catalogs exist. See `applyChildCustomActions`.
|
|
2385
|
+
this.moleculer.onChildCustomActions((child) => this.applyChildCustomActions(child));
|
|
2378
2386
|
this.eventBusService.subscribe({ category: 'capability:provider-registered' }, (event) => {
|
|
2379
2387
|
const rawCapability = event.data['capability'];
|
|
2380
2388
|
const rawAddonId = event.data['addonId'];
|
|
@@ -3100,12 +3108,12 @@ class AddonRegistryService {
|
|
|
3100
3108
|
catch (err) {
|
|
3101
3109
|
throw new Error(`Failed to spawn runner "${runnerId}" (${addons.length} addons): ${(0, types_1.errMsg)(err)}`, { cause: err });
|
|
3102
3110
|
}
|
|
3103
|
-
//
|
|
3104
|
-
//
|
|
3105
|
-
//
|
|
3106
|
-
//
|
|
3111
|
+
// Provider registration for cap methods is handled by
|
|
3112
|
+
// `CapabilityBridge.onProviderConnected` once the runner's INFO heartbeat
|
|
3113
|
+
// lands, and the custom-action catalogs arrive the same way — in the
|
|
3114
|
+
// runner's post-init register frame, applied by `applyChildCustomActions`.
|
|
3115
|
+
// Nothing about the runner is read from its bundle here (D444).
|
|
3107
3116
|
for (const { addonId } of addons) {
|
|
3108
|
-
await this.registerForkedAddonCustomActions(addonId, runnerId);
|
|
3109
3117
|
// Mark the entry initialized so the in-process core-builtin boot
|
|
3110
3118
|
// passes skip it (those passes only touch `@camstack/system`).
|
|
3111
3119
|
const entry = this.addonEntries.get(addonId);
|
|
@@ -3207,146 +3215,78 @@ class AddonRegistryService {
|
|
|
3207
3215
|
}
|
|
3208
3216
|
this.recentGroupRespawns.set(runnerId, Date.now());
|
|
3209
3217
|
}
|
|
3210
|
-
// 3. Bookkeeping for EVERY roster member.
|
|
3211
|
-
//
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3218
|
+
// 3. Bookkeeping for EVERY roster member. Mark each member initialized so
|
|
3219
|
+
// the in-process core-builtin boot passes skip them. The custom-action
|
|
3220
|
+
// catalogs are NOT touched here: the respawned runner describes them in
|
|
3221
|
+
// its post-init register frame and `applyChildCustomActions` replaces
|
|
3222
|
+
// whatever the previous incarnation registered (D444).
|
|
3215
3223
|
for (const { addonId: memberId } of roster) {
|
|
3216
|
-
await this.registerForkedAddonCustomActions(memberId, runnerId);
|
|
3217
3224
|
const memberEntry = this.addonEntries.get(memberId);
|
|
3218
3225
|
if (memberEntry)
|
|
3219
3226
|
memberEntry.initialized = true;
|
|
3220
3227
|
}
|
|
3221
3228
|
}
|
|
3222
3229
|
/**
|
|
3223
|
-
*
|
|
3224
|
-
*
|
|
3230
|
+
* Apply the custom-action catalogs a UDS child DESCRIBED in its register
|
|
3231
|
+
* frame (`RegisteredChild.customActions`, schemas stripped) to the hub-wide
|
|
3232
|
+
* `CustomActionRegistry`, dispatching each action back to that child over
|
|
3233
|
+
* the addon-call plane.
|
|
3225
3234
|
*
|
|
3226
|
-
*
|
|
3227
|
-
* addon module's `customActions` named export — the handler dispatches
|
|
3228
|
-
* over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
|
|
3229
|
-
* {target:'custom', action, args})` (F3 — replaces the removed per-addon
|
|
3230
|
-
* Moleculer `custom.<action>` action), so the only divergence vs an
|
|
3231
|
-
* in-process addon is the transport, exactly like cap methods. The hub's
|
|
3232
|
-
* `CustomActionRegistry` validates input/output around this dispatch.
|
|
3235
|
+
* ## Why the child describes, and the hub never reads the bundle
|
|
3233
3236
|
*
|
|
3234
|
-
*
|
|
3235
|
-
*
|
|
3236
|
-
* `
|
|
3237
|
-
*
|
|
3238
|
-
*
|
|
3239
|
-
*
|
|
3240
|
-
*
|
|
3237
|
+
* Until D444 this was `registerForkedAddonCustomActions`: purge the require
|
|
3238
|
+
* cache, re-import the addon's entry INSIDE hub-main, read its
|
|
3239
|
+
* `customActions` export. Measured 2026-09-10 on the live hub: 9.3 MB of
|
|
3240
|
+
* `addon-pipeline-orchestrator/dist/index.js` evaluated in hub-main by a
|
|
3241
|
+
* sampling heap profile, ten deploys in the process's 23 h life, NONE of
|
|
3242
|
+
* which exported a catalog — and the only line saying so was at `debug`,
|
|
3243
|
+
* which the hub does not write. Every evaluation pins whatever module-scope
|
|
3244
|
+
* state the evicted graph left behind, and the bundle-reload guard existed
|
|
3245
|
+
* only to keep that re-execution survivable (D284). The runner is the
|
|
3246
|
+
* process that owns the addon; it describes its catalog once its init loop
|
|
3247
|
+
* has produced it, in the post-init register frame, and validates the
|
|
3248
|
+
* action's input and output itself (`child-addon-call-dispatch.ts`).
|
|
3241
3249
|
*
|
|
3242
|
-
*
|
|
3243
|
-
*
|
|
3250
|
+
* ## What one description does
|
|
3251
|
+
*
|
|
3252
|
+
* - identical to the child's last description → nothing, silently (a
|
|
3253
|
+
* native-cap change or reconnect re-sends the same frame);
|
|
3254
|
+
* - otherwise every addon in it is (re-)registered — an EMPTY catalog
|
|
3255
|
+
* withdraws that addon's actions — and an addon the previous description
|
|
3256
|
+
* named but this one does not is withdrawn too;
|
|
3257
|
+
* - and ONE `info` line per applied description names what it produced,
|
|
3258
|
+
* including "nothing": a load that produces nothing and says nothing is
|
|
3259
|
+
* the branch D391 forbids.
|
|
3244
3260
|
*/
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
// entries resolvable.
|
|
3249
|
-
this.customActionRegistry.unregisterAddon(addonId);
|
|
3250
|
-
const entry = this.addonEntries.get(addonId);
|
|
3251
|
-
const addonDir = entry?.addonDir;
|
|
3252
|
-
const declarationEntry = entry?.declaration?.entry;
|
|
3253
|
-
if (!addonDir || !declarationEntry)
|
|
3254
|
-
return;
|
|
3255
|
-
// Resolve the built entry the same way `AddonLoader.loadDeclaration`
|
|
3256
|
-
// does: `./src/x.ts` → `dist/x.js`, with index.js fallbacks.
|
|
3257
|
-
const entryFile = declarationEntry
|
|
3258
|
-
.replace(/^\.\//, '')
|
|
3259
|
-
.replace(/^src\//, 'dist/')
|
|
3260
|
-
.replace(/\.ts$/, '.js');
|
|
3261
|
-
let entryPath = path.resolve(addonDir, entryFile);
|
|
3262
|
-
if (!fs.existsSync(entryPath)) {
|
|
3263
|
-
const base = entryPath.replace(/\.(js|cjs|mjs)$/, '');
|
|
3264
|
-
const alternatives = [
|
|
3265
|
-
`${base}.cjs`,
|
|
3266
|
-
`${base}.mjs`,
|
|
3267
|
-
path.resolve(addonDir, 'dist', 'index.js'),
|
|
3268
|
-
path.resolve(addonDir, 'dist', 'index.cjs'),
|
|
3269
|
-
path.resolve(addonDir, 'dist', 'index.mjs'),
|
|
3270
|
-
path.resolve(addonDir, declarationEntry),
|
|
3271
|
-
];
|
|
3272
|
-
entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath;
|
|
3273
|
-
}
|
|
3274
|
-
if (!fs.existsSync(entryPath))
|
|
3275
|
-
return;
|
|
3276
|
-
let catalog;
|
|
3277
|
-
try {
|
|
3278
|
-
// ── The query string does NOT bust a CommonJS bundle ──────────────
|
|
3279
|
-
//
|
|
3280
|
-
// Every addon here builds to CJS, and Node's ESM loader serves a CJS
|
|
3281
|
-
// module out of the REQUIRE cache, which is keyed by the resolved
|
|
3282
|
-
// filename with the query stripped. So `?t=<now>` below busts nothing
|
|
3283
|
-
// for them: this function re-registered the catalog captured at hub boot
|
|
3284
|
-
// on every restart, forever.
|
|
3285
|
-
//
|
|
3286
|
-
// The consequence was invisible and total: an EXISTING action kept
|
|
3287
|
-
// working, so nothing looked broken, while a NEWLY added one could never
|
|
3288
|
-
// become reachable without restarting the whole hub process — which
|
|
3289
|
-
// quietly falsifies the reason bridge actions exist ("no codegen, no
|
|
3290
|
-
// republish, no train"). Found 2026-08-08, deploying `nc.injectTestEvent`:
|
|
3291
|
-
// the deploy succeeded, the restart logged "custom actions registered",
|
|
3292
|
-
// and the action 404'd.
|
|
3293
|
-
//
|
|
3294
|
-
// Dropping the addon's own modules from the require cache is what
|
|
3295
|
-
// actually re-reads the bundle — see `require-cache.ts` for the scoping
|
|
3296
|
-
// and the real-path match Node's cache keys demand.
|
|
3297
|
-
//
|
|
3298
|
-
// The purge mutates the PROCESS-GLOBAL require cache, so purge-then-import
|
|
3299
|
-
// must stay atomic per BUNDLE now that the boot plan overlaps (D171):
|
|
3300
|
-
// `@camstack/addon-remote-storage` ships four addon ids whose entries all
|
|
3301
|
-
// resolve into one `dist/`, and a sibling's purge landing mid-import would
|
|
3302
|
-
// evict a module graph while it is being evaluated. Different bundles
|
|
3303
|
-
// still load concurrently.
|
|
3304
|
-
//
|
|
3305
|
-
// The scope is the BUNDLE (`<addonDir>/dist`), never `dirname(entryPath)`
|
|
3306
|
-
// — see `bundleGraphRootFor`. The narrow scope evicted the entry chunk and
|
|
3307
|
-
// kept the shared chunks, so the re-import re-ran the entry's module-scope
|
|
3308
|
-
// declarations against singletons that had survived, and took the broker
|
|
3309
|
-
// down on 2026-08-29 (D284). The serializer key MUST match the purge
|
|
3310
|
-
// scope: two addon ids of one package now purge the same graph, so
|
|
3311
|
-
// serializing them per entry-folder would let one evict the other's
|
|
3312
|
-
// modules mid-evaluation — the exact race the key exists to prevent.
|
|
3313
|
-
const graphRoot = (0, require_cache_js_1.bundleGraphRootFor)(addonDir, entryPath);
|
|
3314
|
-
const modUnknown = await this.moduleLoadSerializer.run(graphRoot, async () => {
|
|
3315
|
-
(0, require_cache_js_1.purgeRequireCacheUnder)(graphRoot);
|
|
3316
|
-
// Kept for a genuinely ESM addon entry, where it IS the mechanism.
|
|
3317
|
-
const cacheBustedUrl = `${(0, node_url_1.pathToFileURL)(entryPath).href}?t=${Date.now()}`;
|
|
3318
|
-
// A plain `await import()` here is downleveled by tsc (the backend builds
|
|
3319
|
-
// with `module: CommonJS`) into a `require()`-based shim. `require()` then
|
|
3320
|
-
// treats the `?t=…` cache-bust query as part of the literal filename and
|
|
3321
|
-
// throws MODULE_NOT_FOUND for EVERY forked addon — so no forked addon's
|
|
3322
|
-
// custom-action catalog ever loaded. Use a native dynamic import the
|
|
3323
|
-
// compiler won't rewrite, so Node's ESM loader handles the query string.
|
|
3324
|
-
const nativeImport = new Function('specifier', 'return import(specifier)');
|
|
3325
|
-
return nativeImport(cacheBustedUrl);
|
|
3326
|
-
});
|
|
3327
|
-
catalog =
|
|
3328
|
-
modUnknown && typeof modUnknown === 'object'
|
|
3329
|
-
? modUnknown['customActions']
|
|
3330
|
-
: undefined;
|
|
3331
|
-
}
|
|
3332
|
-
catch (err) {
|
|
3333
|
-
this.logger.warn('Failed to load custom-action catalog for forked addon', {
|
|
3334
|
-
tags: { addonId },
|
|
3335
|
-
meta: { error: (0, types_1.errMsg)(err) },
|
|
3336
|
-
});
|
|
3261
|
+
applyChildCustomActions(child) {
|
|
3262
|
+
const catalogs = child.customActions;
|
|
3263
|
+
if (catalogs === undefined)
|
|
3337
3264
|
return;
|
|
3338
|
-
|
|
3339
|
-
if (
|
|
3340
|
-
this.logger.debug('Forked addon exports no custom actions', {
|
|
3341
|
-
tags: { addonId },
|
|
3342
|
-
meta: { runnerId },
|
|
3343
|
-
});
|
|
3265
|
+
const fingerprint = (0, forked_custom_actions_js_1.childCatalogsFingerprint)(catalogs);
|
|
3266
|
+
if (this.childCatalogFingerprints.get(child.childId) === fingerprint)
|
|
3344
3267
|
return;
|
|
3268
|
+
this.childCatalogFingerprints.set(child.childId, fingerprint);
|
|
3269
|
+
const described = new Set(Object.keys(catalogs));
|
|
3270
|
+
for (const previous of this.childCatalogAddons.get(child.childId) ?? []) {
|
|
3271
|
+
if (!described.has(previous))
|
|
3272
|
+
this.customActionRegistry.unregisterAddon(previous);
|
|
3273
|
+
}
|
|
3274
|
+
this.childCatalogAddons.set(child.childId, described);
|
|
3275
|
+
for (const [addonId, descriptor] of Object.entries(catalogs)) {
|
|
3276
|
+
if (Object.keys(descriptor).length === 0) {
|
|
3277
|
+
this.customActionRegistry.unregisterAddon(addonId);
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
this.customActionRegistry.registerAddon(addonId, (0, forked_custom_actions_js_1.toForkedCustomActionsSpec)(descriptor), (action, input, caller) => this.dispatchForkedCustomAction(addonId, action, input, caller));
|
|
3345
3281
|
}
|
|
3346
|
-
|
|
3347
|
-
this.logger.info('
|
|
3348
|
-
|
|
3349
|
-
|
|
3282
|
+
const summary = (0, forked_custom_actions_js_1.summarizeChildCatalogs)(catalogs);
|
|
3283
|
+
this.logger.info('UDS child custom-action catalogs applied', {
|
|
3284
|
+
meta: {
|
|
3285
|
+
childId: child.childId,
|
|
3286
|
+
incarnation: child.incarnation,
|
|
3287
|
+
registered: summary.withActions.length > 0 ? summary.withActions.join(',') : '(none)',
|
|
3288
|
+
withoutActions: summary.withoutActions.length > 0 ? summary.withoutActions.join(',') : '(none)',
|
|
3289
|
+
},
|
|
3350
3290
|
});
|
|
3351
3291
|
}
|
|
3352
3292
|
/**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toForkedCustomActionsSpec = toForkedCustomActionsSpec;
|
|
4
|
+
exports.childCatalogsFingerprint = childCatalogsFingerprint;
|
|
5
|
+
exports.summarizeChildCatalogs = summarizeChildCatalogs;
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
/**
|
|
8
|
+
* Build the hub-side catalog for a forked addon.
|
|
9
|
+
*
|
|
10
|
+
* `input`/`output` are `z.unknown()` ON PURPOSE: the hub's
|
|
11
|
+
* `dispatchCustomAction` parses input and output around the dispatch, and for
|
|
12
|
+
* a forked addon both parses must be passthroughs — the runner owns the
|
|
13
|
+
* schemas and refuses what they refuse. `kind`, `auth`, `scope` and `caller`
|
|
14
|
+
* are the fields the hub actually gates on, and they arrive intact.
|
|
15
|
+
*/
|
|
16
|
+
function toForkedCustomActionsSpec(descriptor) {
|
|
17
|
+
const spec = {};
|
|
18
|
+
for (const [name, action] of Object.entries(descriptor)) {
|
|
19
|
+
spec[name] = {
|
|
20
|
+
input: zod_1.z.unknown(),
|
|
21
|
+
output: zod_1.z.unknown(),
|
|
22
|
+
kind: action.kind,
|
|
23
|
+
auth: action.auth,
|
|
24
|
+
scope: action.scope,
|
|
25
|
+
...(action.caller === 'required' ? { caller: 'required' } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return spec;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A stable identity for a child's whole catalog set, so a re-register that
|
|
32
|
+
* carries the SAME catalogs (native-cap change, reconnect — 246 re-registers in
|
|
33
|
+
* 30 minutes with zero respawns, measured 2026-08-25) registers nothing and
|
|
34
|
+
* logs nothing. Key order is normalised at every level: two descriptions of
|
|
35
|
+
* one catalog must never fingerprint differently because a runner enumerated
|
|
36
|
+
* its map in another order.
|
|
37
|
+
*/
|
|
38
|
+
function childCatalogsFingerprint(catalogs) {
|
|
39
|
+
return JSON.stringify(sortKeysDeep(catalogs));
|
|
40
|
+
}
|
|
41
|
+
function sortKeysDeep(value) {
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value.map(sortKeysDeep);
|
|
44
|
+
if (value === null || typeof value !== 'object')
|
|
45
|
+
return value;
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const key of Object.keys(value).sort()) {
|
|
48
|
+
out[key] = sortKeysDeep(Reflect.get(value, key));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function summarizeChildCatalogs(catalogs) {
|
|
53
|
+
const withActions = [];
|
|
54
|
+
const withoutActions = [];
|
|
55
|
+
for (const addonId of Object.keys(catalogs).sort()) {
|
|
56
|
+
const count = Object.keys(catalogs[addonId] ?? {}).length;
|
|
57
|
+
if (count > 0)
|
|
58
|
+
withActions.push(`${addonId}:${count}`);
|
|
59
|
+
else
|
|
60
|
+
withoutActions.push(addonId);
|
|
61
|
+
}
|
|
62
|
+
return { withActions, withoutActions };
|
|
63
|
+
}
|
|
@@ -49,6 +49,15 @@ class MoleculerService {
|
|
|
49
49
|
* See {@link ChildManifestGate}.
|
|
50
50
|
*/
|
|
51
51
|
childManifestGate = new ChildManifestGate();
|
|
52
|
+
/**
|
|
53
|
+
* Receives every child register frame that DESCRIBES its custom-action
|
|
54
|
+
* catalogs (the post-init one and every re-register after it). Consulted
|
|
55
|
+
* BEFORE {@link ChildManifestGate}: the gate compares caps, and a post-init
|
|
56
|
+
* register of a non-device runner carries the same caps as its pre-init one
|
|
57
|
+
* — skipping it would lose the only frame that carries the catalogs (D444).
|
|
58
|
+
* The consumer (`AddonRegistryService`) dedupes by catalog fingerprint.
|
|
59
|
+
*/
|
|
60
|
+
childCustomActionsHandler = null;
|
|
52
61
|
/**
|
|
53
62
|
* Fixed-period agent-readiness snapshot sweep (D8 reconcile) — repairs
|
|
54
63
|
* agent-origin readiness deltas lost while the agent stayed connected.
|
|
@@ -126,6 +135,14 @@ class MoleculerService {
|
|
|
126
135
|
get childRegistry() {
|
|
127
136
|
return this.localChildRegistry;
|
|
128
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Register the (single) handler for a child's described custom-action
|
|
140
|
+
* catalogs. See {@link childCustomActionsHandler}. Called once by
|
|
141
|
+
* `AddonRegistryService` when it wires its consumers.
|
|
142
|
+
*/
|
|
143
|
+
onChildCustomActions(handler) {
|
|
144
|
+
this.childCustomActionsHandler = handler;
|
|
145
|
+
}
|
|
129
146
|
/** The CapRouteResolver once onModuleInit has completed; null before that. */
|
|
130
147
|
get capRouteResolver() {
|
|
131
148
|
return this.resolver;
|
|
@@ -437,6 +454,20 @@ class MoleculerService {
|
|
|
437
454
|
registry.onChildRegistered((child) => {
|
|
438
455
|
const hubNodeId = this.brokerSafe.nodeID;
|
|
439
456
|
const childNodeId = `${hubNodeId}/${child.childId}`;
|
|
457
|
+
// The catalogs first, and unconditionally: the manifest gate below
|
|
458
|
+
// judges CAPS, and a frame whose caps are unchanged may still be the
|
|
459
|
+
// one frame that describes the catalogs. An absent field is a pre-init
|
|
460
|
+
// register and says nothing about actions — it is not forwarded.
|
|
461
|
+
if (child.customActions !== undefined) {
|
|
462
|
+
try {
|
|
463
|
+
this.childCustomActionsHandler?.(child);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
logger.warn('UDS child custom-action catalogs could not be applied', {
|
|
467
|
+
meta: { nodeId: childNodeId, incarnation: child.incarnation, error: (0, types_1.errMsg)(err) },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
440
471
|
const params = buildChildUdsManifest(childNodeId, child.childId, child.caps);
|
|
441
472
|
// `updateCaps()` re-sends the whole register frame on the same socket,
|
|
442
473
|
// so this fires far more often than a runner starts: 246 rebuilds in 30
|
|
@@ -132,6 +132,11 @@ class UpdateAvailabilityEmitter {
|
|
|
132
132
|
nodeId: scope,
|
|
133
133
|
}));
|
|
134
134
|
const head = list[0];
|
|
135
|
+
// `target: 'wrapper'` carries WHICH shell, and it has to survive the
|
|
136
|
+
// rebuild here or the notification loses the only thing that makes it
|
|
137
|
+
// actionable — a docker node would be told to press a button that cannot
|
|
138
|
+
// exist. Absent for every other target.
|
|
139
|
+
const wrapperKind = head.wrapperKind;
|
|
135
140
|
this.eventBus.emit({
|
|
136
141
|
id: `update.available:${target}:${scope}:${packages
|
|
137
142
|
.map((pkg) => `${pkg.packageName}@${pkg.latestVersion}`)
|
|
@@ -148,6 +153,7 @@ class UpdateAvailabilityEmitter {
|
|
|
148
153
|
packages,
|
|
149
154
|
nodeId: scope,
|
|
150
155
|
nodeIds: [scope],
|
|
156
|
+
...(wrapperKind !== undefined ? { wrapperKind } : {}),
|
|
151
157
|
},
|
|
152
158
|
});
|
|
153
159
|
}
|
|
@@ -98,6 +98,9 @@ class UpdateCheckScheduler {
|
|
|
98
98
|
continue;
|
|
99
99
|
}
|
|
100
100
|
await this.attempt('node server package', node.id, () => this.targets.checkNodeServerUpdate(node.id, node.isHub));
|
|
101
|
+
// The shell, on EVERY node role — the hub is the one this failure was
|
|
102
|
+
// found on.
|
|
103
|
+
await this.attempt('node wrapper', node.id, () => this.targets.checkWrapperUpdate(node.id, node.isHub));
|
|
101
104
|
if (node.isHub)
|
|
102
105
|
continue;
|
|
103
106
|
await this.attempt('agent addon packages', node.id, () => this.targets.checkAgentAddons(node.id));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WrapperUpdateChecker = exports.WRAPPER_PACKAGE_NAME = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The availability row's package name. A shell is not an npm package, but the
|
|
6
|
+
* emitter keys dedup on `(target, node, packageName)` and a stable literal is
|
|
7
|
+
* what makes "one shell per node" the dedup slot. Never rendered: the
|
|
8
|
+
* notification body is chosen from the wrapper KIND.
|
|
9
|
+
*/
|
|
10
|
+
exports.WRAPPER_PACKAGE_NAME = 'camstack-wrapper';
|
|
11
|
+
class WrapperUpdateChecker {
|
|
12
|
+
emitter;
|
|
13
|
+
logger;
|
|
14
|
+
readStatus;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.emitter = options.emitter;
|
|
17
|
+
this.logger = options.logger;
|
|
18
|
+
this.readStatus = options.readStatus;
|
|
19
|
+
}
|
|
20
|
+
async check(nodeId, isHub) {
|
|
21
|
+
const status = await this.readStatus(nodeId, isHub);
|
|
22
|
+
const wrapper = status.wrapper;
|
|
23
|
+
const contract = status.imageContract;
|
|
24
|
+
if (wrapper === undefined || contract === undefined) {
|
|
25
|
+
// Version skew: this node's `@camstack/server` predates the wrapper
|
|
26
|
+
// field. Unanswerable, so it is an ERROR — reporting it as "no wrapper
|
|
27
|
+
// update" would be a claim nothing checked.
|
|
28
|
+
throw new Error(`node ${nodeId} reports no wrapper identity (server-management payload predates it)`);
|
|
29
|
+
}
|
|
30
|
+
if (contract.state === 'in-sync') {
|
|
31
|
+
// The one case where an empty snapshot is a fact rather than a guess.
|
|
32
|
+
this.emitter.publishSnapshot('wrapper', [], nodeId);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const seedVersion = contract.seedVersion;
|
|
36
|
+
const contractVersion = contract.contractVersion;
|
|
37
|
+
const behind = contract.state === 'behind-patch' || contract.state === 'behind-series';
|
|
38
|
+
if (!behind || seedVersion === null || contractVersion === null) {
|
|
39
|
+
// `unknown` (no seed, or no registry answer yet) and `ahead` (a stale
|
|
40
|
+
// check) both mean "nothing was proved". Publishing an empty snapshot
|
|
41
|
+
// would clear a standing announcement on no evidence at all.
|
|
42
|
+
this.logger.warn('Wrapper check inconclusive — nothing published', {
|
|
43
|
+
tags: { nodeId },
|
|
44
|
+
meta: { state: contract.state, wrapperKind: wrapper.kind, seedVersion, contractVersion },
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const candidate = {
|
|
49
|
+
target: 'wrapper',
|
|
50
|
+
packageName: exports.WRAPPER_PACKAGE_NAME,
|
|
51
|
+
// The SHELL's versions, not the running code's. `wrapper.currentVersion`
|
|
52
|
+
// is what the shell says about ITSELF (an Electron app version), which
|
|
53
|
+
// is not on the same scale as a release; the seed is, and the seed is
|
|
54
|
+
// what `contractVersion` was compared against.
|
|
55
|
+
currentVersion: seedVersion,
|
|
56
|
+
latestVersion: contractVersion,
|
|
57
|
+
nodeId,
|
|
58
|
+
wrapperKind: wrapper.kind,
|
|
59
|
+
};
|
|
60
|
+
this.emitter.publishSnapshot('wrapper', [candidate], nodeId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.WrapperUpdateChecker = WrapperUpdateChecker;
|
package/dist/main.js
CHANGED
|
@@ -79,6 +79,7 @@ const trpc_router_1 = require("./api/trpc/trpc.router");
|
|
|
79
79
|
const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
|
|
80
80
|
const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
|
|
81
81
|
const ws_request_census_1 = require("./api/trpc/ws-request-census");
|
|
82
|
+
const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
|
|
82
83
|
const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
|
|
83
84
|
const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
|
|
84
85
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
@@ -302,6 +303,16 @@ async function bootstrap() {
|
|
|
302
303
|
socketPlane: (0, system_1.createSocketPlaneReader)({
|
|
303
304
|
registry: () => moleculerForEventPlane?.childRegistry ?? null,
|
|
304
305
|
}),
|
|
306
|
+
// The ArrayBuffer census, armed by the probe itself. Twice on 2026-09-10
|
|
307
|
+
// this process's `arrayBuffers` climbed linearly for an hour (to 1.3 GB
|
|
308
|
+
// and to 5 GB, both LIVE across a forced compaction) and released in one
|
|
309
|
+
// instant, with no line at either boundary; the measurement that names
|
|
310
|
+
// the shape existed and was never run because it needed a human awake
|
|
311
|
+
// during the episode. Now the heartbeat takes it: one forced full GC —
|
|
312
|
+
// the pause a reclaim pass already costs — walked in memory, nothing
|
|
313
|
+
// written, on a threshold held for a minute, at most twice an hour.
|
|
314
|
+
// It is NOT a heap snapshot; see `array-buffer-census.ts`.
|
|
315
|
+
arrayBufferCensus: { census: (0, system_1.createInspectorArrayBufferCensus)() },
|
|
305
316
|
});
|
|
306
317
|
// Clean up orphaned processes from previous crashes before starting
|
|
307
318
|
cleanupOrphanProcesses();
|
|
@@ -1223,18 +1234,39 @@ async function bootstrap() {
|
|
|
1223
1234
|
// call. The frame is the only place an operation is observable from
|
|
1224
1235
|
// outside the adapter — see `ws-request-census.ts`. Registered before
|
|
1225
1236
|
// `applyWSSHandler` so no socket can be accepted without a session.
|
|
1237
|
+
// A subscriber that stops READING is disconnected, not buffered until the
|
|
1238
|
+
// hub dies. On 2026-09-10 the only WS client (the operator's phone, viewer
|
|
1239
|
+
// in the background, JS suspended, TCP open) queued 88 MB of JSON strings
|
|
1240
|
+
// in hub-main's old_space with no bound and no line. See
|
|
1241
|
+
// `ws-slow-consumer-guard.ts` for the policy and the alternatives it beat.
|
|
1242
|
+
const wsSlowConsumerGuard = new ws_slow_consumer_guard_js_1.WsSlowConsumerGuard({
|
|
1243
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'),
|
|
1244
|
+
});
|
|
1245
|
+
wsSlowConsumerGuard.start();
|
|
1226
1246
|
wss.on('connection', (client, req) => {
|
|
1227
1247
|
(0, ws_request_census_1.attachWsCensusSession)(httpRequestCensus, client, req, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
|
|
1248
|
+
wsSlowConsumerGuard.attach(client, (0, ws_slow_consumer_guard_js_1.wsConnectionFacts)(req));
|
|
1228
1249
|
});
|
|
1229
1250
|
(0, ws_1.applyWSSHandler)({
|
|
1230
1251
|
wss,
|
|
1231
1252
|
router: appRouter,
|
|
1253
|
+
// The complement of the bound above, for the SUSPENDED client: a PING is
|
|
1254
|
+
// a text frame the client's JS must answer, and a backgrounded app's JS
|
|
1255
|
+
// does not run — so the socket is terminated ~40 s into the background
|
|
1256
|
+
// instead of accumulating for the night, and the app reconnects on
|
|
1257
|
+
// return (every WS client of this hub is `@trpc/client` >= 11, which
|
|
1258
|
+
// answers PING with PONG; the viewer pins ^11.16). The bound stays: it
|
|
1259
|
+
// covers a client whose JS runs but whose link cannot drain the events.
|
|
1260
|
+
// A termination here closes with 1006; the guard's close line names it.
|
|
1261
|
+
keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 },
|
|
1232
1262
|
createContext: async (opts) => {
|
|
1233
1263
|
const wsCtx = await (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
|
|
1234
1264
|
// The connection's identity, derived ONCE and reused for every
|
|
1235
1265
|
// operation on this socket. Frames that arrived while the bearer was
|
|
1236
1266
|
// still resolving are held by the session and released here.
|
|
1237
|
-
|
|
1267
|
+
const principal = (0, trpc_error_principal_1.describeTrpcPrincipal)(wsCtx.user);
|
|
1268
|
+
(0, ws_request_census_1.identifyWsCensusSession)(opts.res, principal);
|
|
1269
|
+
wsSlowConsumerGuard.identify(opts.res, principal);
|
|
1238
1270
|
return wsCtx;
|
|
1239
1271
|
},
|
|
1240
1272
|
onError: ({ path: trpcPath, error, ctx, input, }) => {
|
package/dist/manual-boot.js
CHANGED
|
@@ -77,9 +77,11 @@ const server_update_service_1 = require("./core/server-update/server-update.serv
|
|
|
77
77
|
const storage_service_1 = require("./core/storage/storage.service");
|
|
78
78
|
const stream_probe_service_1 = require("./core/streaming/stream-probe.service");
|
|
79
79
|
const topology_emitter_service_1 = require("./core/topology/topology-emitter.service");
|
|
80
|
+
const update_availability_emitter_js_1 = require("./core/update-availability-emitter.js");
|
|
80
81
|
const update_availability_store_js_1 = require("./core/update-availability-store.js");
|
|
81
82
|
const agent_installed_packages_js_1 = require("./core/updates/agent-installed-packages.js");
|
|
82
83
|
const update_check_scheduler_js_1 = require("./core/updates/update-check-scheduler.js");
|
|
84
|
+
const wrapper_update_checker_js_1 = require("./core/updates/wrapper-update-checker.js");
|
|
83
85
|
// ---------------------------------------------------------------------------
|
|
84
86
|
// Service container — narrowing via `instanceof`, no casts.
|
|
85
87
|
// ---------------------------------------------------------------------------
|
|
@@ -252,6 +254,25 @@ async function bootManual(opts) {
|
|
|
252
254
|
// channel — that gate is why the only periodic publisher was dead on every
|
|
253
255
|
// live hub (`{"channel":"off"}`). See UpdateCheckScheduler.
|
|
254
256
|
const updateCheckLogger = loggingService.createLogger('UpdateCheck');
|
|
257
|
+
// The fifth target: the SHELL each node runs inside. Its own emitter scope,
|
|
258
|
+
// so a shell announcement and a code announcement never overwrite each
|
|
259
|
+
// other's dedup state — they are different facts about the same node (D437).
|
|
260
|
+
const wrapperUpdateChecker = new wrapper_update_checker_js_1.WrapperUpdateChecker({
|
|
261
|
+
logger: updateCheckLogger,
|
|
262
|
+
emitter: new update_availability_emitter_js_1.UpdateAvailabilityEmitter(eventBusService, { type: 'core', id: 'wrapper-update-check' }, new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'wrapper-update', loggingService.createLogger('UpdateAvailability'))),
|
|
263
|
+
readStatus: async (nodeId, isHub) => {
|
|
264
|
+
if (isHub)
|
|
265
|
+
return serverUpdateService.getServerPackageStatus();
|
|
266
|
+
const proxy = moleculerService.createCapabilityProxy('server-management', nodeId);
|
|
267
|
+
if (proxy === null) {
|
|
268
|
+
// Unreachable is not "the image is fine". Reject so the scheduler
|
|
269
|
+
// records a failed check instead of a clean bill.
|
|
270
|
+
throw new Error(`server-management unreachable on ${nodeId}`);
|
|
271
|
+
}
|
|
272
|
+
const status = await proxy['getServerPackageStatus']?.({});
|
|
273
|
+
return types_1.ServerPackageStatusSchema.parse(status);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
255
276
|
const updateCheckScheduler = new update_check_scheduler_js_1.UpdateCheckScheduler({
|
|
256
277
|
logger: updateCheckLogger,
|
|
257
278
|
getIntervalSeconds: () => addonPackageService.getAutoUpdateSettings().updateCheckIntervalSeconds,
|
|
@@ -278,6 +299,7 @@ async function bootManual(opts) {
|
|
|
278
299
|
}
|
|
279
300
|
return proxy['checkServerUpdate']?.({});
|
|
280
301
|
},
|
|
302
|
+
checkWrapperUpdate: (nodeId, isHub) => wrapperUpdateChecker.check(nodeId, isHub),
|
|
281
303
|
},
|
|
282
304
|
});
|
|
283
305
|
addonPackageService.setUpdateCheckRescheduler(() => updateCheckScheduler.reschedule());
|
|
@@ -77,6 +77,7 @@ var require_dist = __commonJS({
|
|
|
77
77
|
DEV_UPLOADS_DIRNAME: /* @__PURE__ */ __name(() => DEV_UPLOADS_DIRNAME, "DEV_UPLOADS_DIRNAME"),
|
|
78
78
|
DEV_UPLOADS_KEEP_COUNT: /* @__PURE__ */ __name(() => DEV_UPLOADS_KEEP_COUNT, "DEV_UPLOADS_KEEP_COUNT"),
|
|
79
79
|
DEV_UPLOAD_MANIFEST_FILE: /* @__PURE__ */ __name(() => DEV_UPLOAD_MANIFEST_FILE, "DEV_UPLOAD_MANIFEST_FILE"),
|
|
80
|
+
ELECTRON_APP_VERSION_ENV: /* @__PURE__ */ __name(() => ELECTRON_APP_VERSION_ENV, "ELECTRON_APP_VERSION_ENV"),
|
|
80
81
|
HOST_EXTERNAL_SPECIFIERS: /* @__PURE__ */ __name(() => HOST_EXTERNAL_SPECIFIERS, "HOST_EXTERNAL_SPECIFIERS"),
|
|
81
82
|
HUB_ROOT_SPEC: /* @__PURE__ */ __name(() => HUB_ROOT_SPEC2, "HUB_ROOT_SPEC"),
|
|
82
83
|
PENDING_ROOT_SWAP_FILE: /* @__PURE__ */ __name(() => PENDING_ROOT_SWAP_FILE, "PENDING_ROOT_SWAP_FILE"),
|
|
@@ -93,6 +94,7 @@ var require_dist = __commonJS({
|
|
|
93
94
|
currentDir: /* @__PURE__ */ __name(() => currentDir, "currentDir"),
|
|
94
95
|
currentEntryPath: /* @__PURE__ */ __name(() => currentEntryPath, "currentEntryPath"),
|
|
95
96
|
detectWorkspaceRoot: /* @__PURE__ */ __name(() => detectWorkspaceRoot, "detectWorkspaceRoot"),
|
|
97
|
+
detectWrapperIdentity: /* @__PURE__ */ __name(() => detectWrapperIdentity, "detectWrapperIdentity"),
|
|
96
98
|
devChannelEpoch: /* @__PURE__ */ __name(() => devChannelEpoch, "devChannelEpoch"),
|
|
97
99
|
devUploadManifestPath: /* @__PURE__ */ __name(() => devUploadManifestPath, "devUploadManifestPath"),
|
|
98
100
|
devUploadVersionDir: /* @__PURE__ */ __name(() => devUploadVersionDir, "devUploadVersionDir"),
|
|
@@ -110,6 +112,7 @@ var require_dist = __commonJS({
|
|
|
110
112
|
readPendingRootSwap: /* @__PURE__ */ __name(() => readPendingRootSwap, "readPendingRootSwap"),
|
|
111
113
|
readRestartIntentMarker: /* @__PURE__ */ __name(() => readRestartIntentMarker, "readRestartIntentMarker"),
|
|
112
114
|
readServerRootState: /* @__PURE__ */ __name(() => readServerRootState, "readServerRootState"),
|
|
115
|
+
readWrapperProbes: /* @__PURE__ */ __name(() => readWrapperProbes, "readWrapperProbes"),
|
|
113
116
|
registerActiveRootResolver: /* @__PURE__ */ __name(() => registerActiveRootResolver, "registerActiveRootResolver"),
|
|
114
117
|
restartIntentMarkerPath: /* @__PURE__ */ __name(() => restartIntentMarkerPath, "restartIntentMarkerPath"),
|
|
115
118
|
rootEntryPath: /* @__PURE__ */ __name(() => rootEntryPath2, "rootEntryPath"),
|
|
@@ -789,6 +792,93 @@ var require_dist = __commonJS({
|
|
|
789
792
|
};
|
|
790
793
|
}
|
|
791
794
|
__name(assessImageContract, "assessImageContract");
|
|
795
|
+
var ELECTRON_APP_VERSION_ENV = "CAMSTACK_AGENT_APP_VERSION";
|
|
796
|
+
var CONTAINER_CGROUP_MARKERS = [
|
|
797
|
+
"docker",
|
|
798
|
+
"containerd",
|
|
799
|
+
"kubepods",
|
|
800
|
+
"podman"
|
|
801
|
+
];
|
|
802
|
+
function cgroupNamesAContainer(contents) {
|
|
803
|
+
const lower = contents.toLowerCase();
|
|
804
|
+
return CONTAINER_CGROUP_MARKERS.some((marker) => lower.includes(marker));
|
|
805
|
+
}
|
|
806
|
+
__name(cgroupNamesAContainer, "cgroupNamesAContainer");
|
|
807
|
+
function cgroupDetail(contents) {
|
|
808
|
+
const lines = contents.split("\n").filter((line) => line.trim().length > 0);
|
|
809
|
+
const named = lines.find((line) => cgroupNamesAContainer(line));
|
|
810
|
+
return (named ?? lines[0] ?? "").trim().slice(0, 200);
|
|
811
|
+
}
|
|
812
|
+
__name(cgroupDetail, "cgroupDetail");
|
|
813
|
+
function detectWrapperIdentity(inputs) {
|
|
814
|
+
const { platform, dockerEnvFileExists, proc1Cgroup, electronAppVersion, seedVersion } = inputs;
|
|
815
|
+
const linux = platform === "linux";
|
|
816
|
+
const cgroupAnswered = proc1Cgroup !== null;
|
|
817
|
+
const cgroupSaysContainer = proc1Cgroup !== null && cgroupNamesAContainer(proc1Cgroup);
|
|
818
|
+
const containerObserved = dockerEnvFileExists || cgroupSaysContainer;
|
|
819
|
+
const electronDeclared = electronAppVersion !== void 0 && electronAppVersion.length > 0;
|
|
820
|
+
const containerRuledOut = !containerObserved && (cgroupAnswered || !linux);
|
|
821
|
+
const evidence = [
|
|
822
|
+
{
|
|
823
|
+
fact: "platform",
|
|
824
|
+
mode: "observed",
|
|
825
|
+
holds: linux,
|
|
826
|
+
detail: linux ? `process.platform=${platform} \u2014 a container is possible here` : `process.platform=${platform} \u2014 a linux container cannot run on this platform`
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
fact: "dockerenv-file",
|
|
830
|
+
mode: "observed",
|
|
831
|
+
holds: dockerEnvFileExists,
|
|
832
|
+
detail: dockerEnvFileExists ? "/.dockerenv exists" : "/.dockerenv not present"
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
fact: "proc-1-cgroup",
|
|
836
|
+
mode: "observed",
|
|
837
|
+
holds: cgroupSaysContainer,
|
|
838
|
+
detail: proc1Cgroup === null ? "/proc/1/cgroup unreadable \u2014 this probe did NOT answer" : cgroupDetail(proc1Cgroup)
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
fact: "electron-app-version-env",
|
|
842
|
+
mode: "declared",
|
|
843
|
+
holds: electronDeclared,
|
|
844
|
+
detail: electronDeclared ? `CAMSTACK_AGENT_APP_VERSION=${String(electronAppVersion)}` : "CAMSTACK_AGENT_APP_VERSION not set"
|
|
845
|
+
}
|
|
846
|
+
];
|
|
847
|
+
if (containerObserved && electronDeclared) {
|
|
848
|
+
return {
|
|
849
|
+
kind: "contradictory",
|
|
850
|
+
evidence,
|
|
851
|
+
currentVersion: null
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (containerObserved) {
|
|
855
|
+
return {
|
|
856
|
+
kind: "docker",
|
|
857
|
+
evidence,
|
|
858
|
+
currentVersion: seedVersion
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (electronDeclared) {
|
|
862
|
+
return {
|
|
863
|
+
kind: "electron",
|
|
864
|
+
evidence,
|
|
865
|
+
currentVersion: electronAppVersion ?? null
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (containerRuledOut) {
|
|
869
|
+
return {
|
|
870
|
+
kind: "native",
|
|
871
|
+
evidence,
|
|
872
|
+
currentVersion: seedVersion
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
kind: "unknown",
|
|
877
|
+
evidence,
|
|
878
|
+
currentVersion: null
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
__name(detectWrapperIdentity, "detectWrapperIdentity");
|
|
792
882
|
var fs4 = __toESM2(require("fs"));
|
|
793
883
|
var path4 = __toESM2(require("path"));
|
|
794
884
|
function detectWorkspaceRoot(fromDir) {
|
|
@@ -930,6 +1020,26 @@ var require_dist = __commonJS({
|
|
|
930
1020
|
var NPM_INSTALL_TIMEOUT_MS = 15 * 6e4;
|
|
931
1021
|
var RESTART_REASON_PREFIX = "server-update";
|
|
932
1022
|
var STALE_TRANSIENT_MS = 24 * 60 * 60 * 1e3;
|
|
1023
|
+
function readWrapperProbes() {
|
|
1024
|
+
let proc1Cgroup = null;
|
|
1025
|
+
try {
|
|
1026
|
+
proc1Cgroup = fs6.readFileSync("/proc/1/cgroup", "utf-8");
|
|
1027
|
+
} catch {
|
|
1028
|
+
proc1Cgroup = null;
|
|
1029
|
+
}
|
|
1030
|
+
let dockerEnvFileExists = false;
|
|
1031
|
+
try {
|
|
1032
|
+
dockerEnvFileExists = fs6.existsSync("/.dockerenv");
|
|
1033
|
+
} catch {
|
|
1034
|
+
dockerEnvFileExists = false;
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
platform: process.platform,
|
|
1038
|
+
dockerEnvFileExists,
|
|
1039
|
+
proc1Cgroup
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
__name(readWrapperProbes, "readWrapperProbes");
|
|
933
1043
|
function readPackageVersion(pkgJsonPath) {
|
|
934
1044
|
try {
|
|
935
1045
|
const raw = JSON.parse(fs6.readFileSync(pkgJsonPath, "utf-8"));
|
|
@@ -956,6 +1066,7 @@ var require_dist = __commonJS({
|
|
|
956
1066
|
execNpm;
|
|
957
1067
|
ensureNativePrebuildsFn;
|
|
958
1068
|
env;
|
|
1069
|
+
readWrapperProbesFn;
|
|
959
1070
|
now;
|
|
960
1071
|
runningPackageJsonPath;
|
|
961
1072
|
workspaceProbeDir;
|
|
@@ -983,6 +1094,7 @@ var require_dist = __commonJS({
|
|
|
983
1094
|
});
|
|
984
1095
|
this.ensureNativePrebuildsFn = options.ensureNativePrebuilds ?? (async () => void 0);
|
|
985
1096
|
this.env = options.env ?? process.env;
|
|
1097
|
+
this.readWrapperProbesFn = options.readWrapperProbes ?? readWrapperProbes;
|
|
986
1098
|
this.now = options.now ?? Date.now;
|
|
987
1099
|
this.runningPackageJsonPath = options.runningPackageJsonPath;
|
|
988
1100
|
this.workspaceProbeDir = options.workspaceProbeDir;
|
|
@@ -1008,6 +1120,20 @@ var require_dist = __commonJS({
|
|
|
1008
1120
|
if (seedDir === void 0 || seedDir.length === 0) return null;
|
|
1009
1121
|
return readPackageVersion(path6.join(seedDir, "package.json"));
|
|
1010
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* What this node runs INSIDE. Observed where it can be (the container
|
|
1125
|
+
* probes), declared only where it cannot (the Electron app version, which a
|
|
1126
|
+
* child process has no other way to learn). A contradiction between the two
|
|
1127
|
+
* is reported as `contradictory`, never resolved — see `wrapper-identity.ts`
|
|
1128
|
+
* and D437.
|
|
1129
|
+
*/
|
|
1130
|
+
wrapperIdentity() {
|
|
1131
|
+
return detectWrapperIdentity({
|
|
1132
|
+
...this.readWrapperProbesFn(),
|
|
1133
|
+
electronAppVersion: this.env[ELECTRON_APP_VERSION_ENV],
|
|
1134
|
+
seedVersion: this.seedVersion()
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1011
1137
|
rootDir() {
|
|
1012
1138
|
return serverRootDir(this.dataDir);
|
|
1013
1139
|
}
|
|
@@ -1046,7 +1172,11 @@ var require_dist = __commonJS({
|
|
|
1046
1172
|
seedVersion: this.seedVersion(),
|
|
1047
1173
|
latestVersion,
|
|
1048
1174
|
runningVersion
|
|
1049
|
-
})
|
|
1175
|
+
}),
|
|
1176
|
+
// The other half of the same question. `imageContract` says the shell is
|
|
1177
|
+
// BEHIND; this says WHAT the shell is, which is the only thing that can
|
|
1178
|
+
// turn that verdict into an instruction the operator can act on.
|
|
1179
|
+
wrapper: this.wrapperIdentity()
|
|
1050
1180
|
};
|
|
1051
1181
|
}
|
|
1052
1182
|
// ── Check ─────────────────────────────────────────────────────────────
|