@matter/protocol 0.17.7 → 0.17.8-alpha.0-20260801-92169d0aa

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.
Files changed (61) hide show
  1. package/dist/cjs/interaction/Subscription.d.ts +8 -2
  2. package/dist/cjs/interaction/Subscription.d.ts.map +1 -1
  3. package/dist/cjs/interaction/Subscription.js.map +1 -1
  4. package/dist/cjs/peer/RebootResubscribeArmer.js +1 -1
  5. package/dist/cjs/peer/RebootResubscribeArmer.js.map +1 -1
  6. package/dist/cjs/protocol/DeviceAdvertiser.js +2 -2
  7. package/dist/cjs/protocol/DeviceAdvertiser.js.map +1 -1
  8. package/dist/cjs/protocol/ExchangeManager.d.ts.map +1 -1
  9. package/dist/cjs/protocol/ExchangeManager.js +5 -1
  10. package/dist/cjs/protocol/ExchangeManager.js.map +1 -1
  11. package/dist/cjs/protocol/MessageExchange.d.ts +4 -2
  12. package/dist/cjs/protocol/MessageExchange.d.ts.map +1 -1
  13. package/dist/cjs/protocol/MessageExchange.js +13 -3
  14. package/dist/cjs/protocol/MessageExchange.js.map +1 -1
  15. package/dist/cjs/session/NodeSession.d.ts +1 -1
  16. package/dist/cjs/session/NodeSession.d.ts.map +1 -1
  17. package/dist/cjs/session/NodeSession.js +27 -7
  18. package/dist/cjs/session/NodeSession.js.map +1 -1
  19. package/dist/cjs/session/Session.d.ts +1 -1
  20. package/dist/cjs/session/Session.d.ts.map +1 -1
  21. package/dist/cjs/session/Session.js +2 -2
  22. package/dist/cjs/session/Session.js.map +1 -1
  23. package/dist/cjs/session/SessionManager.d.ts +7 -2
  24. package/dist/cjs/session/SessionManager.d.ts.map +1 -1
  25. package/dist/cjs/session/SessionManager.js +54 -9
  26. package/dist/cjs/session/SessionManager.js.map +1 -1
  27. package/dist/esm/interaction/Subscription.d.ts +8 -2
  28. package/dist/esm/interaction/Subscription.d.ts.map +1 -1
  29. package/dist/esm/interaction/Subscription.js.map +1 -1
  30. package/dist/esm/peer/RebootResubscribeArmer.js +1 -1
  31. package/dist/esm/peer/RebootResubscribeArmer.js.map +1 -1
  32. package/dist/esm/protocol/DeviceAdvertiser.js +2 -2
  33. package/dist/esm/protocol/DeviceAdvertiser.js.map +1 -1
  34. package/dist/esm/protocol/ExchangeManager.d.ts.map +1 -1
  35. package/dist/esm/protocol/ExchangeManager.js +5 -1
  36. package/dist/esm/protocol/ExchangeManager.js.map +1 -1
  37. package/dist/esm/protocol/MessageExchange.d.ts +4 -2
  38. package/dist/esm/protocol/MessageExchange.d.ts.map +1 -1
  39. package/dist/esm/protocol/MessageExchange.js +13 -3
  40. package/dist/esm/protocol/MessageExchange.js.map +1 -1
  41. package/dist/esm/session/NodeSession.d.ts +1 -1
  42. package/dist/esm/session/NodeSession.d.ts.map +1 -1
  43. package/dist/esm/session/NodeSession.js +31 -8
  44. package/dist/esm/session/NodeSession.js.map +1 -1
  45. package/dist/esm/session/Session.d.ts +1 -1
  46. package/dist/esm/session/Session.d.ts.map +1 -1
  47. package/dist/esm/session/Session.js +2 -2
  48. package/dist/esm/session/Session.js.map +1 -1
  49. package/dist/esm/session/SessionManager.d.ts +7 -2
  50. package/dist/esm/session/SessionManager.d.ts.map +1 -1
  51. package/dist/esm/session/SessionManager.js +54 -9
  52. package/dist/esm/session/SessionManager.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/interaction/Subscription.ts +10 -2
  55. package/src/peer/RebootResubscribeArmer.ts +1 -1
  56. package/src/protocol/DeviceAdvertiser.ts +2 -2
  57. package/src/protocol/ExchangeManager.ts +5 -1
  58. package/src/protocol/MessageExchange.ts +18 -15
  59. package/src/session/NodeSession.ts +33 -9
  60. package/src/session/Session.ts +2 -2
  61. package/src/session/SessionManager.ts +77 -11
