@camstack/system 1.2.23 → 1.2.25

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.
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_manifest_python_deps = require("./manifest-python-deps-CqDPWqEE.js");
2
+ const require_manifest_python_deps = require("./manifest-python-deps-COgal7Jy.js");
3
3
  const require_custom_action_registry = require("./custom-action-registry-jY0NOZK8.js");
4
4
  let node_fs = require("node:fs");
5
5
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { Ct as resolveAddonClass, I as createUdsLoggerWithControl, L as LocalChildClient, at as setWorkerNativeCapsChangeListener, bt as installManifestNativeDeps, i as createUdsAddonContext, nt as getWorkerNativeCapProvider, rt as getWorkerNativeCapSnapshot, st as validateProviderRegistrations, t as installManifestPythonDeps } from "./manifest-python-deps-57PA4rHm.mjs";
1
+ import { Ct as resolveAddonClass, I as createUdsLoggerWithControl, L as LocalChildClient, at as setWorkerNativeCapsChangeListener, bt as installManifestNativeDeps, i as createUdsAddonContext, nt as getWorkerNativeCapProvider, rt as getWorkerNativeCapSnapshot, st as validateProviderRegistrations, t as installManifestPythonDeps } from "./manifest-python-deps-julrfvyf.mjs";
2
2
  import { t as CustomActionRegistry } from "./custom-action-registry-F__gp_VX.mjs";
3
3
  import { register } from "node:module";
4
4
  import * as fs from "node:fs";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
3
  const require_dist = require("./dist-CS0pdC8l.js");
4
4
  const require_model_download_service = require("./model-download-service-hf0ookyy.js");
5
- const require_manifest_python_deps = require("./manifest-python-deps-CqDPWqEE.js");
5
+ const require_manifest_python_deps = require("./manifest-python-deps-COgal7Jy.js");
6
6
  const require_resource_monitor = require("./resource-monitor-DNNomR-i.js");
7
7
  const require_builtins_sqlite_storage_filesystem_storage_addon = require("./builtins/sqlite-storage/filesystem-storage.addon.js");
8
8
  const require_builtins_sqlite_storage_sqlite_settings_addon = require("./builtins/sqlite-storage/sqlite-settings.addon.js");
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
2
  import { B as logLevelAtMost, Dt as readinessKey, Et as parseJsonUnknown$1, F as isVoidInput, I as kebabToCamel, L as lifecycleJobSchema, M as isArrayOutputSchema, N as isCollectionArrayMethod, Ot as resolveCapMount, P as isObjectInput, Tt as parseJsonObject, V as looseSchema, W as objectInputDeclaresAddonId, _t as asString$1, c as RUNTIME_DEFAULTS, ct as DEVICE_STATUS_METHOD, ft as ReadinessRegistry, gt as asNumber, ht as asJsonObject$1, it as errMsg$1, jt as EventCategory$1, k as extractNestedAddonId, kt as scopeKey, m as addonSettingsCapability, ot as DATAPLANE_SECRET_HEADER$1, pt as ReadinessTimeoutError, q as procedureAuthKey, s as METHOD_ACCESS_MAP, st as DEVICE_SETTINGS_CONTRIBUTION_METHODS, t as ALL_CAPABILITY_DEFINITIONS, vt as createEvent, xt as expandCapMethods, yt as emitDownForOwnedCaps } from "./dist-CIek6F9X.mjs";
3
3
  import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-Cp9f4dk6.mjs";
4
- import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-57PA4rHm.mjs";
4
+ import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-julrfvyf.mjs";
5
5
  import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-BkP504Vq.mjs";
6
6
  import { FilesystemStorageAddon, t as FilesystemStorageProvider } from "./builtins/sqlite-storage/filesystem-storage.addon.mjs";
