Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ The main work (all changes without a GitHub username in brackets in the below li
## __WORK IN PROGRESS__
-->

## __WORK IN PROGRESS__

- @matter/protocol
- Fix: The peer's medium-specific MRP retransmission margin now applies to peer-initiated exchanges (e.g. subscription data reports) and to exchanges created without peer context
- Enhancement: Network profiles carry a separate `bdxAdditionalMrpDelay` for bulk transfer; thread applies no additional margin to BDX
- Fix: Grouped `PeerTimingParameters` (`kickRestartCooldown`, `addressChangeProbeCooldown`) merge field-wise, so overriding one member keeps its siblings

- @matter/node
- Enhancement: `network.profiles` accepts `bdxAdditionalMrpDelay`, and `network.timing` accepts the kick and address-change parameters

## 0.17.7 (2026-07-27)

- @matter/general
Expand Down
59 changes: 56 additions & 3 deletions packages/node/src/behavior/system/network/NetworkServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { ServerSubscriptionConfig } from "#node/server/ServerSubscription.js";
import { Duration, Logger, Minutes } from "@matter/general";
import { DeepPartial, Duration, Logger, Minutes } from "@matter/general";
import { duration, field, uint16 } from "@matter/model";
import { Ble, FabricManager, NetworkProfiles, PeerTimingParameters } from "@matter/protocol";
import { DiscoveryCapabilitiesBitmap, TypeFromPartialBitSchema } from "@matter/types";
Expand Down Expand Up @@ -78,7 +78,25 @@ export namespace NetworkServer {
declare runtime: ServerNetworkRuntime;
}

export class TimingConfig implements Partial<PeerTimingParameters> {
export class KickRestartCooldownConfig implements Partial<PeerTimingParameters["kickRestartCooldown"]> {
@field(duration)
addressChange?: Duration;

@field(duration)
connect?: Duration;
}

export class AddressChangeProbeCooldownConfig implements Partial<
PeerTimingParameters["addressChangeProbeCooldown"]
> {
@field(duration)
minimum?: Duration;

@field(duration)
maximum?: Duration;
}

export class TimingConfig implements DeepPartial<PeerTimingParameters> {
@field(duration)
defaultConnectionTimeout?: Duration;

Expand All @@ -98,7 +116,22 @@ export namespace NetworkServer {
delayAfterUnhandledError?: Duration;

@field(duration)
minimumTimeBetweenMrpKicks?: Duration;
kickThrottleInterval?: Duration;

@field(uint16)
kickMinRetransmissions?: number;

@field(duration)
kickMinRestartSaving?: Duration;

@field(KickRestartCooldownConfig)
kickRestartCooldown?: KickRestartCooldownConfig;

@field(duration)
addressChangeStabilizationDelay?: Duration;

@field(AddressChangeProbeCooldownConfig)
addressChangeProbeCooldown?: AddressChangeProbeCooldownConfig;
}

export class ConcreteLimitsConfig implements Partial<NetworkProfiles.ConcreteLimits> {
Expand All @@ -113,6 +146,9 @@ export namespace NetworkServer {

@field(duration)
additionalMrpDelay?: Duration;

@field(duration)
bdxAdditionalMrpDelay?: Duration;
}

export class LimitsConfig extends ConcreteLimitsConfig {
Expand Down Expand Up @@ -146,6 +182,23 @@ export namespace NetworkServer {
icdLit?: LimitsConfig;
}

type Mismatched<A, B> = Exclude<keyof A, keyof B> | Exclude<keyof B, keyof A>;
type SameFields<_Mismatched extends never> = unknown;

/**
* The config classes above restate their source's fields because {@link field} needs real property declarations,
* and `implements Partial<Source>` catches neither a field they fail to declare nor one the source has dropped.
* This does: either divergence breaks the build here, naming the offending field.
*/
export type ConfigMatchesItsSource = [
SameFields<Mismatched<PeerTimingParameters, TimingConfig>>,
SameFields<Mismatched<PeerTimingParameters["kickRestartCooldown"], KickRestartCooldownConfig>>,
SameFields<Mismatched<PeerTimingParameters["addressChangeProbeCooldown"], AddressChangeProbeCooldownConfig>>,
SameFields<Mismatched<NetworkProfiles.ConcreteLimits, ConcreteLimitsConfig>>,
SameFields<Mismatched<NetworkProfiles.Limits, LimitsConfig>>,
SameFields<Mismatched<NetworkProfiles.Templates, ProfilesConfig>>,
];

export class State extends NetworkBehavior.State {
listeningAddressIpv4?: string = undefined;
listeningAddressIpv6?: string = undefined;
Expand Down
67 changes: 66 additions & 1 deletion packages/node/test/node/ClientTuningTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import { Endpoint } from "#endpoint/Endpoint.js";
import { SecondaryNetworkInterfaceEndpoint } from "#endpoints/secondary-network-interface";
import { ServerNode } from "#index.js";
import { Bytes, causedBy, Crypto, Millis, MockCrypto, MockNetwork, Network, Seconds } from "@matter/general";
import { NetworkProfiles, PeerSet, PeerTimingParameters, PeerUnreachableError, SessionManager } from "@matter/protocol";
import {
MdnsService,
NetworkProfiles,
PeerSet,
PeerTimingParameters,
PeerUnreachableError,
SessionManager,
} from "@matter/protocol";
import { NetworkCommissioning } from "@matter/types/clusters/network-commissioning";
import { ThreadNetworkDiagnostics } from "@matter/types/clusters/thread-network-diagnostics";
import { MockServerNode } from "./mock-server-node.js";
Expand Down Expand Up @@ -73,6 +80,37 @@ describe("ClientTuningTest", () => {
expect(peer.network.additionalMrpDelay).equals(Seconds(1));
});

it("exposes the peer's MRP margin on the session so peer-initiated exchanges inherit it", async () => {
await using site = new MockSite();
const controller = await site.addController();
const device = await site.addNode(WifiRoot, { device: OnOffLightDevice });

await commission(controller, device);

const peer = [...controller.env.get(PeerSet)][0];
expect(peer.newestSession()?.peerMrpMargins?.messaging).equals(Seconds(1));
});

it("adopts sessions that predate its construction", async () => {
await using site = new MockSite();
const controller = await site.addController();
const device = await site.addNode(WifiRoot, { device: OnOffLightDevice });

await commission(controller, device);

const { env } = controller;
const late = new PeerSet({
lifetime: env,
sessions: env.get(SessionManager),
names: env.get(MdnsService).names,
networks: env.get(NetworkProfiles),
});

const peer = [...late][0];
expect(peer?.address).deep.equals([...env.get(PeerSet)][0].address);
expect(peer.sessions.size).equals(1);
});

it("uses default timing when not configured", async () => {
await using site = new MockSite();
const { controller } = await site.addCommissionedPair();
Expand Down Expand Up @@ -142,6 +180,33 @@ describe("ClientTuningTest", () => {
expect(conservative.additionalMrpDelay).equals(Seconds(1.5));
});

it("a configured bdxAdditionalMrpDelay propagates to NetworkProfiles", async () => {
await using site = new MockSite();

const controller = await site.addController({
network: {
profiles: {
thread: { bdxAdditionalMrpDelay: Seconds(8) },
wifi: { bdxAdditionalMrpDelay: Seconds(2) },
},
},
});
const device = await site.addDevice();
await commission(controller, device);

const profiles = controller.env.get(NetworkProfiles);

const thread = profiles.get("thread");
expect(thread.bdxAdditionalMrpDelay).equals(Seconds(8));
expect(thread.additionalMrpDelay).equals(Seconds(1.5));

const wifi = profiles.get("wifi");
expect(wifi.bdxAdditionalMrpDelay).equals(Seconds(2));
expect(wifi.additionalMrpDelay).equals(Seconds(1));

expect(profiles.get("conservative").bdxAdditionalMrpDelay).equals(Seconds(1.5));
});

it("ownNetworkProfileId sets the sender-side MRP margin", async () => {
await using site = new MockSite();

Expand Down
27 changes: 22 additions & 5 deletions packages/protocol/src/peer/NetworkProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { PeerAddress } from "#peer/PeerAddress.js";
import { MRP } from "#protocol/MRP.js";
import {
Diagnostic,
Duration,
Expand Down Expand Up @@ -44,6 +45,13 @@ export interface ConcreteNetworkProfile {
* with the local "own" profile margin via max at send time.
*/
additionalMrpDelay: Duration;

/**
* {@link additionalMrpDelay} for bulk transfer (BDX) exchanges, which sustain many round trips over one path.
*
* Defaults to {@link additionalMrpDelay} so a profile that does not distinguish traffic classes behaves as before.
*/
bdxAdditionalMrpDelay: Duration;
}

/**
Expand Down Expand Up @@ -138,25 +146,28 @@ export class NetworkProfiles {
return this.configure(id, this.#defaults[id as keyof NetworkProfiles.Templates]);
}

configure(id: string, limits: NetworkProfiles.Limits, parentDelay?: Duration) {
const additionalMrpDelay = limits.additionalMrpDelay ?? parentDelay ?? Millis(0);
configure(id: string, limits: NetworkProfiles.Limits, parent?: MRP.Margins) {
const additionalMrpDelay = limits.additionalMrpDelay ?? parent?.messaging ?? Millis(0);
const bdxAdditionalMrpDelay = limits.bdxAdditionalMrpDelay ?? parent?.bdx ?? additionalMrpDelay;
const network: NetworkProfile = {
id,
semaphore: new Semaphore(`network semaphore ${id}`, limits.exchanges, limits.delay, limits.timeout),
additionalMrpDelay,
bdxAdditionalMrpDelay,
};
const inherited = { messaging: additionalMrpDelay, bdx: bdxAdditionalMrpDelay };
if (limits.connect) {
network.connect = this.configure(
`${id}:connect`,
{ ...limits.connect, connect: undefined, probeAddress: undefined },
additionalMrpDelay,
inherited,
);
}
if (limits.probeAddress) {
network.probeAddress = this.configure(
`${id}:probe`,
{ ...limits.probeAddress, connect: undefined, probeAddress: undefined },
additionalMrpDelay,
inherited,
);
}
logger.info(
Expand Down Expand Up @@ -241,6 +252,11 @@ export namespace NetworkProfiles {
* Additive MRP retransmission margin for this medium. Defaults to 0 unless the template sets one.
*/
additionalMrpDelay?: Duration;

/**
* {@link additionalMrpDelay} for bulk transfer (BDX) exchanges. Defaults to {@link additionalMrpDelay}.
*/
bdxAdditionalMrpDelay?: Duration;
}

/**
Expand Down Expand Up @@ -344,7 +360,8 @@ export namespace NetworkProfiles {
icdLit: { exchanges: Infinity, additionalMrpDelay: Millis(0) },
fast: { exchanges: 200, additionalMrpDelay: Millis(0) },
wifi: { exchanges: 200, additionalMrpDelay: Seconds(1) },
thread: conservative,
// The explicit zero matters: omitting it would inherit conservative's messaging margin for BDX too
thread: { ...conservative, bdxAdditionalMrpDelay: Seconds(0) },
conservative,
unknown: conservative,
};
Expand Down
10 changes: 10 additions & 0 deletions packages/protocol/src/peer/Peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ export class Peer {

// Ensure session parameters reflect those most recently reported by peer
this.#descriptor.sessionParameters = session.parameters;

// Only pad when we actually know the peer's medium; the "unknown" fallback is deliberately not applied
// here because it would inflate responder timing for every peer of a node that never characterizes them.
session.peerMrpMarginsResolver = () => {
if (this.#physicalProperties === undefined) {
return undefined;
}
const { additionalMrpDelay, bdxAdditionalMrpDelay } = this.network;
return { messaging: additionalMrpDelay, bdx: bdxAdditionalMrpDelay };
};
});
}

Expand Down
41 changes: 23 additions & 18 deletions packages/protocol/src/peer/PeerSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
import { MdnsService } from "#mdns/MdnsService.js";
import { PeerAddress } from "#peer/PeerAddress.js";
import { ExchangeManager } from "#protocol/ExchangeManager.js";
import type { NodeSession } from "#session/NodeSession.js";
import { Session } from "#session/Session.js";
import { SessionManager } from "#session/SessionManager.js";
import {
Abort,
AsyncObservable,
BasicSet,
ChannelType,
DeepPartial,
DnssdNames,
Environment,
Environmental,
Expand All @@ -29,7 +31,6 @@ import {
RetrySchedule,
ServerAddressIp,
} from "@matter/general";
import { FabricIndex } from "@matter/types";
import { NetworkProfiles } from "./NetworkProfile.js";
import { Peer } from "./Peer.js";
import { PeerConnection } from "./PeerConnection.js";
Expand Down Expand Up @@ -91,32 +92,36 @@ export class PeerSet implements ImmutableSet<Peer>, ObservableSet<Peer> {
join: name => this.#lifetime.join(name),
};

this.#peers.added.on(peer => {
this.#observers.on(this.#peers.added, peer => {
// Scoped to the peer's own observable, so it needs no cleanup of ours
peer.sessions.deleted.on(() => {
if (!peer.sessions.size) {
this.#disconnected.emit(peer);
}
});
});

this.#sessions.sessions.added.on(session => {
if (session.peerAddress.fabricIndex === FabricIndex.NO_FABRIC || session.isClosed) {
return;
}
this.#observers.on(this.#sessions.sessions.added, session => this.#adoptSession(session));

this.addKnownPeer({
address: session.peerAddress,
operationalAddress: operationalAddressOf(session),
});
});
// Sessions may predate us: nothing guarantees we are instantiated before the node establishes its first
// operational session
for (const session of this.#sessions.sessions) {
this.#adoptSession(session);
}
}

this.#observers.on(this.#sessions.sessions.added, session => {
if (session.fabric === undefined) {
return;
}
/**
* Associate an operational session with its peer, creating the peer if we do not know it yet.
*/
#adoptSession(session: NodeSession) {
if (session.fabric === undefined || session.isClosed) {
return;
}

this.for(session.peerAddress).sessions.add(session);
});
this.addKnownPeer({
address: session.peerAddress,
operationalAddress: operationalAddressOf(session),
}).sessions.add(session);
}

set exchanges(exchanges: ExchangeManager | undefined) {
Expand Down Expand Up @@ -222,7 +227,7 @@ export class PeerSet implements ImmutableSet<Peer>, ObservableSet<Peer> {
return this.#peerContext.timing;
}

set timing(timing: Partial<PeerTimingParameters>) {
set timing(timing: DeepPartial<PeerTimingParameters>) {
this.#peerContext.timing = PeerTimingParameters(timing);
}

Expand Down
Loading
Loading