@@ -520,7 +520,11 @@ export class ExchangeManager implements Transport.Provider {
520
520
  }
521
521
 
522
522
  // Report peer loss to the session manager; this notifies all (relevant) sessions for the peer
523
- await this.#sessions.handlePeerLoss(session.peerAddress, cause, createdAt);
523
+ await this.#sessions.handlePeerLoss(session.peerAddress, {
524
+ cause,
525
+ asOf: createdAt,
526
+ currentExchange: exchange,
527
+ });
524
528
  },
525
529
 
526
530
  retry: number => this.#sessions.retry.emit(session, number),
@@ -103,7 +103,7 @@ export interface ExchangeSendOptions {
103
103
  /** Initial MRP retransmission time (default: calculated as by Matter specification) */
104
104
  initialRetransmissionTime?: Duration;
105
105
 
106
- /** Suppress peer-loss reporting on send-failure so the session stays alive. Used by probing. */
106
+ /** Suppress peer-loss reporting for this send. OR'd with {@link MessageExchange.Options.suppressPeerLoss}. */
107
107
  suppressPeerLoss?: boolean;
108
108
  }
109
109
 
@@ -119,7 +119,7 @@ export interface ExchangeReceiveOptions {
119
119
  */
120
120
  expectedProcessingTime?: Duration;
121
121
 
122
- /** Suppress peer-loss reporting on receive failure so the session stays alive for further probing. */
122
+ /** Suppress peer-loss reporting for this receive. OR'd with {@link MessageExchange.Options.suppressPeerLoss}. */
123
123
  suppressPeerLoss?: boolean;
124
124
  }
125
125
 