7
7
  import { SqliteSettingsAddon, t as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Read-modify-write guard for the addon settings stores.
3
+ *
4
+ * ## The data loss this prevents
5
+ *
6
+ * Every settings mutation is `read current → merge patch → write back`. The
7
+ * read used to answer `{}` for BOTH "this addon has no settings yet" and "the
8
+ * read failed", so a failed read merged the patch onto nothing and the write
9
+ * persisted a blob containing ONLY the patch. Everything else in it was gone.
10
+ *
11
+ * It happened on 2026-07-31. The UDS transport stalled, `settings write failed
12
+ * (addon-settings/root) — transport error ... UDS request timed out after
13
+ * 60000ms` appeared in the log, and the recording configuration for all three
14
+ * recording cameras came back as `{"enabled": false, "bands": []}`. Two and a
15
+ * half hours of footage were never written, and nothing looked broken: an
16
+ * emptied config is indistinguishable from an operator switching recording off.
17
+ *
18
+ * The rule this module enforces: **a read that FAILED is not an empty value.**
19
+ * A miss still merges onto `{}` — that is a real empty. A failure refuses the
20
+ * write and says so, leaving the stored blob untouched so the next successful
21
+ * write starts from the truth.
22
+ */
23
+ /**
24
+ * Outcome of a settings read. `missing` and `failed` are deliberately distinct
25
+ * — collapsing them is the bug.
26
+ */
27
+ export type SettingsRead = {
28
+ readonly kind: 'value';
29
+ readonly value: Record<string, unknown>;
30
+ } | {
31
+ readonly kind: 'missing';
32
+ } | {
33
+ readonly kind: 'failed';
34
+ readonly error: string;
35
+ };
36
+ export interface ReadModifyWriteInput {
37
+ readonly read: () => Promise<SettingsRead>;
38
+ readonly write: (next: Record<string, unknown>) => Promise<void>;
39
+ readonly patch: Record<string, unknown>;
40
+ /** Called instead of writing when the read failed — always log this. */
41
+ readonly onRefused: (error: string) => void;
42
+ }
43
+ /**
44
+ * Merge `patch` over the current value and write it — unless the read failed,
45
+ * in which case write NOTHING.
46
+ *
47
+ * Returns whether the write was attempted, so a caller can surface the refusal.
48
+ */
49
+ export declare function readModifyWrite(input: ReadModifyWriteInput): Promise<boolean>;
@@ -95,6 +95,18 @@ export interface LocalChildRegistryOptions {
95
95
  * everything). The kernel has no cap registry, so the wiring side injects it.
96
96
  */
97
97
  readonly isAggregatedCollectionMethod?: (capName: string, method: string) => boolean;
98
+ /**
99
+ * Per-cap-method bound for UDS cap calls, in ms — read from the cap
100
+ * definition's declared `timeoutMs`. The SAME resolver the remote plane uses
101
+ * (`CapRouteResolver.capTimeoutMs`), so the two planes agree by construction
102
+ * instead of by coincidence.
103
+ *
104
+ * Without this the channel default (60 s) would apply to everything, and a
105
+ * legitimately long method — `applyServerUpdate` declares 16 min because it
106
+ * awaits a full npm install — would be cut off mid-run. Omitted ⇒ the
107
+ * channel default for every cap call.
108
+ */
109
+ readonly capTimeoutMs?: (capName: string, method: string) => number | undefined;
98
110
  }
99
111
  /**
100
112
  * Parent-side authority for local addon-runners reachable over a
@@ -118,6 +130,8 @@ export declare class LocalChildRegistry {
118
130
  private readonly ownNodeId?;
119
131
  /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
120
132
  private readonly isAggregatedCollectionMethod?;
133
+ /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
134
+ private readonly capTimeoutMs?;
121
135
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
122
136
  private readonly egressRoutedCaps;
123
137
  /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
@@ -227,6 +241,12 @@ export declare class LocalChildRegistry {
227
241
  */
228
242
  isChildKnown(childId: string): boolean;
229
243
  childProvides(childId: string, capName: string, deviceId?: number): boolean;
244
+ /**
245
+ * Per-request options for a cap call: the cap method's declared `timeoutMs`
246
+ * when one exists, else `undefined` so the channel applies its default.
247
+ * Mirrors `CapRouteResolver.callTimeout` for the remote plane.
248
+ */
249
+ private capRequestOpts;
230
250
  /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
231
251
  callCapOnChild(childId: string, input: CapCallInput): Promise<unknown>;
232
252
  /** Forward a cap method call to the owning child over UDS; rejects if none. */
@@ -19,9 +19,19 @@ export type Frame = {
19
19
  };
20
20
  export type RequestHandler = (body: unknown) => Promise<unknown>;
21
21
  export type EventHandler = (body: unknown) => void;
