@camstack/system 1.2.360 → 1.2.361

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.
@@ -151,6 +151,22 @@ export default class NativeMetricsAddon extends BaseAddon<NativeMetricsConfig> {
151
151
  * is why the retention default is six hours rather than three days.
152
152
  */
153
153
  private startProcessSnapshotTimer;
154
+ /**
155
+ * Run one periodic tick so that it can NEVER become an unhandled rejection.
156
+ *
157
+ * `clearInterval` in `onShutdown` stops FUTURE ticks; it cannot recall one
158
+ * already in flight. A process tick awaits a `/proc` read and a
159
+ * `$process.list` broker call, and a graceful stop shuts this addon down
160
+ * (nulling `ctx`) while the agent goes on to stop its other addons and the
161
+ * broker — so the tick resumed with no context, its catch block reached for
162
+ * `this.ctx.logger`, threw again, and the rejection of the timer callback
163
+ * killed `docker stop CamStack-Agent` with exit code 1 (little-unraid,
164
+ * 2026-10-07 04:22Z). The ticks themselves now capture their context once
165
+ * and stand down when it is gone; this wrapper is the backstop.
166
+ *
167
+ * A tick before `initialize()` or after `shutdown()` is a no-op.
168
+ */
169
+ private runTick;
154
170
  protected onShutdown(): Promise<void>;
155
171
  /**
156
172
  * Resolve this node's short id (strips any `nodeId/addonId` suffix).
@@ -3014,7 +3014,8 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3014
3014
  }
3015
3015
  this.memoryProbe = new ContainerMemoryProbe({ logger: this.ctx.logger.child("ContainerMemory") });
3016
3016
  this.pidsProbe = new ContainerPidsProbe({ logger: this.ctx.logger.child("ContainerPids") });
3017
- this.snapshotTimer = setInterval(() => this.emitResourcesSnapshot(), METRICS_SNAPSHOT_INTERVAL_MS);
3017
+ if (this.snapshotTimer) clearInterval(this.snapshotTimer);
3018
+ this.snapshotTimer = setInterval(() => this.runTick("resources snapshot", () => this.emitResourcesSnapshot()), METRICS_SNAPSHOT_INTERVAL_MS);
3018
3019
  this.startProcessSnapshotTimer();
3019
3020
  this.ctx.addDisposer(this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.MetricsNodeProcessesSnapshot }, (event) => this.retainSnapshot(event.data.nodeId, event.data.timestamp, event.data.processes, event.data.containerMemory)));
3020
3021
  return [{
@@ -3179,7 +3180,33 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3179
3180
  */
3180
3181
  startProcessSnapshotTimer() {
3181
3182
  if (this.processSnapshotTimer) clearInterval(this.processSnapshotTimer);
3182
- this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), this.loadConfig.cadenceSec * 1e3);
3183
+ this.processSnapshotTimer = null;
3184
+ if (!this.ctxIfReady) return;
3185
+ this.processSnapshotTimer = setInterval(() => this.runTick("process snapshot", () => this.emitProcessesSnapshot()), this.loadConfig.cadenceSec * 1e3);
3186
+ }
3187
+ /**
3188
+ * Run one periodic tick so that it can NEVER become an unhandled rejection.
3189
+ *
3190
+ * `clearInterval` in `onShutdown` stops FUTURE ticks; it cannot recall one
3191
+ * already in flight. A process tick awaits a `/proc` read and a
3192
+ * `$process.list` broker call, and a graceful stop shuts this addon down
3193
+ * (nulling `ctx`) while the agent goes on to stop its other addons and the
3194
+ * broker — so the tick resumed with no context, its catch block reached for
3195
+ * `this.ctx.logger`, threw again, and the rejection of the timer callback
3196
+ * killed `docker stop CamStack-Agent` with exit code 1 (little-unraid,
3197
+ * 2026-10-07 04:22Z). The ticks themselves now capture their context once
3198
+ * and stand down when it is gone; this wrapper is the backstop.
3199
+ *
3200
+ * A tick before `initialize()` or after `shutdown()` is a no-op.
3201
+ */
3202
+ runTick(name, tick) {
3203
+ if (!this.ctxIfReady) return;
3204
+ tick().catch((err) => {
3205
+ this.ctxIfReady?.logger.warn("native-metrics tick failed — this interval will be missing", { meta: {
3206
+ tick: name,
3207
+ error: err instanceof Error ? err.message : String(err)
3208
+ } });
3209
+ });
3183
3210
  }
