@matter/protocol 0.17.3 → 0.17.4-alpha.0-20260623-5647634fc

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 (55) hide show
  1. package/dist/cjs/action/client/ClientInteraction.d.ts +2 -0
  2. package/dist/cjs/action/client/ClientInteraction.d.ts.map +1 -1
  3. package/dist/cjs/action/client/ClientInteraction.js +6 -4
  4. package/dist/cjs/action/client/ClientInteraction.js.map +1 -1
  5. package/dist/cjs/peer/Peer.d.ts +10 -1
  6. package/dist/cjs/peer/Peer.d.ts.map +1 -1
  7. package/dist/cjs/peer/Peer.js +15 -5
  8. package/dist/cjs/peer/Peer.js.map +1 -1
  9. package/dist/cjs/peer/PeerAddressMonitor.d.ts +19 -1
  10. package/dist/cjs/peer/PeerAddressMonitor.d.ts.map +1 -1
  11. package/dist/cjs/peer/PeerAddressMonitor.js +99 -65
  12. package/dist/cjs/peer/PeerAddressMonitor.js.map +1 -1
  13. package/dist/cjs/peer/PeerExchangeProvider.d.ts +5 -1
  14. package/dist/cjs/peer/PeerExchangeProvider.d.ts.map +1 -1
  15. package/dist/cjs/peer/PeerExchangeProvider.js +3 -0
  16. package/dist/cjs/peer/PeerExchangeProvider.js.map +1 -1
  17. package/dist/cjs/peer/PeerTimingParameters.d.ts +2 -2
  18. package/dist/cjs/peer/PeerTimingParameters.d.ts.map +1 -1
  19. package/dist/cjs/peer/PeerTimingParameters.js +2 -2
  20. package/dist/cjs/peer/PeerTimingParameters.js.map +1 -1
  21. package/dist/cjs/protocol/ExchangeProvider.d.ts +11 -0
  22. package/dist/cjs/protocol/ExchangeProvider.d.ts.map +1 -1
  23. package/dist/cjs/protocol/ExchangeProvider.js +8 -0
  24. package/dist/cjs/protocol/ExchangeProvider.js.map +1 -1
  25. package/dist/esm/action/client/ClientInteraction.d.ts +2 -0
  26. package/dist/esm/action/client/ClientInteraction.d.ts.map +1 -1
  27. package/dist/esm/action/client/ClientInteraction.js +6 -4
  28. package/dist/esm/action/client/ClientInteraction.js.map +1 -1
  29. package/dist/esm/peer/Peer.d.ts +10 -1
  30. package/dist/esm/peer/Peer.d.ts.map +1 -1
  31. package/dist/esm/peer/Peer.js +15 -5
  32. package/dist/esm/peer/Peer.js.map +1 -1
  33. package/dist/esm/peer/PeerAddressMonitor.d.ts +19 -1
  34. package/dist/esm/peer/PeerAddressMonitor.d.ts.map +1 -1
  35. package/dist/esm/peer/PeerAddressMonitor.js +100 -65
  36. package/dist/esm/peer/PeerAddressMonitor.js.map +1 -1
  37. package/dist/esm/peer/PeerExchangeProvider.d.ts +5 -1
  38. package/dist/esm/peer/PeerExchangeProvider.d.ts.map +1 -1
  39. package/dist/esm/peer/PeerExchangeProvider.js +3 -0
  40. package/dist/esm/peer/PeerExchangeProvider.js.map +1 -1
  41. package/dist/esm/peer/PeerTimingParameters.d.ts +2 -2
  42. package/dist/esm/peer/PeerTimingParameters.d.ts.map +1 -1
  43. package/dist/esm/peer/PeerTimingParameters.js +3 -3
  44. package/dist/esm/peer/PeerTimingParameters.js.map +1 -1
  45. package/dist/esm/protocol/ExchangeProvider.d.ts +11 -0
  46. package/dist/esm/protocol/ExchangeProvider.d.ts.map +1 -1
  47. package/dist/esm/protocol/ExchangeProvider.js +8 -0
  48. package/dist/esm/protocol/ExchangeProvider.js.map +1 -1
  49. package/package.json +5 -5
  50. package/src/action/client/ClientInteraction.ts +7 -4
  51. package/src/peer/Peer.ts +20 -8
  52. package/src/peer/PeerAddressMonitor.ts +127 -92
  53. package/src/peer/PeerExchangeProvider.ts +5 -1
  54. package/src/peer/PeerTimingParameters.ts +5 -5
  55. package/src/protocol/ExchangeProvider.ts +12 -0