@@ -203,6 +203,7 @@ export class MessageExchange {
203
203
  #onReceive?: MessageExchange.ReceiveNotifier;
204
204
  readonly #addressOverride?: ServerAddressUdp;
205
205
  readonly #peerAdditionalMrpDelay?: Duration;
206
+ readonly #suppressPeerLoss: boolean;
206
207
  #receivedMessageToAck: Message | undefined;
207
208
  #receivedMessageAckTimer = Time.getTimer("ack receipt timeout", MRP.STANDALONE_ACK_TIMEOUT, () => {
208
209
  if (this.#receivedMessageToAck !== undefined) {
@@ -256,6 +257,7 @@ export class MessageExchange {
256
257
  network,
257
258
  peerAdditionalMrpDelay,
258
259
  addressOverride,
260
+ suppressPeerLoss = false,
259
261
  } = config;
260
262
 
261
263
  this.#context = context;
@@ -269,6 +271,7 @@ export class MessageExchange {
269
271
  this.#onReceive = onReceive;
270
272
  this.#addressOverride = addressOverride;
271
273
  this.#peerAdditionalMrpDelay = peerAdditionalMrpDelay;
274
+ this.#suppressPeerLoss = suppressPeerLoss;
272
275
 
273
276
  const { activeThreshold, activeInterval, idleInterval } = this.session.parameters;
274
277
 
@@ -499,12 +502,8 @@ export class MessageExchange {
499
502
  try {
500
503
  await this.#send(messageType, payload);
501
504
  } catch (e) {
502
- // Only declare the peer as lost when this exchange has never received a response. If we already
503
- // exchanged messages, the peer was reachable, and the later send-failure may be transient — declaring
504
- // peer loss would unnecessarily close sessions and tear down subscriptions.
505
505
  if (
506
- !options.suppressPeerLoss &&
507
- this.#messageReceivedCounter === 0 &&
506
+ this.#reportsPeerLoss(options.suppressPeerLoss) &&
508
507
  causedBy(e, TransientPeerCommunicationError, TimeoutError, NetworkError)
509
508
  ) {
510
509
  await this.#context.peerLost(this, asError(e));
@@ -514,6 +513,14 @@ export class MessageExchange {
514
513
  }
515
514
  }
516
515
 
516
+ /**
517
+ * Whether a failure here is evidence the peer is gone. Only if we never heard from it on this exchange: one that
518
+ * answered earlier was reachable, and peer loss closes every session with it.
519
+ */
520
+ #reportsPeerLoss(suppressedForOperation?: boolean) {
521
+ return !this.#suppressPeerLoss && suppressedForOperation !== true && this.#messageReceivedCounter === 0;
522
+ }
523
+
517
524
  async #send(messageType: number, payload: Bytes, standaloneAckMessageId?: number) {
518
525
  const {
519
526
  expectAckOnly = false,
@@ -704,14 +711,7 @@ export class MessageExchange {
704
711
  try {
705
712
  return await this.#nextMessage(options);
706
713
  } catch (e) {
707
- // Only declare the peer as lost when this exchange has never received a message. Receiving at least
708
- // one message confirms the peer was reachable; a later timeout waiting for the next message in a
709
- // multi-message exchange should not be treated as permanent peer absence.
710
- if (
711
- !options?.suppressPeerLoss &&
712
- this.#messageReceivedCounter === 0 &&
713
- causedBy(e, TransientPeerCommunicationError)
714
- ) {
714
+ if (this.#reportsPeerLoss(options?.suppressPeerLoss) && causedBy(e, TransientPeerCommunicationError)) {
715
715
  await this.#context.peerLost(this, asError(e));
716
716
  }
717
717
 
@@ -1119,6 +1119,9 @@ export namespace MessageExchange {
1119
1119
  * instead of the session's default peer address.
1120
1120
  */
1121
1121
  addressOverride?: ServerAddressUdp;
1122
+
1123
+ /** Waive peer-loss inference here. Required for exchanges to a peer that drives its own recovery. */
1124
+ suppressPeerLoss?: boolean;
1122
1125
  }
1123
1126
 
1124
1127
  export interface Config extends Options {
@@ -27,9 +27,13 @@ import {
27
27
  Diagnostic,
28
28
  Duration,
29
29
  hex,
30
+ Instant,
30
31
  InternalError,
31
32
  Logger,
32
33
  MatterFlowError,
34
+ Mutex,
35
+ Time,
36
+ Timer,
33
37
  } from "@matter/general";
34
38
  import { CaseAuthenticatedTag, FabricIndex, GlobalFabricId, NodeId } from "@matter/types";
35
39
  import { SecureSession } from "./SecureSession.js";
@@ -55,6 +59,8 @@ export class NodeSession extends SecureSession {
55
59
  readonly supportsMRP = true;
56
60
  readonly type = SessionType.Unicast;
57
61
  readonly #closedByPeer = AsyncObservableValue();
62
+ #rolloverCloseTimer?: Timer;
63
+ readonly #rolloverClose = new Mutex(this);
58
64
  #isPeerLost = false;
59
65
 
60
66
  // TODO - remove this; subscriptions should be owned by the peer, not the session
@@ -108,13 +114,7 @@ export class NodeSession extends SecureSession {
108
114
  ...config,
109
115
  setActiveTimestamp: true, // We always set the active timestamp for Secure sessions
110
116
  // Can be changed to a PersistedMessageCounter if we implement session storage
111
- messageCounter: new MessageCounter(crypto, async () => {
112
- // Secure Session Message Counter
113
- // Expire/End the session before the counter rolls over
114
- await this.initiateClose(async () => {
115
- await this.closeSubscriptions(true);
116
- });
117
- }),
117
+ messageCounter: new MessageCounter(crypto, async () => this.#scheduleRolloverClose()),
118
118
  messageReceptionState: new MessageReceptionStateEncryptedWithoutRollover(0),
119
119
  });
120
120
 
@@ -269,10 +269,10 @@ export class NodeSession extends SecureSession {
269
269
  return this.#fabric;
270
270
  }
271
271
 
272
- override async closeSubscriptions(flush = false) {
272
+ override async closeSubscriptions(flush = false, currentExchange?: MessageExchange) {
273
273
  const subscriptions = [...this.#subscriptions]; // get all values because subscriptions will remove themselves when cancelled
274
274
  for (const subscription of subscriptions) {
275
- await subscription.close(flush ? this : undefined);
275
+ await subscription.close(flush ? this : undefined, currentExchange);
276
276
  }
277
277
  return subscriptions.length;
278
278
  }
@@ -337,7 +337,31 @@ export class NodeSession extends SecureSession {
337
337
  });
338
338
  }
339
339
 
340
+ /**
341
+ * Wind the session down before its message counter rolls over. Notification arrives 100,000 messages ahead, on
342
+ * the stack of the send that consumed the counter, so closing there would make the flush wait on that send.
343
+ */
344
+ #scheduleRolloverClose() {
345
+ if (this.#rolloverCloseTimer !== undefined || this.isClosing) {
346
+ return;
347
+ }
348
+
349
+ logger.info(this.via, "Message counter nearing rollover, ending session");
350
+
351
+ this.#rolloverCloseTimer = Time.getTimer("session rollover close", Instant, () =>
352
+ this.#rolloverClose.run(async () => {
353
+ await this.initiateClose(async () => {
354
+ await this.closeSubscriptions(true);
355
+ });
356
+ }),
357
+ ).start();
358
+ }
359
+
340
360
  protected override async close() {
361
+ this.#rolloverCloseTimer?.stop();
362
+ this.#rolloverCloseTimer = undefined;
363
+ await this.#rolloverClose;
364
+
341
365
  if (!this.#isPeerLost) {
342
366
  try {
343
367
  await this.gracefulClose.emit();
@@ -274,7 +274,7 @@ export abstract class Session {
274
274
  async initiateForceClose(context: PeerLossContext) {
275
275
  await this.initiateClose(async () => {
276
276
  if (!context.keepSubscriptions) {
277
- await this.closeSubscriptions();
277
+ await this.closeSubscriptions(false, context.currentExchange);
278
278
  }
279
279
  for (const exchange of this.#exchanges) {
280
280
  if (exchange === context.currentExchange) {
@@ -317,7 +317,7 @@ export abstract class Session {
317
317
  return !!this.#exchanges.size;
318
318
  }
319
319
 
320
- async closeSubscriptions(_cancelledByPeer = false): Promise<number> {
320
+ async closeSubscriptions(_flush = false, _currentExchange?: MessageExchange): Promise<number> {
321
321
  return 0;
322
322
  }
323
323
 
@@ -156,6 +156,12 @@ export interface SessionManagerContext {
156
156
 
157
157
  const ID_SPACE_UPPER_BOUND = 0xffff;
158
158
 
159
+ /**
160
+ * Sessions retained per peer. A new CASE does not close the peer's previous sessions, so a peer that reconnects
161
+ * repeatedly accumulates them. Per peer rather than global so one noisy peer cannot evict another's.
162
+ */
163
+ const MAX_SESSIONS_PER_PEER = 5;
164
+
159
165
  /** Storage key for the node-global Group Encrypted Data Message Counter in the session storage context. */
160
166
  const GROUP_DATA_COUNTER_KEY = "groupDataCounter";
161
167
 
@@ -166,8 +172,15 @@ const GROUP_DATA_COUNTER_KEY = "groupDataCounter";
166
172
  */
167
173
  const GROUP_DATA_COUNTER_RESERVE = 1000;
168
174
 
175
+ /** Thrown into a session closed because its peer exceeded {@link MAX_SESSIONS_PER_PEER}. */
176
+ export class SessionEvictedError extends ClosedError {
177
+ constructor(message = "Session evicted; peer holds too many sessions", options?: ErrorOptions) {
178
+ super(message, options);
179
+ }
180
+ }
181
+
169
182
  /**
170
- * Thrown when communication terminates due node shutdown.
183
+ * Thrown when communication terminates due to node shutdown.
171
184
  */
172
185
  export class ShutdownError extends ClosedError {
173
186
  constructor(message = "Local node shutdown", options?: ErrorOptions) {
@@ -203,6 +216,7 @@ export class SessionManager {
203
216
  readonly #construction: Construction<SessionManager>;
204
217
  readonly #observers = new ObserverGroup();
205
218
  readonly #subscriptionUpdateMutex = new Mutex(this);
219
+ readonly #sessionEvictionMutex = new Mutex(this);
206
220
  #idUpperBound = ID_SPACE_UPPER_BOUND;
207
221
 
208
222
  readonly #subscriptionsChanged = Observable<[session: NodeSession, subscription: Subscription]>();
@@ -224,6 +238,14 @@ export class SessionManager {
224
238
  await this.deleteResumptionRecordsForFabric(fabric);
225
239
  });
226
240
 
241
+ this.#sessions.added.on(session => {
242
+ // We are the responder, so nothing else reclaims these: an initiator's sessions are swept by the peer
243
+ // loss its own traffic reports
244
+ if (!session.isInitiator) {
245
+ this.#sessionEvictionMutex.run(() => this.#evictExcessSessionsFor(session));
246
+ }
247
+ });
248
+
227
249
  // Add subscription monitors to new node sessions
228
250
  this.#sessions.added.on(session => {
229
251
  const subscriptionsChanged = (subscription: Subscription) => {
@@ -444,19 +466,25 @@ export class SessionManager {
444
466
  return deletedCount > 0;
445
467
  }
446
468
 
447
- findOldestInactiveSession() {
448
- this.#construction.assert();
449
-
450
- let oldestSession: NodeSession | undefined = undefined;
469
+ /** The session with the least recent traffic among those {@link filter} accepts, if any. */
470
+ #leastRecentlyUsedSession(filter: (session: NodeSession) => boolean) {
471
+ let oldest: NodeSession | undefined;
451
472
  for (const session of this.#sessions) {
452
- if (!oldestSession || session.timestamp < oldestSession.timestamp) {
453
- oldestSession = session;
473
+ if (filter(session) && (oldest === undefined || session.timestamp < oldest.timestamp)) {
474
+ oldest = session;
454
475
  }
455
476
  }
456
- if (oldestSession === undefined) {
477
+ return oldest;
478
+ }
479
+
480
+ findOldestInactiveSession() {
481
+ this.#construction.assert();
482
+
483
+ const oldest = this.#leastRecentlyUsedSession(() => true);
484
+ if (oldest === undefined) {
457
485
  throw new MatterFlowError("No session found to close and all session ids are taken.");
458
486
  }
459
- return oldestSession;
487
+ return oldest;
460
488
  }
461
489
 
462
490
  async getNextAvailableSessionId() {
@@ -556,8 +584,45 @@ export class SessionManager {
556
584
  /**
557
585
  * Removes all Peer sessions and closes subscriptions.
558
586
  */
559
- async handlePeerLoss(address: PeerAddress, cause: Error, asOf?: Timestamp) {
560
- return await this.#handlePeerLoss({ address, asOf: asOf ?? Time.nowMs, cause });
587
+ async handlePeerLoss(address: PeerAddress, context: PeerLossContext) {
588
+ return await this.#handlePeerLoss({ ...context, address, asOf: context.asOf ?? Time.nowMs });
589
+ }
590
+
591
+ /**
592
+ * Close the peer's least recently active sessions once it exceeds {@link MAX_SESSIONS_PER_PEER}.
593
+ *
594
+ * Only sessions the peer established are eligible: ones we initiated are ours to manage, and the peer loss our
595
+ * own traffic reports already sweeps them. {@link added} is excluded so a burst of new sessions cannot evict the
596
+ * one that prompted the check.
597
+ */
598
+ async #evictExcessSessionsFor(added: NodeSession) {
599
+ if (added.isPase || added.isClosing) {
600
+ return;
601
+ }
602
+
603
+ const address = added.peerAddress;
604
+ const claimsSlot = (session: NodeSession) =>
605
+ session !== added && !session.isInitiator && session.peerIs(address);
606
+
607
+ // A session already closing still occupies a slot until it completes, so it counts here but is not evicted
608
+ // again -- otherwise one that parks in deferredClose drops out of the count and the cap stops capping
609
+ let excess = this.#sessions.filter(claimsSlot).length + 1 - MAX_SESSIONS_PER_PEER;
610
+
611
+ while (excess-- > 0) {
612
+ const session = this.#leastRecentlyUsedSession(s => claimsSlot(s) && !s.isClosing);
613
+ if (session === undefined) {
614
+ return;
615
+ }
616
+
617
+ logger.info(
618
+ session.via,
619
+ `Closing least recently used session; ${PeerAddress(address)} exceeds ${MAX_SESSIONS_PER_PEER} sessions`,
620
+ );
621
+
622
+ // Force close rather than the graceful path: that sends CloseSession over MRP, and the eviction target is
623
+ // typically the peer we can no longer reach, so it would hold this mutex for a full retransmission window
624
+ await session.initiateForceClose({ cause: new SessionEvictedError() });
625
+ }
561
626
  }
562
627
 
563
628
  async #handlePeerLoss(
@@ -917,6 +982,7 @@ export class SessionManager {
917
982
  }
918
983
 
919
984
  await this.#subscriptionUpdateMutex;
985
+ await this.#sessionEvictionMutex;
920
986
 
921
987
  const context: PeerLossContext = { cause: new ShutdownError("Session closed by node shutdown") };
922
988