3184
3211
  async onShutdown() {
3185
3212
  if (this.snapshotTimer) {
@@ -3197,9 +3224,9 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3197
3224
  * Resolve this node's short id (strips any `nodeId/addonId` suffix).
3198
3225
  * Returns null when the provider or event bus isn't available yet.
3199
3226
  */
3200
- resolveSnapshotNodeId() {
3201
- if (!this.provider || !this.ctx.eventBus) return null;
3202
- const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
3227
+ resolveSnapshotNodeId(ctx) {
3228
+ if (!this.provider || !ctx.eventBus) return null;
3229
+ const rawNodeId = ctx.kernel.localNodeId ?? ctx.id;
3203
3230
  return rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
3204
3231
  }
3205
3232
  /**
@@ -3208,13 +3235,16 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3208
3235
  * runs at samplingIntervalMs). Fires on METRICS_SNAPSHOT_INTERVAL_MS.
3209
3236
  */
3210
3237
  async emitResourcesSnapshot() {
3238
+ const ctx = this.ctxIfReady;
3239
+ if (!ctx) return;
3211
3240
  const provider = this.provider;
3212
- const eventBus = this.ctx.eventBus;
3213
- const nodeId = this.resolveSnapshotNodeId();
3241
+ const eventBus = ctx.eventBus;
3242
+ const nodeId = this.resolveSnapshotNodeId(ctx);
3214
3243
  if (!provider || !eventBus || !nodeId) return;
3215
3244
  const timestamp = Date.now();
3216
3245
  try {
3217
3246
  const snapshot = await provider.getCached() ?? await provider.collectSnapshot();
3247
+ if (this.ctxIfReady !== ctx) return;
3218
3248
  const coarse = coarsenResourcesSnapshot(snapshot);
3219
3249
  const prev = this.lastResourcesEmit;
3220
3250
  const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_SNAPSHOT_HEARTBEAT_MS;
@@ -3247,12 +3277,18 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3247
3277
  * A failed scan emits nothing, which is the same signal: nobody reported.
3248
3278
  */
3249
3279
  async emitProcessesSnapshot() {
3250
- const eventBus = this.ctx.eventBus;
3251
- const nodeId = this.resolveSnapshotNodeId();
3280
+ const ctx = this.ctxIfReady;
3281
+ if (!ctx) return;
3282
+ const eventBus = ctx.eventBus;
3283
+ const nodeId = this.resolveSnapshotNodeId(ctx);
3252
3284
  if (!this.provider || !eventBus || !nodeId) return;
3253
3285
  const timestamp = Date.now();
3254
3286
  try {
3255
3287
  const processes = await this.listNodeProcesses();
3288
+ if (this.ctxIfReady !== ctx) {
3289
+ ctx.logger.debug("process snapshot dropped — addon shut down mid-tick", { meta: { nodeId } });
3290
+ return;
3291
+ }
3256
3292
  const containerMemory = this.memoryProbe?.sample() ?? null;
3257
3293
  this.pidsProbe?.sample();
3258
3294
  eventBus.emit(require_dist.createEvent(require_dist.EventCategory.MetricsNodeProcessesSnapshot, {
@@ -3266,20 +3302,21 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3266
3302
  containerMemory
3267
3303
  }));
3268
3304
  } catch (err) {
3269
- this.ctx.logger.warn("process snapshot skipped — this interval will be missing", { meta: {
3305
+ ctx.logger.warn("process snapshot skipped — this interval will be missing", { meta: {
3270
3306
  nodeId,
3271
3307
  error: err instanceof Error ? err.message : String(err)
3272
3308
  } });
3273
3309
  }
3274
3310
  }
3275
3311
  async onConfigChanged() {
3312
+ if (!this.ctxIfReady) return;
3276
3313
  this.provider?.stopSampling();
3277
3314
  this.provider?.startSampling(this.config.samplingIntervalMs);
3278
3315
  this.applyLoadSeriesConfig();
3279
3316
  this.startProcessSnapshotTimer();
3280
3317
  }
3281
3318
  async listWorkerInstances() {
3282
- const broker = this.ctx.kernel.cluster?.broker;
3319
+ const broker = this.ctxIfReady?.kernel.cluster?.broker;
3283
3320
  if (!broker) return [];
3284
3321
  try {
3285
3322
  return await broker.call("$process.list");
@@ -3326,6 +3363,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3326
3363
  * walk behind them — live in `process-classification.ts`.
3327
3364
  */
3328
3365
  async listNodeProcesses() {
3366
+ const selfNodeId = this.ctx.kernel.cluster?.broker?.nodeID ?? "hub";
3329
3367
  const ps = await this.runPs();
3330
3368
  if (ps.length === 0) return [];
3331
3369
  const managed = /* @__PURE__ */ new Map();
@@ -3339,7 +3377,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3339
3377
  return buildNodeProcesses({
3340
3378
  psRows: ps,
3341
3379
  selfPid: process.pid,
3342
- selfNodeId: this.ctx.kernel.cluster?.broker?.nodeID ?? "hub",
3380
+ selfNodeId,
3343
3381
  managed,
3344
3382
  cpuSplits
3345
3383
  });
@@ -3355,7 +3393,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends require_dist.BaseAddon
3355
3393
  try {
3356
3394
  return await readProcessTable();
3357
3395
  } catch (err) {
3358
- this.ctx.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3396
+ this.ctxIfReady?.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3359
3397
  return [];
3360
3398
  }
3361
3399
  }
@@ -3009,7 +3009,8 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3009
3009
  }
3010
3010
  this.memoryProbe = new ContainerMemoryProbe({ logger: this.ctx.logger.child("ContainerMemory") });
3011
3011
  this.pidsProbe = new ContainerPidsProbe({ logger: this.ctx.logger.child("ContainerPids") });
3012
- this.snapshotTimer = setInterval(() => this.emitResourcesSnapshot(), METRICS_SNAPSHOT_INTERVAL_MS);
3012
+ if (this.snapshotTimer) clearInterval(this.snapshotTimer);
3013
+ this.snapshotTimer = setInterval(() => this.runTick("resources snapshot", () => this.emitResourcesSnapshot()), METRICS_SNAPSHOT_INTERVAL_MS);
3013
3014
  this.startProcessSnapshotTimer();
3014
3015
  this.ctx.addDisposer(this.ctx.eventBus.subscribe({ category: EventCategory.MetricsNodeProcessesSnapshot }, (event) => this.retainSnapshot(event.data.nodeId, event.data.timestamp, event.data.processes, event.data.containerMemory)));
3015
3016
  return [{
@@ -3174,7 +3175,33 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3174
3175
  */
3175
3176
  startProcessSnapshotTimer() {
3176
3177
  if (this.processSnapshotTimer) clearInterval(this.processSnapshotTimer);
3177
- this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), this.loadConfig.cadenceSec * 1e3);
3178
+ this.processSnapshotTimer = null;
3179
+ if (!this.ctxIfReady) return;
3180
+ this.processSnapshotTimer = setInterval(() => this.runTick("process snapshot", () => this.emitProcessesSnapshot()), this.loadConfig.cadenceSec * 1e3);
3181
+ }
3182
+ /**
3183
+ * Run one periodic tick so that it can NEVER become an unhandled rejection.
3184
+ *
3185
+ * `clearInterval` in `onShutdown` stops FUTURE ticks; it cannot recall one
3186
+ * already in flight. A process tick awaits a `/proc` read and a
3187
+ * `$process.list` broker call, and a graceful stop shuts this addon down
3188
+ * (nulling `ctx`) while the agent goes on to stop its other addons and the
3189
+ * broker — so the tick resumed with no context, its catch block reached for
3190
+ * `this.ctx.logger`, threw again, and the rejection of the timer callback
3191
+ * killed `docker stop CamStack-Agent` with exit code 1 (little-unraid,
3192
+ * 2026-10-07 04:22Z). The ticks themselves now capture their context once
3193
+ * and stand down when it is gone; this wrapper is the backstop.
3194
+ *
3195
+ * A tick before `initialize()` or after `shutdown()` is a no-op.
3196
+ */
3197
+ runTick(name, tick) {
3198
+ if (!this.ctxIfReady) return;
3199
+ tick().catch((err) => {
3200
+ this.ctxIfReady?.logger.warn("native-metrics tick failed — this interval will be missing", { meta: {
3201
+ tick: name,
3202
+ error: err instanceof Error ? err.message : String(err)
3203
+ } });
3204
+ });
3178
3205
  }
3179
3206
  async onShutdown() {
3180
3207
  if (this.snapshotTimer) {
@@ -3192,9 +3219,9 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3192
3219
  * Resolve this node's short id (strips any `nodeId/addonId` suffix).
3193
3220
  * Returns null when the provider or event bus isn't available yet.
3194
3221
  */
3195
- resolveSnapshotNodeId() {
3196
- if (!this.provider || !this.ctx.eventBus) return null;
3197
- const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
3222
+ resolveSnapshotNodeId(ctx) {
3223
+ if (!this.provider || !ctx.eventBus) return null;
3224
+ const rawNodeId = ctx.kernel.localNodeId ?? ctx.id;
3198
3225
  return rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
3199
3226
  }
3200
3227
  /**
@@ -3203,13 +3230,16 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3203
3230
  * runs at samplingIntervalMs). Fires on METRICS_SNAPSHOT_INTERVAL_MS.
3204
3231
  */
3205
3232
  async emitResourcesSnapshot() {
3233
+ const ctx = this.ctxIfReady;
3234
+ if (!ctx) return;
3206
3235
  const provider = this.provider;
3207
- const eventBus = this.ctx.eventBus;
3208
- const nodeId = this.resolveSnapshotNodeId();
3236
+ const eventBus = ctx.eventBus;
3237
+ const nodeId = this.resolveSnapshotNodeId(ctx);
3209
3238
  if (!provider || !eventBus || !nodeId) return;
3210
3239
  const timestamp = Date.now();
3211
3240
  try {
3212
3241
  const snapshot = await provider.getCached() ?? await provider.collectSnapshot();
3242
+ if (this.ctxIfReady !== ctx) return;
3213
3243
  const coarse = coarsenResourcesSnapshot(snapshot);
3214
3244
  const prev = this.lastResourcesEmit;
3215
3245
  const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_SNAPSHOT_HEARTBEAT_MS;
@@ -3242,12 +3272,18 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3242
3272
  * A failed scan emits nothing, which is the same signal: nobody reported.
3243
3273
  */
3244
3274
  async emitProcessesSnapshot() {
3245
- const eventBus = this.ctx.eventBus;
3246
- const nodeId = this.resolveSnapshotNodeId();
3275
+ const ctx = this.ctxIfReady;
3276
+ if (!ctx) return;
3277
+ const eventBus = ctx.eventBus;
3278
+ const nodeId = this.resolveSnapshotNodeId(ctx);
3247
3279
  if (!this.provider || !eventBus || !nodeId) return;
3248
3280
  const timestamp = Date.now();
3249
3281
  try {
3250
3282
  const processes = await this.listNodeProcesses();
3283
+ if (this.ctxIfReady !== ctx) {
3284
+ ctx.logger.debug("process snapshot dropped — addon shut down mid-tick", { meta: { nodeId } });
3285
+ return;
3286
+ }
3251
3287
  const containerMemory = this.memoryProbe?.sample() ?? null;
3252
3288
  this.pidsProbe?.sample();
3253
3289
  eventBus.emit(createEvent(EventCategory.MetricsNodeProcessesSnapshot, {
@@ -3261,20 +3297,21 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3261
3297
  containerMemory
3262
3298
  }));
3263
3299
  } catch (err) {
3264
- this.ctx.logger.warn("process snapshot skipped — this interval will be missing", { meta: {
3300
+ ctx.logger.warn("process snapshot skipped — this interval will be missing", { meta: {
3265
3301
  nodeId,
3266
3302
  error: err instanceof Error ? err.message : String(err)
3267
3303
  } });
3268
3304
  }
3269
3305
  }
3270
3306
  async onConfigChanged() {
3307
+ if (!this.ctxIfReady) return;
3271
3308
  this.provider?.stopSampling();
3272
3309
  this.provider?.startSampling(this.config.samplingIntervalMs);
3273
3310
  this.applyLoadSeriesConfig();
3274
3311
  this.startProcessSnapshotTimer();
3275
3312
  }
3276
3313
  async listWorkerInstances() {
3277
- const broker = this.ctx.kernel.cluster?.broker;
3314
+ const broker = this.ctxIfReady?.kernel.cluster?.broker;
3278
3315
  if (!broker) return [];
3279
3316
  try {
3280
3317
  return await broker.call("$process.list");
@@ -3321,6 +3358,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3321
3358
  * walk behind them — live in `process-classification.ts`.
3322
3359
  */
3323
3360
  async listNodeProcesses() {
3361
+ const selfNodeId = this.ctx.kernel.cluster?.broker?.nodeID ?? "hub";
3324
3362
  const ps = await this.runPs();
3325
3363
  if (ps.length === 0) return [];
3326
3364
  const managed = /* @__PURE__ */ new Map();
@@ -3334,7 +3372,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3334
3372
  return buildNodeProcesses({
3335
3373
  psRows: ps,
3336
3374
  selfPid: process.pid,
3337
- selfNodeId: this.ctx.kernel.cluster?.broker?.nodeID ?? "hub",
3375
+ selfNodeId,
3338
3376
  managed,
3339
3377
  cpuSplits
3340
3378
  });
@@ -3350,7 +3388,7 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3350
3388
  try {
3351
3389
  return await readProcessTable();
3352
3390
  } catch (err) {
3353
- this.ctx.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3391
+ this.ctxIfReady?.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3354
3392
  return [];
3355
3393
  }
3356
3394
  }
@@ -3886,7 +3886,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
3886
3886
  try {
3887
3887
  await this.pressureManager.sweep();
3888
3888
  } catch (err) {
3889
- this.ctx.logger.warn("storage pressure sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3889
+ this.ctxIfReady?.logger.warn("storage pressure sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3890
3890
  } finally {
3891
3891
  this.pressureSweepInFlight = false;
3892
3892
  }
@@ -3881,7 +3881,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
3881
3881
  try {
3882
3882
  await this.pressureManager.sweep();
3883
3883
  } catch (err) {
3884
- this.ctx.logger.warn("storage pressure sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3884
+ this.ctxIfReady?.logger.warn("storage pressure sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3885
3885
  } finally {
3886
3886
  this.pressureSweepInFlight = false;
3887
3887
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.360",
3
+ "version": "1.2.361",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",