@@ -4,7 +4,9 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import type { ReachabilityReason } from "#protocol/ExchangeProvider.js";
7
8
  import {
9
+ Abort,
8
10
  ChannelType,
9
11
  Diagnostic,
10
12
  Duration,
@@ -21,6 +23,9 @@ import { PeerUnresponsiveError } from "./PeerCommunicationError.js";
21
23
 
22
24
  const logger = Logger.get("PeerAddressMonitor");
23
25
 
26
+ /** A liveness probe does not need the full MRP retransmission budget. */
27
+ const PROBE_MAX_RETRANSMISSIONS = 2;
28
+
24
29
  /**
25
30
  * Monitors mDNS-discovered addresses for a peer and probes the current session when the session's IP
26
31
  * disappears from discovered addresses.
@@ -41,12 +46,11 @@ export class PeerAddressMonitor {
41
46
  readonly #timer: Timer;
42
47
  readonly #cooldownMin: Duration;
43
48
  readonly #cooldownMax: Duration;
44
- readonly #trackWork: (work: PromiseLike<void>) => void;
45
- #probing?: Promise<void>;
49
+ readonly #trackWork: (work: PromiseLike<unknown>) => void;
50
+ #probing?: Promise<boolean>;
46
51
 
47
- // Fibonacci backoff state — resets when probed IP changes
52
+ // Fibonacci backoff state — grows on each successful probe, resets only on a real reachability scare
48
53
  #lastProbeAt?: Timestamp;
49
- #lastProbedIp?: string;
50
54
  #fibPrev: Duration = 0;
51
55
  #fibCurr: Duration = 0;
52
56
 
@@ -55,7 +59,7 @@ export class PeerAddressMonitor {
55
59
  stabilizationDelay: Duration,
56
60
  probeCooldown: { minimum: Duration; maximum: Duration },
57
61
  abort: AbortSignal,
58
- trackWork: (work: PromiseLike<void>) => void,
62
+ trackWork: (work: PromiseLike<unknown>) => void,
59
63
  ) {
60
64
  this.#peer = peer;
61
65
  this.#abort = abort;
@@ -63,15 +67,7 @@ export class PeerAddressMonitor {
63
67
  this.#cooldownMax = probeCooldown.maximum;
64
68
  this.#trackWork = trackWork;
65
69
  this.#timer = Time.getTimer("address check stabilization", stabilizationDelay, () => {
66
- if (!this.#probing) {
67
- this.#probing = this.#check().finally(() => {
68
- this.#probing = undefined;
69
-
70
- // If address changes arrived during the probe, re-check
71
- this.schedule();
72
- });
73
- this.#trackWork(this.#probing);
74
- }
70
+ this.#trackWork(this.verifyReachability({ reason: "address-change", abort: this.#abort }));
75
71
  });
76
72
  }
77
73
 
@@ -94,110 +90,149 @@ export class PeerAddressMonitor {
94
90
  this.#timer.stop();
95
91
  }
96
92
 
97
- async #check() {
93
+ /**
94
+ * Tear down: stop the timer and await any in-flight run so teardown does not race a probe. The
95
+ * caller is expected to have aborted the peer first, so the in-flight run unwinds promptly.
96
+ */
97
+ async close() {
98
+ this.#timer.stop();
99
+ await this.#probing;
100
+ }
101
+
102
+ /**
103
+ * Verify the peer is reachable, driving recovery. Coalesces concurrent triggers into one run.
104
+ *
105
+ * An `address-change` run probes the current address then walks discovered alternates (migrating in
106
+ * place on success); a `session-suspect` run only probes the current address and never walks alternates.
107
+ * Returns `true` if the session is usable afterward (current address answered, or an alternate
108
+ * answered and the session migrated); `false` if no address answered and the session was closed.
109
+ */
110
+ async verifyReachability(options: { reason: ReachabilityReason; abort?: AbortSignal }): Promise<boolean> {
111
+ if (this.#probing === undefined) {
112
+ this.#probing = this.#run(options.reason).finally(() => {
113
+ this.#probing = undefined;
114
+ // Only an address-change run may have coalesced newer mDNS changes worth re-checking;
115
+ // a lone session-suspect run must not arm the debounce timer.
116
+ if (options.reason === "address-change") {
117
+ this.schedule();
118
+ }
119
+ });
120
+ }
121
+
122
+ const run = this.#probing;
123
+ // A joiner waits on the shared run but bails on its own abort without cancelling it for others.
124
+ return options.abort !== undefined && options.abort !== this.#abort
125
+ ? ((await Abort.race(options.abort, run)) ?? false)
126
+ : run;
127
+ }
128
+
129
+ async #run(reason: ReachabilityReason): Promise<boolean> {
130
+ const abort = this.#abort;
98
131
  const session = this.#peer.newestSession();