22
+ /** Per-request options a {@link LocalChannel} implementation may honour. */
23
+ export interface LocalRequestOptions {
24
+ /**
25
+ * Bound on this request in ms; `0` or negative disables it. Omitted ⇒ the
26
+ * implementation's default. Cap calls pass the cap method's declared
27
+ * `timeoutMs` so a legitimately long method (`applyServerUpdate`, 16 min) is
28
+ * not cut short.
29
+ */
30
+ readonly timeoutMs?: number;
31
+ }
22
32
  /** A bidirectional request/response + one-way event channel over one socket. */
23
33
  export interface LocalChannel {
24
- request(body: unknown): Promise<unknown>;
34
+ request(body: unknown, opts?: LocalRequestOptions): Promise<unknown>;
25
35
  emit(body: unknown): void;
26
36
  onRequest(handler: RequestHandler): void;
27
37
  onEvent(handler: EventHandler): void;
@@ -1,5 +1,21 @@
1
1
  import { Socket } from 'node:net';
2
2
  import { LocalChannel, RequestHandler, EventHandler } from './local-transport.js';
3
+ /**
4
+ * Default bound on one UDS request, when the caller names none.
5
+ *
6
+ * Chosen to match `REMOTE_CALL_TIMEOUT_MS` in `cap-route-resolver.ts`: the SAME
7
+ * cap method already dies at 60 s when it resolves to a remote node, so
8
+ * applying the same bound locally makes the two planes consistent rather than
9
+ * newly restrictive. Callers with a legitimately longer method (the cap
10
+ * layer's declared `timeoutMs`, e.g. `applyServerUpdate` at 16 min) pass it
11
+ * explicitly.
12
+ */
13
+ export declare const DEFAULT_UDS_REQUEST_TIMEOUT_MS = 60000;
14
+ /** Per-request options for {@link SocketChannel.request}. */
15
+ export interface SocketRequestOptions {
16
+ /** Bound in ms; `0` or negative disables the timer entirely. */
17
+ readonly timeoutMs?: number;
18
+ }
3
19
  export declare class SocketChannel implements LocalChannel {
4
20
  private readonly socket;
5
21
  private nextId;
@@ -11,7 +27,18 @@ export declare class SocketChannel implements LocalChannel {
11
27
  private closed;
12
28
  private closeFired;
13
29
  constructor(socket: Socket);
14
- request(body: unknown): Promise<unknown>;
30
+ /**
31
+ * Send a request and wait for the peer's response.
32
+ *
33
+ * Bounded. Without a timer this settles on exactly two events — a matching
34
+ * `res` frame, or the socket's `close` (which drains `pending`) — so a peer
35
+ * that receives the request and then neither answers nor closes leaves the
36
+ * caller pending forever. That is a wedged handler, or a half-open socket
37
+ * after a child died without a clean teardown, and it is how camera 615 spent
38
+ * 30+ minutes with every inference permit held by a call that was never
39
+ * coming back.
40
+ */
41
+ request(body: unknown, opts?: SocketRequestOptions): Promise<unknown>;
15
42
  emit(body: unknown): void;
16
43
  /** Replace the request handler. Only one handler is active at a time. */
17
44
  onRequest(handler: RequestHandler): void;
@@ -1188,6 +1188,27 @@ function addSettingsActions(addon, actions) {
1188
1188
  }
1189
1189
  }
1190
1190
  //#endregion
1191
+ //#region src/kernel/moleculer/settings-read-guard.ts
1192
+ /**
1193
+ * Merge `patch` over the current value and write it — unless the read failed,
1194
+ * in which case write NOTHING.
1195
+ *
1196
+ * Returns whether the write was attempted, so a caller can surface the refusal.
1197
+ */
1198
+ async function readModifyWrite(input) {
1199
+ const current = await input.read();
1200
+ if (current.kind === "failed") {
1201
+ input.onRefused(current.error);
1202
+ return false;
1203
+ }
1204
+ const base = current.kind === "value" ? current.value : {};
1205
+ await input.write({
1206
+ ...base,
1207
+ ...input.patch
1208
+ });
1209
+ return true;
1210
+ }
1211
+ //#endregion
1191
1212
  //#region src/kernel/moleculer/device-cap-proxy.ts
1192
1213
  /**
1193
1214
  * Max number of accessory children spawned concurrently during a single
@@ -3526,8 +3547,6 @@ function localEndpointPath(nodeId) {
3526
3547
  if (process.platform === "win32") return `\\\\.\\pipe\\camstack-${safe}`;
3527
3548
  return (0, node_path.join)((0, node_os.tmpdir)(), `camstack-${safe}.sock`);
3528
3549
  }
3529
- //#endregion
3530
- //#region src/kernel/transport/socket-channel.ts
3531
3550
  var SocketChannel = class {
3532
3551
  socket;
3533
3552
  nextId = 1;
@@ -3546,13 +3565,34 @@ var SocketChannel = class {
3546
3565
  socket.on("close", () => this.handleClose());
3547
3566
  socket.on("error", () => {});
3548
3567
  }
3549
- request(body) {
3568
+ /**
3569
+ * Send a request and wait for the peer's response.
3570
+ *
3571
+ * Bounded. Without a timer this settles on exactly two events — a matching
3572
+ * `res` frame, or the socket's `close` (which drains `pending`) — so a peer
3573
+ * that receives the request and then neither answers nor closes leaves the
3574
+ * caller pending forever. That is a wedged handler, or a half-open socket
3575
+ * after a child died without a clean teardown, and it is how camera 615 spent
3576
+ * 30+ minutes with every inference permit held by a call that was never
3577
+ * coming back.
3578
+ */
3579
+ request(body, opts) {
3550
3580
  if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("channel closed"));
3551
3581
  const id = this.nextId++;
3582
+ const timeoutMs = opts?.timeoutMs ?? 6e4;
3552
3583
  return new Promise((resolve, reject) => {
3584
+ let timer = null;
3585
+ if (timeoutMs > 0) {
3586
+ timer = setTimeout(() => {
3587
+ this.pending.delete(id);
3588
+ reject(/* @__PURE__ */ new Error(`UDS request timed out after ${timeoutMs}ms`));
3589
+ }, timeoutMs);
3590
+ timer.unref?.();
3591
+ }
3553
3592
  this.pending.set(id, {
3554
3593
  resolve,
3555
- reject
3594
+ reject,
3595
+ timer
3556
3596
  });
3557
3597
  this.send({
3558
3598
  k: "req",
@@ -3616,6 +3656,7 @@ var SocketChannel = class {
3616
3656
  const p = this.pending.get(frame.id);
3617
3657
  if (!p) return;
3618
3658
  this.pending.delete(frame.id);
3659
+ if (p.timer !== null) clearTimeout(p.timer);
3619
3660
  if (frame.ok) p.resolve(frame.body);
3620
3661
  else p.reject(new Error(frame.err));
3621
3662
  return;
@@ -3626,7 +3667,10 @@ var SocketChannel = class {
3626
3667
  if (this.closeFired) return;
3627
3668
  this.closeFired = true;
3628
3669
  this.closed = true;
3629
- for (const [, p] of this.pending) p.reject(/* @__PURE__ */ new Error("channel closed"));
3670
+ for (const [, p] of this.pending) {
3671
+ if (p.timer !== null) clearTimeout(p.timer);
3672
+ p.reject(/* @__PURE__ */ new Error("channel closed"));
3673
+ }
3630
3674
  this.pending.clear();
3631
3675
  this.closeHandler();
3632
3676
  }
@@ -4305,6 +4349,8 @@ var LocalChildRegistry = class {
4305
4349
  ownNodeId;
4306
4350
  /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
4307
4351
  isAggregatedCollectionMethod;
4352
+ /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
4353
+ capTimeoutMs;
4308
4354
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
4309
4355
  egressRoutedCaps = /* @__PURE__ */ new Set();
4310
4356
  /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
@@ -4334,6 +4380,7 @@ var LocalChildRegistry = class {
4334
4380
  this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
4335
4381
  this.ownNodeId = opts.ownNodeId;
4336
4382
  this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
4383
+ this.capTimeoutMs = opts.capTimeoutMs;
4337
4384
  } else {
4338
4385
  this.server = serverOrOptions;
4339
4386
  this.onUnownedCall = onUnownedCallArg;
@@ -4496,11 +4543,20 @@ var LocalChildRegistry = class {
4496
4543
  if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
4497
4544
  return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
4498
4545
  }
4546
+ /**
4547
+ * Per-request options for a cap call: the cap method's declared `timeoutMs`
4548
+ * when one exists, else `undefined` so the channel applies its default.
4549
+ * Mirrors `CapRouteResolver.callTimeout` for the remote plane.
4550
+ */
4551
+ capRequestOpts(input) {
4552
+ const declared = this.capTimeoutMs?.(input.capName, input.method);
4553
+ return typeof declared === "number" ? { timeoutMs: declared } : void 0;
4554
+ }
4499
4555
  /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
4500
4556
  async callCapOnChild(childId, input) {
4501
4557
  const entry = this.children.get(childId);
4502
4558
  if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
4503
- return entry.channel.request(this.toCapCall(input));
4559
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
4504
4560
  }
4505
4561
  /** Forward a cap method call to the owning child over UDS; rejects if none. */
4506
4562
  async callCap(input) {
@@ -4510,7 +4566,7 @@ var LocalChildRegistry = class {
4510
4566
  const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
4511
4567
  throw new Error(`no local child provides cap "${input.capName}"${where}`);
4512
4568
  }
4513
- return entry.channel.request(this.toCapCall(input));
4569
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
4514
4570
  }
4515
4571
  /**
4516
4572
  * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
@@ -7136,8 +7192,18 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7136
7192
  bindingCache.clear();
7137
7193
  });
7138
7194
  const settingsApi = Reflect.get(api, "settingsStore");
7139
- const readBlob = async (coll, key, namespace) => {
7140
- if (!settingsApi) return {};
7195
+ /**
7196
+ * Read a settings blob, distinguishing a MISS from a FAILURE.
7197
+ *
7198
+ * Collapsing the two is a data-loss bug, not a style choice: every mutation
7199
+ * is read-modify-write, so a failed read that answers "empty" makes the write
7200
+ * persist the patch ALONE and destroy every field the patch does not mention.
7201
+ * That is what emptied the recording configuration on 2026-07-31 when the UDS
7202
+ * transport stalled — `{"enabled": false, "bands": []}`, two and a half hours
7203
+ * of footage never written, and nothing that looked like a fault.
7204
+ */
7205
+ const readBlobResult = async (coll, key, namespace) => {
7206
+ if (!settingsApi) return { kind: "missing" };
7141
7207
  try {
7142
7208
  const v = await settingsApi.get.query(namespace !== void 0 ? {
7143
7209
  namespace,
@@ -7147,11 +7213,40 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7147
7213
  collection: coll,
7148
7214
  key
7149
7215
  });
7150
- return v && typeof v === "object" ? v : {};
7151
- } catch {
7152
- return {};
7216
+ return v && typeof v === "object" ? {
7217
+ kind: "value",
7218
+ value: v
7219
+ } : { kind: "missing" };
7220
+ } catch (err) {
7221
+ return {
7222
+ kind: "failed",
7223
+ error: (0, _camstack_types_addon.errMsg)(err)
7224
+ };
7153
7225
  }
7154
7226
  };
7227
+ /**
7228
+ * Soft read for CONSUMERS: a failure reads as empty so a boot path degrades
7229
+ * to defaults instead of throwing. Safe here precisely because the result is
7230
+ * never written back — {@link readBlobResult} is what the write paths use.
7231
+ */
7232
+ const readBlob = async (coll, key, namespace) => {
7233
+ const r = await readBlobResult(coll, key, namespace);
7234
+ return r.kind === "value" ? r.value : {};
7235
+ };
7236
+ /** Read-modify-write that REFUSES to write when the read failed. */
7237
+ const mergeBlob = async (coll, key, patch, namespace) => {
7238
+ await readModifyWrite({
7239
+ read: () => readBlobResult(coll, key, namespace),
7240
+ write: (next) => writeBlob(coll, key, next, namespace),
7241
+ patch,
7242
+ onRefused: (error) => {
7243
+ scopedLogger.error(`settings merge REFUSED (${coll}/${key}) — read failed, refusing to overwrite with a partial blob`, { meta: {
7244
+ error,
7245
+ patchKeys: Object.keys(patch)
7246
+ } });
7247
+ }
7248
+ });
7249
+ };
7155
7250
  const writeBlob = async (coll, key, value, namespace) => {
7156
7251
  if (!settingsApi) return;
7157
7252
  try {
@@ -7190,10 +7285,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7190
7285
  },
7191
7286
  async writeAddonStore(patch) {
7192
7287
  return serializeRmw("addon-settings", "root", async () => {
7193
- await writeBlob("addon-settings", "root", {
7194
- ...await readBlob("addon-settings", "root", addonId),
7195
- ...patch
7196
- }, addonId);
7288
+ await mergeBlob("addon-settings", "root", patch, addonId);
7197
7289
  });
7198
7290
  },
7199
7291
  async readDeviceStore(deviceId) {
@@ -7202,10 +7294,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7202
7294
  async writeDeviceStore(deviceId, patch) {
7203
7295
  const key = String(deviceId);
7204
7296
  return serializeRmw("addon-devices", key, async () => {
7205
- await writeBlob("addon-devices", key, {
7206
- ...await readBlob("addon-devices", key, addonId),
7207
- ...patch
7208
- }, addonId);
7297
+ await mergeBlob("addon-devices", key, patch, addonId);
7209
7298
  });
7210
7299
  },
7211
7300
  async clearDeviceStore(deviceId) {
@@ -7225,10 +7314,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7225
7314
  },
7226
7315
  async setSection(section, patch) {
7227
7316
  return serializeRmw("sections", section, async () => {
7228
- await writeBlob("sections", section, {
7229
- ...await readBlob("sections", section),
7230
- ...patch
7231
- });
7317
+ await mergeBlob("sections", section, patch);
7232
7318
  });
7233
7319
  }
7234
7320
  };
@@ -1186,6 +1186,27 @@ function addSettingsActions(addon, actions) {
1186
1186
  }
1187
1187
  }
1188
1188
  //#endregion
1189
+ //#region src/kernel/moleculer/settings-read-guard.ts
1190
+ /**
1191
+ * Merge `patch` over the current value and write it — unless the read failed,
1192
+ * in which case write NOTHING.
1193
+ *
1194
+ * Returns whether the write was attempted, so a caller can surface the refusal.
1195
+ */
1196
+ async function readModifyWrite(input) {
1197
+ const current = await input.read();
1198
+ if (current.kind === "failed") {
1199
+ input.onRefused(current.error);
1200
+ return false;
1201
+ }
1202
+ const base = current.kind === "value" ? current.value : {};
1203
+ await input.write({
1204
+ ...base,
1205
+ ...input.patch
1206
+ });
1207
+ return true;
1208
+ }
1209
+ //#endregion
1189
1210
  //#region src/kernel/moleculer/device-cap-proxy.ts
1190
1211
  /**
1191
1212
  * Max number of accessory children spawned concurrently during a single
@@ -3524,8 +3545,6 @@ function localEndpointPath(nodeId) {
3524
3545
  if (process.platform === "win32") return `\\\\.\\pipe\\camstack-${safe}`;
3525
3546
  return join(tmpdir(), `camstack-${safe}.sock`);
3526
3547
  }
3527
- //#endregion
3528
- //#region src/kernel/transport/socket-channel.ts
3529
3548
  var SocketChannel = class {
3530
3549
  socket;
3531
3550
  nextId = 1;
@@ -3544,13 +3563,34 @@ var SocketChannel = class {
3544
3563
  socket.on("close", () => this.handleClose());
3545
3564
  socket.on("error", () => {});
3546
3565
  }
3547
- request(body) {
3566
+ /**
3567
+ * Send a request and wait for the peer's response.
3568
+ *
3569
+ * Bounded. Without a timer this settles on exactly two events — a matching
3570
+ * `res` frame, or the socket's `close` (which drains `pending`) — so a peer
3571
+ * that receives the request and then neither answers nor closes leaves the
3572
+ * caller pending forever. That is a wedged handler, or a half-open socket
3573
+ * after a child died without a clean teardown, and it is how camera 615 spent
3574
+ * 30+ minutes with every inference permit held by a call that was never
3575
+ * coming back.
3576
+ */
3577
+ request(body, opts) {
3548
3578
  if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("channel closed"));
3549
3579
  const id = this.nextId++;
3580
+ const timeoutMs = opts?.timeoutMs ?? 6e4;
3550
3581
  return new Promise((resolve, reject) => {
3582
+ let timer = null;
3583
+ if (timeoutMs > 0) {
3584
+ timer = setTimeout(() => {
3585
+ this.pending.delete(id);
3586
+ reject(/* @__PURE__ */ new Error(`UDS request timed out after ${timeoutMs}ms`));
3587
+ }, timeoutMs);
3588
+ timer.unref?.();
3589
+ }
3551
3590
  this.pending.set(id, {
3552
3591
  resolve,
3553
- reject
3592
+ reject,
3593
+ timer
3554
3594
  });
3555
3595
  this.send({
3556
3596
  k: "req",
@@ -3614,6 +3654,7 @@ var SocketChannel = class {
3614
3654
  const p = this.pending.get(frame.id);
3615
3655
  if (!p) return;
3616
3656
  this.pending.delete(frame.id);
3657
+ if (p.timer !== null) clearTimeout(p.timer);
3617
3658
  if (frame.ok) p.resolve(frame.body);
3618
3659
  else p.reject(new Error(frame.err));
3619
3660
  return;
@@ -3624,7 +3665,10 @@ var SocketChannel = class {
3624
3665
  if (this.closeFired) return;
3625
3666
  this.closeFired = true;
3626
3667
  this.closed = true;
3627
- for (const [, p] of this.pending) p.reject(/* @__PURE__ */ new Error("channel closed"));
3668
+ for (const [, p] of this.pending) {
3669
+ if (p.timer !== null) clearTimeout(p.timer);
3670
+ p.reject(/* @__PURE__ */ new Error("channel closed"));
3671
+ }
3628
3672
  this.pending.clear();
3629
3673
  this.closeHandler();
3630
3674
  }
@@ -4303,6 +4347,8 @@ var LocalChildRegistry = class {
4303
4347
  ownNodeId;
4304
4348
  /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
4305
4349
  isAggregatedCollectionMethod;
4350
+ /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
4351
+ capTimeoutMs;
4306
4352
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
4307
4353
  egressRoutedCaps = /* @__PURE__ */ new Set();
4308
4354
  /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
@@ -4332,6 +4378,7 @@ var LocalChildRegistry = class {
4332
4378
  this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
4333
4379
  this.ownNodeId = opts.ownNodeId;
4334
4380
  this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
4381
+ this.capTimeoutMs = opts.capTimeoutMs;
4335
4382
  } else {
4336
4383
  this.server = serverOrOptions;
4337
4384
  this.onUnownedCall = onUnownedCallArg;
@@ -4494,11 +4541,20 @@ var LocalChildRegistry = class {
4494
4541
  if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
4495
4542
  return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
4496
4543
  }
4544
+ /**
4545
+ * Per-request options for a cap call: the cap method's declared `timeoutMs`
4546
+ * when one exists, else `undefined` so the channel applies its default.
4547
+ * Mirrors `CapRouteResolver.callTimeout` for the remote plane.
4548
+ */
4549
+ capRequestOpts(input) {
4550
+ const declared = this.capTimeoutMs?.(input.capName, input.method);
4551
+ return typeof declared === "number" ? { timeoutMs: declared } : void 0;
4552
+ }
4497
4553
  /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
4498
4554
  async callCapOnChild(childId, input) {
4499
4555
  const entry = this.children.get(childId);
4500
4556
  if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
4501
- return entry.channel.request(this.toCapCall(input));
4557
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
4502
4558
  }
4503
4559
  /** Forward a cap method call to the owning child over UDS; rejects if none. */
4504
4560
  async callCap(input) {
@@ -4508,7 +4564,7 @@ var LocalChildRegistry = class {
4508
4564
  const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
4509
4565
  throw new Error(`no local child provides cap "${input.capName}"${where}`);
4510
4566
  }
4511
- return entry.channel.request(this.toCapCall(input));
4567
+ return entry.channel.request(this.toCapCall(input), this.capRequestOpts(input));
4512
4568
  }
4513
4569
  /**
4514
4570
  * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
@@ -7134,8 +7190,18 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7134
7190
  bindingCache.clear();
7135
7191
  });
7136
7192
  const settingsApi = Reflect.get(api, "settingsStore");
7137
- const readBlob = async (coll, key, namespace) => {
7138
- if (!settingsApi) return {};
7193
+ /**
7194
+ * Read a settings blob, distinguishing a MISS from a FAILURE.
7195
+ *
7196
+ * Collapsing the two is a data-loss bug, not a style choice: every mutation
7197
+ * is read-modify-write, so a failed read that answers "empty" makes the write
7198
+ * persist the patch ALONE and destroy every field the patch does not mention.
7199
+ * That is what emptied the recording configuration on 2026-07-31 when the UDS
7200
+ * transport stalled — `{"enabled": false, "bands": []}`, two and a half hours
7201
+ * of footage never written, and nothing that looked like a fault.
7202
+ */
7203
+ const readBlobResult = async (coll, key, namespace) => {
7204
+ if (!settingsApi) return { kind: "missing" };
7139
7205
  try {
7140
7206
  const v = await settingsApi.get.query(namespace !== void 0 ? {
7141
7207
  namespace,
@@ -7145,11 +7211,40 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7145
7211
  collection: coll,
7146
7212
  key
7147
7213
  });
7148
- return v && typeof v === "object" ? v : {};
7149
- } catch {
7150
- return {};
7214
+ return v && typeof v === "object" ? {
7215
+ kind: "value",
7216
+ value: v
7217
+ } : { kind: "missing" };
7218
+ } catch (err) {
7219
+ return {
7220
+ kind: "failed",
7221
+ error: errMsg(err)
7222
+ };
7151
7223
  }
7152
7224
  };
7225
+ /**
7226
+ * Soft read for CONSUMERS: a failure reads as empty so a boot path degrades
7227
+ * to defaults instead of throwing. Safe here precisely because the result is
7228
+ * never written back — {@link readBlobResult} is what the write paths use.
7229
+ */
7230
+ const readBlob = async (coll, key, namespace) => {
7231
+ const r = await readBlobResult(coll, key, namespace);
7232
+ return r.kind === "value" ? r.value : {};
7233
+ };
7234
+ /** Read-modify-write that REFUSES to write when the read failed. */
7235
+ const mergeBlob = async (coll, key, patch, namespace) => {
7236
+ await readModifyWrite({
7237
+ read: () => readBlobResult(coll, key, namespace),
7238
+ write: (next) => writeBlob(coll, key, next, namespace),
7239
+ patch,
7240
+ onRefused: (error) => {
7241
+ scopedLogger.error(`settings merge REFUSED (${coll}/${key}) — read failed, refusing to overwrite with a partial blob`, { meta: {
7242
+ error,
7243
+ patchKeys: Object.keys(patch)
7244
+ } });
7245
+ }
7246
+ });
7247
+ };
7153
7248
  const writeBlob = async (coll, key, value, namespace) => {
7154
7249
  if (!settingsApi) return;
7155
7250
  try {
@@ -7188,10 +7283,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7188
7283
  },
7189
7284
  async writeAddonStore(patch) {
7190
7285
  return serializeRmw("addon-settings", "root", async () => {
7191
- await writeBlob("addon-settings", "root", {
7192
- ...await readBlob("addon-settings", "root", addonId),
7193
- ...patch
7194
- }, addonId);
7286
+ await mergeBlob("addon-settings", "root", patch, addonId);
7195
7287
  });
7196
7288
  },
7197
7289
  async readDeviceStore(deviceId) {
@@ -7200,10 +7292,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7200
7292
  async writeDeviceStore(deviceId, patch) {
7201
7293
  const key = String(deviceId);
7202
7294
  return serializeRmw("addon-devices", key, async () => {
7203
- await writeBlob("addon-devices", key, {
7204
- ...await readBlob("addon-devices", key, addonId),
7205
- ...patch
7206
- }, addonId);
7295
+ await mergeBlob("addon-devices", key, patch, addonId);
7207
7296
  });
7208
7297
  },
7209
7298
  async clearDeviceStore(deviceId) {
@@ -7223,10 +7312,7 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
7223
7312
  },
7224
7313
  async setSection(section, patch) {
7225
7314
  return serializeRmw("sections", section, async () => {
7226
- await writeBlob("sections", section, {
7227
- ...await readBlob("sections", section),
7228
- ...patch
7229
- });
7315
+ await mergeBlob("sections", section, patch);
7230
7316
  });
7231
7317
  }
7232
7318
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.23",
3
+ "version": "1.2.25",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",