@riddix/hamh 2.1.0-alpha.712 → 2.1.0-alpha.713
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/backend/cli.js
CHANGED
|
@@ -124211,8 +124211,7 @@ var init_bridge_config_schema = __esm({
|
|
|
124211
124211
|
sessionMaxAgeHours: {
|
|
124212
124212
|
title: "Session Rotation Max Age (hours)",
|
|
124213
124213
|
type: "number",
|
|
124214
|
-
description: "
|
|
124215
|
-
default: 4,
|
|
124214
|
+
description: "Rotate matter sessions older than this many hours so controllers re-establish and re-subscribe. Server Mode rotates every 4h by default; standard bridges only rotate when you set a value here. Set 0 to disable. Range 0 to 168. (#287)",
|
|
124216
124215
|
minimum: 0,
|
|
124217
124216
|
maximum: 168
|
|
124218
124217
|
},
|
|
@@ -147398,6 +147397,30 @@ function ensureCommissioningConfig(server) {
|
|
|
147398
147397
|
|
|
147399
147398
|
// src/services/bridges/bridge.ts
|
|
147400
147399
|
init_diagnostic_event_bus();
|
|
147400
|
+
|
|
147401
|
+
// src/services/bridges/session-rotation.ts
|
|
147402
|
+
var DEFAULT_SESSION_MAX_AGE_HOURS = 4;
|
|
147403
|
+
var SESSION_MAX_AGE_HOURS_RANGE = { min: 1, max: 168 };
|
|
147404
|
+
var ROTATION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
|
|
147405
|
+
function parseSessionMaxAgeHours(raw) {
|
|
147406
|
+
if (raw == null || raw === "") return DEFAULT_SESSION_MAX_AGE_HOURS;
|
|
147407
|
+
const n = Number.parseInt(raw, 10);
|
|
147408
|
+
if (Number.isNaN(n) || n < 0) return null;
|
|
147409
|
+
if (n === 0) return 0;
|
|
147410
|
+
const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
|
|
147411
|
+
if (n < min) return min;
|
|
147412
|
+
if (n > max) return max;
|
|
147413
|
+
return n;
|
|
147414
|
+
}
|
|
147415
|
+
function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
|
|
147416
|
+
for (const session of sessions) {
|
|
147417
|
+
if (!startedAt.has(session.id)) {
|
|
147418
|
+
startedAt.set(session.id, now);
|
|
147419
|
+
}
|
|
147420
|
+
}
|
|
147421
|
+
}
|
|
147422
|
+
|
|
147423
|
+
// src/services/bridges/bridge.ts
|
|
147401
147424
|
var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
|
|
147402
147425
|
var DEAD_SESSION_TIMEOUT_MS = 6e4;
|
|
147403
147426
|
var Bridge = class {
|
|
@@ -147436,6 +147459,11 @@ var Bridge = class {
|
|
|
147436
147459
|
autoForceSyncTimer = null;
|
|
147437
147460
|
deadSessionTimer = null;
|
|
147438
147461
|
staleSessionTimers = /* @__PURE__ */ new Map();
|
|
147462
|
+
// Age-based session rotation (#287): track when each session opened so an
|
|
147463
|
+
// aged controller session can be rotated, forcing it to re-subscribe.
|
|
147464
|
+
sessionStartedAt = /* @__PURE__ */ new Map();
|
|
147465
|
+
rotationTimer = null;
|
|
147466
|
+
maxSessionAgeMs = 0;
|
|
147439
147467
|
// Serialize concurrent lifecycle calls so auto-recovery and a manual
|
|
147440
147468
|
// restartBridge can't race past each other's Starting/Stopping states.
|
|
147441
147469
|
startInFlight;
|
|
@@ -147588,6 +147616,7 @@ var Bridge = class {
|
|
|
147588
147616
|
});
|
|
147589
147617
|
}
|
|
147590
147618
|
this.wireSessionDiagnostics();
|
|
147619
|
+
this.startSessionRotation();
|
|
147591
147620
|
logMemoryUsage(this.log, "bridge running");
|
|
147592
147621
|
diagnosticEventBus.emit("bridge_started", `Bridge started`, {
|
|
147593
147622
|
bridgeId: this.id,
|
|
@@ -147645,6 +147674,7 @@ ${e?.toString()}`);
|
|
|
147645
147674
|
wireSessionDiagnostics() {
|
|
147646
147675
|
try {
|
|
147647
147676
|
const sessionManager = this.server.env.get(SessionManager);
|
|
147677
|
+
seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
|
|
147648
147678
|
this.sessionDiagHandler = (session) => {
|
|
147649
147679
|
const sessions = [...sessionManager.sessions];
|
|
147650
147680
|
let totalSubs = 0;
|
|
@@ -147703,6 +147733,7 @@ ${e?.toString()}`);
|
|
|
147703
147733
|
};
|
|
147704
147734
|
sessionManager.subscriptionsChanged.on(this.sessionDiagHandler);
|
|
147705
147735
|
this.sessionAddedHandler = (newSession) => {
|
|
147736
|
+
this.sessionStartedAt.set(newSession.id, Date.now());
|
|
147706
147737
|
this.log.info(
|
|
147707
147738
|
`Session opened: id=${newSession.id} peer=${newSession.peerNodeId}`
|
|
147708
147739
|
);
|
|
@@ -147728,6 +147759,7 @@ ${e?.toString()}`);
|
|
|
147728
147759
|
}
|
|
147729
147760
|
};
|
|
147730
147761
|
this.sessionDeletedHandler = (session) => {
|
|
147762
|
+
this.sessionStartedAt.delete(session.id);
|
|
147731
147763
|
const sessions = [...sessionManager.sessions];
|
|
147732
147764
|
this.log.warn(
|
|
147733
147765
|
`Session closed: id=${session.id} peer=${session.peerNodeId} | remaining sessions=${sessions.length}`
|
|
@@ -147835,6 +147867,8 @@ ${e?.toString()}`);
|
|
|
147835
147867
|
clearTimeout(timer);
|
|
147836
147868
|
}
|
|
147837
147869
|
this.staleSessionTimers.clear();
|
|
147870
|
+
this.stopSessionRotation();
|
|
147871
|
+
this.sessionStartedAt.clear();
|
|
147838
147872
|
}
|
|
147839
147873
|
stopAutoForceSync() {
|
|
147840
147874
|
if (this.autoForceSyncTimer) {
|
|
@@ -147842,12 +147876,100 @@ ${e?.toString()}`);
|
|
|
147842
147876
|
this.autoForceSyncTimer = null;
|
|
147843
147877
|
}
|
|
147844
147878
|
}
|
|
147879
|
+
// Start periodic age-based session rotation (#287). Aging out a controller's
|
|
147880
|
+
// session forces it to re-establish and re-subscribe, recovering a wedged
|
|
147881
|
+
// Alexa subscription that would otherwise stay stuck until a restart.
|
|
147882
|
+
startSessionRotation() {
|
|
147883
|
+
this.stopSessionRotation();
|
|
147884
|
+
const hours = this.readSessionMaxAgeHours();
|
|
147885
|
+
if (hours === 0) {
|
|
147886
|
+
this.log.info(
|
|
147887
|
+
"Session rotation disabled (HAMH_MATTER_SESSION_MAX_AGE_HOURS=0)"
|
|
147888
|
+
);
|
|
147889
|
+
return;
|
|
147890
|
+
}
|
|
147891
|
+
this.maxSessionAgeMs = hours * 60 * 60 * 1e3;
|
|
147892
|
+
this.rotationTimer = setInterval(
|
|
147893
|
+
() => this.rotateAgedSessions(),
|
|
147894
|
+
ROTATION_CHECK_INTERVAL_MS
|
|
147895
|
+
);
|
|
147896
|
+
this.log.info(
|
|
147897
|
+
`Session rotation: max age ${hours}h, check every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
|
|
147898
|
+
);
|
|
147899
|
+
}
|
|
147900
|
+
stopSessionRotation() {
|
|
147901
|
+
if (this.rotationTimer) {
|
|
147902
|
+
clearInterval(this.rotationTimer);
|
|
147903
|
+
this.rotationTimer = null;
|
|
147904
|
+
}
|
|
147905
|
+
}
|
|
147906
|
+
// Resolve the rotation max age. Bridge config wins, then the env var. An
|
|
147907
|
+
// aggregator bridge holds many devices on one controller session, so
|
|
147908
|
+
// rotating it re-subscribes them all at once. Rotation is therefore opt-in
|
|
147909
|
+
// here: it stays disabled unless set via config or the env var.
|
|
147910
|
+
readSessionMaxAgeHours() {
|
|
147911
|
+
const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
|
|
147912
|
+
const fromConfig = this.dataProvider.sessionMaxAgeHours;
|
|
147913
|
+
if (fromConfig != null && Number.isFinite(fromConfig) && fromConfig >= 0) {
|
|
147914
|
+
if (fromConfig === 0) return 0;
|
|
147915
|
+
if (fromConfig < min) return min;
|
|
147916
|
+
if (fromConfig > max) return max;
|
|
147917
|
+
return fromConfig;
|
|
147918
|
+
}
|
|
147919
|
+
const raw = process.env.HAMH_MATTER_SESSION_MAX_AGE_HOURS;
|
|
147920
|
+
if (raw == null || raw === "") {
|
|
147921
|
+
return 0;
|
|
147922
|
+
}
|
|
147923
|
+
const parsed = parseSessionMaxAgeHours(raw);
|
|
147924
|
+
if (parsed == null) {
|
|
147925
|
+
this.log.warn(
|
|
147926
|
+
`Invalid HAMH_MATTER_SESSION_MAX_AGE_HOURS=${raw}, disabling session rotation`
|
|
147927
|
+
);
|
|
147928
|
+
return 0;
|
|
147929
|
+
}
|
|
147930
|
+
return parsed;
|
|
147931
|
+
}
|
|
147932
|
+
// Gracefully close sessions older than maxSessionAgeMs that still hold
|
|
147933
|
+
// subscriptions, so the controller re-establishes CASE and re-subscribes.
|
|
147934
|
+
// 0-sub sessions are handled by the dead/stale-session path.
|
|
147935
|
+
rotateAgedSessions() {
|
|
147936
|
+
if (this.maxSessionAgeMs === 0) return;
|
|
147937
|
+
try {
|
|
147938
|
+
const sessionManager = this.server.env.get(SessionManager);
|
|
147939
|
+
const now = Date.now();
|
|
147940
|
+
const closes = [];
|
|
147941
|
+
for (const s of [...sessionManager.sessions]) {
|
|
147942
|
+
const startedAt = this.sessionStartedAt.get(s.id);
|
|
147943
|
+
if (startedAt == null) continue;
|
|
147944
|
+
const ageMs = now - startedAt;
|
|
147945
|
+
if (ageMs < this.maxSessionAgeMs || s.isClosing || s.subscriptions.size === 0) {
|
|
147946
|
+
continue;
|
|
147947
|
+
}
|
|
147948
|
+
const ageMin = Math.round(ageMs / 6e4);
|
|
147949
|
+
this.log.info(
|
|
147950
|
+
`Rotating session ${s.id} (peer ${s.peerNodeId}, age ${ageMin}min, subs ${s.subscriptions.size})`
|
|
147951
|
+
);
|
|
147952
|
+
closes.push(
|
|
147953
|
+
s.initiateClose().catch(() => {
|
|
147954
|
+
return s.initiateForceClose({
|
|
147955
|
+
cause: new Error("session rotation, forcing")
|
|
147956
|
+
});
|
|
147957
|
+
})
|
|
147958
|
+
);
|
|
147959
|
+
}
|
|
147960
|
+
if (closes.length > 0) {
|
|
147961
|
+
Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
|
|
147962
|
+
}
|
|
147963
|
+
} catch {
|
|
147964
|
+
}
|
|
147965
|
+
}
|
|
147845
147966
|
async update(update) {
|
|
147846
147967
|
try {
|
|
147847
147968
|
this.dataProvider.update(update);
|
|
147848
147969
|
await this.refreshDevices();
|
|
147849
147970
|
if (this.status.code === BridgeStatus.Running) {
|
|
147850
147971
|
this.startAutoForceSyncIfEnabled();
|
|
147972
|
+
this.startSessionRotation();
|
|
147851
147973
|
}
|
|
147852
147974
|
} catch (e) {
|
|
147853
147975
|
const reason = "Failed to update bridge due to error:";
|
|
@@ -160910,6 +161032,7 @@ var EntityIsolationService = new EntityIsolationServiceImpl();
|
|
|
160910
161032
|
|
|
160911
161033
|
// src/services/bridges/bridge-endpoint-manager.ts
|
|
160912
161034
|
var MAX_ENTITY_ID_LENGTH = 150;
|
|
161035
|
+
var ENDPOINT_REMOVAL_GRACE_MS = 6e4;
|
|
160913
161036
|
var BridgeEndpointManager = class extends Service {
|
|
160914
161037
|
constructor(client, registry2, mappingStorage, bridgeId, log, pluginManager, pluginRegistry, pluginInstaller) {
|
|
160915
161038
|
super("BridgeEndpointManager");
|
|
@@ -160943,6 +161066,9 @@ var BridgeEndpointManager = class extends Service {
|
|
|
160943
161066
|
unsubscribe;
|
|
160944
161067
|
_failedEntities = [];
|
|
160945
161068
|
mappingFingerprints = /* @__PURE__ */ new Map();
|
|
161069
|
+
// entityId -> first time it went missing from the registry (grace window)
|
|
161070
|
+
pendingRemovals = /* @__PURE__ */ new Map();
|
|
161071
|
+
removalRecheckTimer = null;
|
|
160946
161072
|
pluginEndpoints = /* @__PURE__ */ new Map();
|
|
160947
161073
|
pluginStateUpdating = /* @__PURE__ */ new Set();
|
|
160948
161074
|
pluginListeners = /* @__PURE__ */ new Map();
|
|
@@ -161145,8 +161271,26 @@ var BridgeEndpointManager = class extends Service {
|
|
|
161145
161271
|
} catch (e) {
|
|
161146
161272
|
this.log.error(`Failed to delete isolated endpoint:`, e);
|
|
161147
161273
|
}
|
|
161274
|
+
this.pendingRemovals.delete(endpoint.entityId);
|
|
161275
|
+
this.mappingFingerprints.delete(endpoint.entityId);
|
|
161148
161276
|
}
|
|
161149
161277
|
}
|
|
161278
|
+
// refreshDevices only runs on registry-fingerprint changes, which may not
|
|
161279
|
+
// recur, so drive any held removals to completion ourselves once the grace
|
|
161280
|
+
// window has passed.
|
|
161281
|
+
scheduleRemovalRecheck() {
|
|
161282
|
+
if (this.removalRecheckTimer) {
|
|
161283
|
+
clearTimeout(this.removalRecheckTimer);
|
|
161284
|
+
this.removalRecheckTimer = null;
|
|
161285
|
+
}
|
|
161286
|
+
if (this.pendingRemovals.size === 0) return;
|
|
161287
|
+
this.removalRecheckTimer = setTimeout(() => {
|
|
161288
|
+
this.removalRecheckTimer = null;
|
|
161289
|
+
this.refreshDevices().catch(
|
|
161290
|
+
(e) => this.log.warn("Endpoint removal recheck failed:", e)
|
|
161291
|
+
);
|
|
161292
|
+
}, ENDPOINT_REMOVAL_GRACE_MS + 5e3);
|
|
161293
|
+
}
|
|
161150
161294
|
getPluginDomainMappings() {
|
|
161151
161295
|
if (!this.pluginManager) return void 0;
|
|
161152
161296
|
const mappings = this.pluginManager.getDomainMappings();
|
|
@@ -161166,6 +161310,11 @@ var BridgeEndpointManager = class extends Service {
|
|
|
161166
161310
|
}
|
|
161167
161311
|
async dispose() {
|
|
161168
161312
|
this.stopObserving();
|
|
161313
|
+
if (this.removalRecheckTimer) {
|
|
161314
|
+
clearTimeout(this.removalRecheckTimer);
|
|
161315
|
+
this.removalRecheckTimer = null;
|
|
161316
|
+
}
|
|
161317
|
+
this.pendingRemovals.clear();
|
|
161169
161318
|
EntityIsolationService.unregisterIsolationCallback(this.bridgeId);
|
|
161170
161319
|
EntityIsolationService.clearIsolatedEntities(this.bridgeId);
|
|
161171
161320
|
const endpoints = this.root.parts.map((p) => p);
|
|
@@ -161232,14 +161381,30 @@ var BridgeEndpointManager = class extends Service {
|
|
|
161232
161381
|
}
|
|
161233
161382
|
}
|
|
161234
161383
|
const existingEndpoints = [];
|
|
161384
|
+
const now = Date.now();
|
|
161235
161385
|
for (const endpoint of endpoints) {
|
|
161236
|
-
|
|
161386
|
+
const present = this.entityIds.includes(endpoint.entityId);
|
|
161387
|
+
if (present) {
|
|
161388
|
+
this.pendingRemovals.delete(endpoint.entityId);
|
|
161389
|
+
}
|
|
161390
|
+
if (!present) {
|
|
161391
|
+
const since = this.pendingRemovals.get(endpoint.entityId);
|
|
161392
|
+
if (since == null) {
|
|
161393
|
+
this.pendingRemovals.set(endpoint.entityId, now);
|
|
161394
|
+
existingEndpoints.push(endpoint);
|
|
161395
|
+
continue;
|
|
161396
|
+
}
|
|
161397
|
+
if (now - since < ENDPOINT_REMOVAL_GRACE_MS) {
|
|
161398
|
+
existingEndpoints.push(endpoint);
|
|
161399
|
+
continue;
|
|
161400
|
+
}
|
|
161237
161401
|
try {
|
|
161238
161402
|
await endpoint.delete();
|
|
161239
161403
|
} catch (e) {
|
|
161240
161404
|
this.log.warn(`Failed to delete endpoint ${endpoint.entityId}:`, e);
|
|
161241
161405
|
}
|
|
161242
161406
|
this.mappingFingerprints.delete(endpoint.entityId);
|
|
161407
|
+
this.pendingRemovals.delete(endpoint.entityId);
|
|
161243
161408
|
} else if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(endpoint.entityId)) {
|
|
161244
161409
|
this.log.info(
|
|
161245
161410
|
`Deleting standalone endpoint ${endpoint.entityId}, consumed by composed device`
|
|
@@ -161275,6 +161440,7 @@ var BridgeEndpointManager = class extends Service {
|
|
|
161275
161440
|
}
|
|
161276
161441
|
}
|
|
161277
161442
|
}
|
|
161443
|
+
this.scheduleRemovalRecheck();
|
|
161278
161444
|
let memoryLimitReached = false;
|
|
161279
161445
|
for (const entityId of this.entityIds) {
|
|
161280
161446
|
if (!memoryLimitReached && isHeapUnderPressure()) {
|
|
@@ -162159,26 +162325,6 @@ init_dist();
|
|
|
162159
162325
|
init_diagnostic_event_bus();
|
|
162160
162326
|
var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
|
|
162161
162327
|
var DEAD_SESSION_TIMEOUT_MS2 = 6e4;
|
|
162162
|
-
var DEFAULT_SESSION_MAX_AGE_HOURS = 4;
|
|
162163
|
-
var SESSION_MAX_AGE_HOURS_RANGE = { min: 1, max: 168 };
|
|
162164
|
-
var ROTATION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
|
|
162165
|
-
function parseSessionMaxAgeHours(raw) {
|
|
162166
|
-
if (raw == null || raw === "") return DEFAULT_SESSION_MAX_AGE_HOURS;
|
|
162167
|
-
const n = Number.parseInt(raw, 10);
|
|
162168
|
-
if (Number.isNaN(n) || n < 0) return null;
|
|
162169
|
-
if (n === 0) return 0;
|
|
162170
|
-
const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
|
|
162171
|
-
if (n < min) return min;
|
|
162172
|
-
if (n > max) return max;
|
|
162173
|
-
return n;
|
|
162174
|
-
}
|
|
162175
|
-
function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
|
|
162176
|
-
for (const session of sessions) {
|
|
162177
|
-
if (!startedAt.has(session.id)) {
|
|
162178
|
-
startedAt.set(session.id, now);
|
|
162179
|
-
}
|
|
162180
|
-
}
|
|
162181
|
-
}
|
|
162182
162328
|
function makeWarmStartState(state, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
162183
162329
|
return { ...state, last_updated: now };
|
|
162184
162330
|
}
|