99
132
  const interaction = this.#peer.interaction;
100
133
  if (!session || !interaction) {
101
- return;
134
+ return false;
102
135
  }
103
136
 
104
137
  const channel = session.channel.transportChannel;
105
- if (!isIpNetworkChannel(channel)) {
106
- return;
107
- }
108
-
109
- // TCP has 1:1 session-connection binding plus OS keep-alive — connection drops evict
110
- // the session directly, so probing the address is unnecessary.
111
- if (channel.type === ChannelType.TCP) {
112
- return;
138
+ // TCP/non-IP sessions are evicted when their connection drops, so address probing is moot.
139
+ if (!isIpNetworkChannel(channel) || channel.type === ChannelType.TCP) {
140
+ return true;
113
141
  }
114
142
 
115
143
  const currentAddress = channel.networkAddress;
116
- const currentIp = currentAddress.ip;
117
144
  const discoveredAddresses = this.#peer.service.addresses;
118
145
 
119
- // If there are no discovered addresses at all, nothing to compare against
120
- if (!discoveredAddresses.size) {
121
- return;
122
- }
123
-
124
- // If the current address is still in the discovered set, all good
125
- if (discoveredAddresses.has(currentAddress)) {
126
- return;
127
- }
128
-
129
- const via = session.via;
130
-
131
- // Reset backoff if the IP we're being asked about differs from last time
132
- if (currentIp !== this.#lastProbedIp) {
146
+ if (reason === "session-suspect") {
147
+ // A subscription timeout is real evidence of trouble — drop accumulated trust so we probe now.
133
148
  this.#resetBackoff();
134
149
  }
135
150
 
136
- // Cooldown: use the more recent of last-probe and last device activity
137
- const lastKnownGood = Timestamp(Math.max(this.#lastProbeAt ?? 0, session.activeTimestamp));
138
- const cooldown = this.#currentCooldown;
139
- if (lastKnownGood > 0 && Timestamp.delta(lastKnownGood) < cooldown) {
140
- return;
151
+ if (reason === "address-change") {
152
+ // Only probe when the current address dropped out of the discovered set. Match by ip/port:
153
+ // discovered addresses are channel-agnostic, so `has()` (keyed by URL incl. protocol) would always miss.
154
+ const stillDiscovered = [...discoveredAddresses].some(addr => ServerAddress.isEqual(addr, currentAddress));
155
+ if (!discoveredAddresses.size || stillDiscovered) {
156
+ return true;
157
+ }
158
+ const lastKnownGood = Timestamp(Math.max(this.#lastProbeAt ?? 0, session.activeTimestamp));
159
+ if (lastKnownGood > 0 && Timestamp.delta(lastKnownGood) < this.#currentCooldown) {
160
+ return true;
161
+ }
162
+ logger.info(
163
+ session.via,
164
+ "Session address",
165
+ Diagnostic.strong(ServerAddress.urlFor(currentAddress)),
166
+ "no longer in mDNS results, probing",
167
+ );
168
+ this.#lastProbeAt = Time.nowMs;
141
169
  }
142
170
 
143
- logger.info(
144
- via,
145
- "Session address",
146
- Diagnostic.strong(ServerAddress.urlFor(currentAddress)),
147
- "no longer in mDNS results, probing",
148
- );
149
-
150
- this.#lastProbeAt = Time.nowMs;
151
- this.#lastProbedIp = currentIp;
152
-
153
- // Get probe network profile
154
171
  const network = this.#peer.network;
155
172
  const probeNetwork = network.probeAddress ?? network;
156
173
 
157
- // Probe the current address — suppress peer-loss so the session stays alive for follow-up probes
158
- if (await interaction.probe({ network: probeNetwork.id, abort: this.#abort, suppressPeerLoss: true })) {
159
- this.#advanceBackoff();
160
- return;
174
+ if (
175
+ await interaction.probe({
176
+ network: probeNetwork.id,
177
+ abort,
178
+ suppressPeerLoss: true,
179
+ maxRetransmissions: PROBE_MAX_RETRANSMISSIONS,
180
+ })
181
+ ) {
182
+ if (reason === "address-change") {
183
+ this.#advanceBackoff();
184
+ }
185
+ return true;
161
186
  }
162
187
 
163
- // Current address unreachable try discovered addresses on the still-alive session.
164
- // Probe path is UDP-only (TCP returned above); IpService stores transport-agnostic
165
- // ServerAddressIp, so tag each candidate as UDP for the override and channel update.
166
- for (const ipAddress of discoveredAddresses) {
167
- if (this.#abort.aborted) {
168
- return;
169
- }
170
- const address: ServerAddressUdp = { ...ipAddress, type: "udp" };
171
-
172
- if (
173
- await interaction.probe({
174
- network: probeNetwork.id,
175
- abort: this.#abort,
176
- addressOverride: address,
177
- suppressPeerLoss: true,
178
- })
179
- ) {
180
- logger.info(
181
- via,
182
- "Discovered address reachable, migrating session to",
183
- Diagnostic.strong(ServerAddress.urlFor(address)),
184
- );
185
- session.channel.networkAddress = address;
186
- interaction.subscriptions.closeForPeer(this.#peer.address);
187
- this.#resetBackoff();
188
- return;
188
+ // Only an mDNS address change walks alternates; a session-suspect probe stays on the current address.
189
+ if (reason === "address-change") {
190
+ for (const ipAddress of discoveredAddresses) {
191
+ if (abort.aborted) {
192
+ return false;
193
+ }
194
+ if (ServerAddress.isEqual(ipAddress, currentAddress)) {
195
+ continue;
196
+ }
197
+ const address: ServerAddressUdp = { ...ipAddress, type: "udp" };
198
+ if (
199
+ await interaction.probe({
200
+ network: probeNetwork.id,
201
+ abort,
202
+ addressOverride: address,
203
+ suppressPeerLoss: true,
204
+ maxRetransmissions: PROBE_MAX_RETRANSMISSIONS,
205
+ })
206
+ ) {
207
+ logger.info(
208
+ session.via,
209
+ "Discovered address reachable, migrating session to",
210
+ Diagnostic.strong(ServerAddress.urlFor(address)),
211
+ );
212
+ session.channel.networkAddress = address;
213
+ // Subscriptions still target the old address for device->client reports; close them so they
214
+ // re-establish and hand the device our new socket.
215
+ interaction.subscriptions.closeForPeer(this.#peer.address);
216
+ this.#resetBackoff();
217
+ return true;
218
+ }
189
219
  }
220
+ this.#resetBackoff();
190
221
  }
191
222
 
192
- this.#resetBackoff();
193
-
194
- if (this.#abort.aborted) {
195
- return;
223
+ if (abort.aborted) {
224
+ return false;
196
225
  }
197
226
 
198
- // No address works close the session so normal reconnection takes over
199
- logger.info(via, "All probes failed, closing session");
200
- await session.handlePeerLoss({ cause: new PeerUnresponsiveError() });
227
+ logger.info(session.via, "All probes failed, closing session");
228
+ try {
229
+ await session.handlePeerLoss({ cause: new PeerUnresponsiveError() });
230
+ } catch (error) {
231
+ // Best-effort recovery: a failure to close the unreachable session must not reject the run
232
+ // (which teardown awaits) — log and move on.
233
+ logger.warn(session.via, "Error closing unreachable session", error);
234
+ }
235
+ return false;
201
236
  }
202
237
 
203
238
  /** Current cooldown duration based on Fibonacci position. */
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { PeerAddress } from "#peer/PeerAddress.js";
8
- import { ExchangeProvider, NewExchangeOptions } from "#protocol/ExchangeProvider.js";
8
+ import { ExchangeProvider, NewExchangeOptions, type ReachabilityReason } from "#protocol/ExchangeProvider.js";
9
9
  import type { MessageExchange } from "#protocol/MessageExchange.js";
10
10
  import { MRP } from "#protocol/MRP.js";
11
11
  import { ChannelType, Duration, InternalError } from "@matter/general";
@@ -58,6 +58,10 @@ export class PeerExchangeProvider extends ExchangeProvider {
58
58
  });
59
59
  }
60
60
 
61
+ override verifyReachability(options: { reason: ReachabilityReason; abort?: AbortSignal }): Promise<boolean> {
62
+ return this.#peer.verifyReachability(options);
63
+ }
64
+
61
65
  override async initiateExchange(options?: NewExchangeOptions): Promise<MessageExchange> {
62
66
  const abort = options?.abort;
63
67
  const isGroup = PeerAddress.isGroup(this.#peer.address);
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
- import { Duration, merge as mergeObjects, Millis, Minutes, Seconds } from "@matter/general";
7
+ import { Duration, Hours, merge as mergeObjects, Millis, Minutes, Seconds } from "@matter/general";
8
8
 
9
9
  /**
10
10
  * Parameters that control network timing for Matter sessions controlled by matter.js.
@@ -105,11 +105,11 @@ export interface PeerTimingParameters {
105
105
  addressChangeStabilizationDelay: Duration;
106
106
 
107
107
  /**
108
- * Probe cooldown range for address-change probes on the same IP.
108
+ * Probe cooldown range for address-change probes.
109
109
  *
110
110
  * When mDNS keeps reporting the session IP as gone but probes succeed, the cooldown grows
111
111
  * using a Fibonacci-like sequence from {@link minimum} to {@link maximum}. The cooldown
112
- * resets when the probed IP changes or a probe fails.
112
+ * resets on a probe failure or a subscription-liveness scare, not on a mere address change.
113
113
  *
114
114
  * Probes are also suppressed while the session is actively receiving data — the cooldown
115
115
  * is measured from whichever is more recent: the last probe or the last received message.
@@ -183,8 +183,8 @@ export namespace PeerTimingParameters {
183
183
  },
184
184
  addressChangeStabilizationDelay: Seconds(10),
185
185
  addressChangeProbeCooldown: {
186
- minimum: Minutes(2),
187
- maximum: Minutes(60),
186
+ minimum: Minutes(10),
187
+ maximum: Hours(24),
188
188
  },
189
189
  };
190
190
  }
@@ -61,6 +61,9 @@ export interface NewExchangeOptions extends Omit<InteractionSettings, "transacti
61
61
  preferredTransport?: ChannelType;
62
62
  }
63
63
 
64
+ /** Why a reachability check was requested. Gates entry conditions, not the probe mechanics. */
65
+ export type ReachabilityReason = "address-change" | "session-suspect";
66
+
64
67
  /**
65
68
  * Interface for obtaining a message exchange with a specific peer.
66
69
  */
@@ -89,6 +92,15 @@ export abstract class ExchangeProvider {
89
92
  * The default implementation is a no-op (already connected).
90
93
  */
91
94
  async connect(_options?: NewExchangeOptions): Promise<void> {}
95
+
96
+ /**
97
+ * Verify the peer is reachable, driving recovery (alternate-address migration or session close).
98
+ *
99
+ * The default reports reachable; only peer-backed providers have a reachability authority and override this.
100
+ */
101
+ async verifyReachability(_options: { reason: ReachabilityReason; abort?: AbortSignal }): Promise<boolean> {
102
+ return true;
103
+ }
92
104
  }
93
105
 
94
106
  /**