@blockcast/mmt-container 2.0.0 → 2.0.1-main.0f2de6af487b

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.0.0",
3
+ "version": "2.0.1-main.0f2de6af487b",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "^2.0.0",
173
- "@blockcast/mmt-base": "^2.0.0",
174
- "@blockcast/mmt-fec": "^2.0.0",
175
- "@blockcast/mmt-manifest-verify": "^2.0.0",
172
+ "@blockcast/fec-worker": "2.0.1-main.0f2de6af487b",
173
+ "@blockcast/mmt-base": "2.0.1-main.0f2de6af487b",
174
+ "@blockcast/mmt-fec": "2.0.1-main.0f2de6af487b",
175
+ "@blockcast/mmt-manifest-verify": "2.0.1-main.0f2de6af487b",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
package/src/index.ts CHANGED
@@ -274,6 +274,7 @@ export {
274
274
  type FrameCallback,
275
275
  type FECManagerLike,
276
276
  type MmtpIngressContext,
277
+ type KeyframeOverlayLeg,
277
278
  type MmtpStreamIdentity,
278
279
  type RoutedPacketMeta,
279
280
  type StagedStreamSwitch,
@@ -175,6 +175,13 @@ export type RoutedFrameMeta = {
175
175
  gapBefore?: FrameGapDecision;
176
176
  };
177
177
 
178
+ /** A keyframe-overlay repair leg: the source its FEC instance protects. */
179
+ export interface KeyframeOverlayLeg {
180
+ sourcePacketId: number;
181
+ sourceCatalogTrackName?: string;
182
+ repairTrackName?: string;
183
+ }
184
+
178
185
  export interface MmtpIngressContext {
179
186
  tracks: readonly Readonly<{ name: string; packetId: number }>[];
180
187
  /**
@@ -1031,6 +1038,14 @@ export class MmtpRouter {
1031
1038
  #packetIdRoles: Map<number, TrackRole> = new Map();
1032
1039
  /** Explicit catalog ownership for dedicated repair-track packet IDs. */
1033
1040
  #repairOwnersByPacketId = new Map<number, Set<number>>();
1041
+ /** Keyframe-overlay repair PID → the source its FEC instance protects
1042
+ * (draft-ramadan-mmt-fec-02 7.4.1). Never an owner in
1043
+ * #repairOwnersByPacketId: the overlay is decoded as its own instance. */
1044
+ #overlayLegs = new Map<number, KeyframeOverlayLeg>();
1045
+ /** Keyframe-overlay repair track names, which name a leg too: a catalog
1046
+ * may state no PID for it (learned at subscribe time). */
1047
+ #overlayRepairNames = new Map<string, KeyframeOverlayLeg>();
1048
+ #overlayStats = { repairDropped: 0, ownerRefused: 0 };
1034
1049
  /** Callback fired when a delta-track FEC block completes or fails. */
1035
1050
  #onDeltaBlockCompleteCb:
1036
1051
  | ((packetId: number, failed: boolean) => void)
@@ -1775,15 +1790,61 @@ export class MmtpRouter {
1775
1790
  /** Replace catalog-derived repair ownership before a catalog reconfiguration. */
1776
1791
  clearRepairPacketIdOwners(): void {
1777
1792
  this.#repairOwnersByPacketId.clear();
1793
+ this.#overlayLegs.clear();
1794
+ this.#overlayRepairNames.clear();
1778
1795
  }
1779
1796
 
1780
- /** Bind one dedicated repair-track PID to its authoritative media PID. */
1797
+ /** Bind one dedicated repair-track PID to its authoritative media PID. A
1798
+ * known keyframe-overlay PID is refused, and counted: its symbols belong to
1799
+ * the overlay instance, never to the source's base decoder (mmt-02 7.4.4). */
1781
1800
  setRepairPacketIdOwner(repairPacketId: number, ownerPacketId: number): void {
1801
+ if (this.#overlayLegs.has(repairPacketId)) {
1802
+ this.#overlayStats.ownerRefused++;
1803
+ return;
1804
+ }
1782
1805
  const owners = this.#repairOwnersByPacketId.get(repairPacketId) ?? new Set<number>();
1783
1806
  owners.add(ownerPacketId);
1784
1807
  this.#repairOwnersByPacketId.set(repairPacketId, owners);
1785
1808
  }
1786
1809
 
1810
+ /**
1811
+ * Record a keyframe-overlay repair leg (draft-ramadan-mmt-fec-02 7.4): its
1812
+ * PID, the source it protects, and its repair track name. Either one names
1813
+ * the leg, so a catalog that states no PID (learned at subscribe time) is
1814
+ * covered by the name. Overlay repair is then never resolved to a base
1815
+ * owner, on multicast or MoQ. Cleared with the catalog owners
1816
+ * (`clearRepairPacketIdOwners`).
1817
+ */
1818
+ setKeyframeOverlayLeg(repairPacketId: number | undefined, leg: KeyframeOverlayLeg): void {
1819
+ if (leg.repairTrackName !== undefined) this.#overlayRepairNames.set(leg.repairTrackName, { ...leg });
1820
+ if (repairPacketId === undefined) return;
1821
+ this.#repairOwnersByPacketId.delete(repairPacketId);
1822
+ this.#overlayLegs.set(repairPacketId, { ...leg });
1823
+ }
1824
+
1825
+ /** The keyframe-overlay leg registered on `repairPacketId`, if any. */
1826
+ keyframeOverlayLeg(repairPacketId: number): Readonly<KeyframeOverlayLeg> | undefined {
1827
+ return this.#overlayLegs.get(repairPacketId);
1828
+ }
1829
+
1830
+ /** Overlay repair dropped for want of an overlay instance, and base-owner
1831
+ * registrations refused on an overlay PID. */
1832
+ get keyframeOverlayStats(): Readonly<{ repairDropped: number; ownerRefused: number }> {
1833
+ return { ...this.#overlayStats };
1834
+ }
1835
+
1836
+ #isOverlayRepair(repairPacketId: number, repairTrackName?: string): boolean {
1837
+ return this.#overlayLegs.has(repairPacketId) ||
1838
+ (repairTrackName !== undefined && this.#overlayRepairNames.has(repairTrackName));
1839
+ }
1840
+
1841
+ /** Overlay repair has no decoder until the overlay instance lands
1842
+ * (BLO-52077 PR 2): drop it before any base owner, locator or decoder. */
1843
+ #dropOverlayRepair(repairPacketId: number): void {
1844
+ this.#overlayStats.repairDropped++;
1845
+ debugRouter(`[MmtpRouter] Dropping keyframe-overlay repair pid=${repairPacketId}: no overlay instance`);
1846
+ }
1847
+
1787
1848
  /**
1788
1849
  * Register callback fired when a delta-track FEC block completes or fails.
1789
1850
  *
@@ -2316,6 +2377,10 @@ export class MmtpRouter {
2316
2377
  // A dedicated repair track can carry a packet ID that differs from the
2317
2378
  // media packet ID. The subscribing track supplies the authoritative owner.
2318
2379
  const repairPacketId = header.packetId;
2380
+ if (this.#isOverlayRepair(repairPacketId, repairTrackName)) {
2381
+ this.#dropOverlayRepair(repairPacketId);
2382
+ return;
2383
+ }
2319
2384
 
2320
2385
  const hintedOwner =
2321
2386
  ownerPacketIdHint != null && this.#classifyPacketId(ownerPacketIdHint) !== null
@@ -2803,6 +2868,13 @@ export class MmtpRouter {
2803
2868
  this.#packetIdRoles.delete(packetId);
2804
2869
  this.#catalogTrackNamesByPacketId.delete(packetId);
2805
2870
  this.#repairOwnersByPacketId.delete(packetId);
2871
+ this.#overlayLegs.delete(packetId);
2872
+ for (const [repairPacketId, leg] of [...this.#overlayLegs]) {
2873
+ if (leg.sourcePacketId === packetId) this.#overlayLegs.delete(repairPacketId);
2874
+ }
2875
+ for (const [name, leg] of [...this.#overlayRepairNames]) {
2876
+ if (leg.sourcePacketId === packetId) this.#overlayRepairNames.delete(name);
2877
+ }
2806
2878
  delete this.#pidCounts[packetId];
2807
2879
  delete this.#repairPidCounts[packetId];
2808
2880
  delete this.#moqRepairPidCounts[packetId];
@@ -3075,6 +3147,8 @@ export class MmtpRouter {
3075
3147
  this.#parserDiagnostics.clear();
3076
3148
  this.#catalogTrackNamesByPacketId.clear();
3077
3149
  this.#repairOwnersByPacketId.clear();
3150
+ this.#overlayLegs.clear();
3151
+ this.#overlayRepairNames.clear();
3078
3152
  for (const { receiver } of this.#provenance.values()) receiver.release();
3079
3153
  this.#provenance.clear();
3080
3154
  this.#provenanceStreamFlow.clear();
@@ -3550,6 +3624,11 @@ export class MmtpRouter {
3550
3624
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3551
3625
  // Read from the header parsed above rather than parsing it again
3552
3626
  // (BLO-36599).
3627
+ // The track a repair packet arrives on names its instance (7.4.2).
3628
+ if (this.#isOverlayRepair(info.packetId, catalogTrackNameForPacket(ingress, info.packetId))) {
3629
+ this.#dropOverlayRepair(info.packetId);
3630
+ return;
3631
+ }
3553
3632
  const ownerPacketId = this.#resolveRepairOwnerPacketId(info.packetId, ingress);
3554
3633
  // An ambiguous name attributes the packet to no instance, so it is
3555
3634
  // dropped before admission can count or fail closed against one.
@@ -97,7 +97,7 @@ export interface MulticastRepairEndpoint {
97
97
  /** The repair packet IDs this group carries. */
98
98
  packetIds: number[];
99
99
  /** Each repair packet ID bound to the media packet ID it repairs. */
100
- repairOwners: ReadonlyArray<{ packetId: number; ownerPacketId: number }>;
100
+ repairOwners: ReadonlyArray<{ packetId: number; ownerPacketId: number; instance?: "keyframe" }>;
101
101
  /** Ingress tracks: the owners' source tracks and the repair tracks. */
102
102
  tracks: MmtpIngressContext["tracks"];
103
103
  /** The protected flow (`MmtpRouter.registerProvenanceFlow`) this group's datagrams belong to. */
@@ -1257,14 +1257,18 @@ function repairTrackNamesBySource(
1257
1257
  return bySource;
1258
1258
  }
1259
1259
 
1260
- /** The repair tracks an `ok` binding joins: base layers, then the overlay. */
1260
+ /** The base repair layers an `ok` binding joins. The keyframe overlay is
1261
+ * not among them: it is its own FEC instance (draft-ramadan-mmt-fec-02 7.4). */
1261
1262
  function boundRepairNames(binding: FecSourceBinding | undefined): string[] {
1262
1263
  if (binding?.status !== "ok") return [];
1263
- const names = binding.layers.map((layer) => layer.repairTrackName);
1264
- if (binding.overlay) names.push(binding.overlay.repairTrackName);
1265
- return names;
1264
+ return binding.layers.map((layer) => layer.repairTrackName);
1266
1265
  }
1267
1266
 
1267
+ type RepairOwnerEntry = MulticastRepairEndpoint["repairOwners"][number] & {
1268
+ ownerTrackName?: string;
1269
+ repairTrackName?: string;
1270
+ };
1271
+
1268
1272
  export class MulticastManager {
1269
1273
  readonly #fec: FECManager;
1270
1274
  readonly #router: MmtpRouter;
@@ -1276,7 +1280,7 @@ export class MulticastManager {
1276
1280
  #failbackHooked = false;
1277
1281
  /** Live joinRepairEndpoints() releases → the repair owners they registered,
1278
1282
  * re-applied after configureMultiTrack() clears catalog-derived owners. */
1279
- readonly #repairReleases = new Map<() => void, MulticastRepairEndpoint["repairOwners"]>();
1283
+ readonly #repairReleases = new Map<() => void, RepairOwnerEntry[]>();
1280
1284
  #reportedFecDiagnosticsFailure = false;
1281
1285
 
1282
1286
  // ── Stream-global FEC timing reduction ──
@@ -2291,7 +2295,14 @@ export class MulticastManager {
2291
2295
  console.warn("[MulticastManager] repair group release failed", error);
2292
2296
  }
2293
2297
  };
2294
- const repairOwners = endpoints.flatMap((endpoint) => endpoint.repairOwners);
2298
+ // An overlay leg's source and repair track names come from the
2299
+ // endpoint's ingress tracks.
2300
+ const repairOwners: RepairOwnerEntry[] = endpoints.flatMap((endpoint) =>
2301
+ endpoint.repairOwners.map((owner) => owner.instance !== "keyframe" ? owner : {
2302
+ ...owner,
2303
+ ownerTrackName: endpoint.tracks.find((t) => t.packetId === owner.ownerPacketId)?.name,
2304
+ repairTrackName: endpoint.tracks.find((t) => t.packetId === owner.packetId)?.name,
2305
+ }));
2295
2306
  this.#applyRepairOwners(repairOwners);
2296
2307
  for (const endpoint of endpoints) {
2297
2308
  const ingress: MmtpIngressContext = {
@@ -2344,10 +2355,18 @@ export class MulticastManager {
2344
2355
  return release;
2345
2356
  }
2346
2357
 
2347
- #applyRepairOwners(owners: MulticastRepairEndpoint["repairOwners"]): void {
2348
- for (const { packetId, ownerPacketId } of owners) {
2358
+ #applyRepairOwners(owners: RepairOwnerEntry[]): void {
2359
+ for (const { packetId, ownerPacketId, instance, ownerTrackName, repairTrackName } of owners) {
2349
2360
  this.#router.setPacketIdRole(packetId, "repair");
2350
- this.#router.setRepairPacketIdOwner(packetId, ownerPacketId);
2361
+ if (instance === "keyframe") {
2362
+ this.#router.setKeyframeOverlayLeg(packetId, {
2363
+ sourcePacketId: ownerPacketId,
2364
+ ...(ownerTrackName === undefined ? {} : { sourceCatalogTrackName: ownerTrackName }),
2365
+ ...(repairTrackName === undefined ? {} : { repairTrackName }),
2366
+ });
2367
+ } else {
2368
+ this.#router.setRepairPacketIdOwner(packetId, ownerPacketId);
2369
+ }
2351
2370
  }
2352
2371
  }
2353
2372
 
@@ -2668,8 +2687,10 @@ export class MulticastManager {
2668
2687
  packet: Uint8Array,
2669
2688
  ownerPacketId?: number,
2670
2689
  catalogTrackName?: string,
2690
+ /** The repair track it arrived on: names its FEC instance (mmt-02 7.4.2). */
2691
+ repairTrackName?: string,
2671
2692
  ) {
2672
- this.#router.feedRepairPacket(packet, ownerPacketId, catalogTrackName);
2693
+ this.#router.feedRepairPacket(packet, ownerPacketId, catalogTrackName, repairTrackName);
2673
2694
  }
2674
2695
 
2675
2696
  /** Route a Worker-recovered FEC block through the MMTP parser + dedup pipeline. */
@@ -2759,7 +2780,10 @@ export class MulticastManager {
2759
2780
  const ownedRepairNames = new Set<string>();
2760
2781
  const claimed = new Map<string, number>();
2761
2782
  for (const binding of bindings.values()) {
2762
- for (const name of boundRepairNames(binding)) claimed.set(name, (claimed.get(name) ?? 0) + 1);
2783
+ const overlay = binding.status === "ok" ? binding.overlay?.repairTrackName : undefined;
2784
+ for (const name of [...boundRepairNames(binding), ...(overlay === undefined ? [] : [overlay])]) {
2785
+ claimed.set(name, (claimed.get(name) ?? 0) + 1);
2786
+ }
2763
2787
  }
2764
2788
  for (const [name, count] of claimed) if (count === 1) ownedRepairNames.add(name);
2765
2789
  const repairOwnerGroup = new Map<string, string>();
@@ -2928,10 +2952,12 @@ export class MulticastManager {
2928
2952
  const binding = bindings.get(track.name);
2929
2953
  if (binding?.status === "ok") {
2930
2954
  const fecCfg = binding.base;
2931
- // Every repair layer and the keyframe overlay route to this source's
2932
- // decoder, not only layer 0. A layer whose packetId had no registered
2933
- // owner would have its repair symbols dropped as an unknown owner, and
2934
- // two sources whose RS_ID ranges overlap would be indistinguishable.
2955
+ // Every base repair layer routes to this source's decoder, not only
2956
+ // layer 0. A layer whose packetId had no registered owner would have
2957
+ // its repair symbols dropped as an unknown owner, and two sources whose
2958
+ // RS_ID ranges overlap would be indistinguishable. The keyframe overlay
2959
+ // is another FEC instance: it is recorded as an overlay leg, never
2960
+ // owned by this source's base decoder (draft-ramadan-mmt-fec-02 7.4.4).
2935
2961
  if (track.packetId !== undefined) {
2936
2962
  for (const repairName of boundRepairNames(binding)) {
2937
2963
  if (!ownedRepairNames.has(repairName)) continue;
@@ -2940,6 +2966,16 @@ export class MulticastManager {
2940
2966
  this.#router.setRepairPacketIdOwner(repairTrack.packetId, track.packetId);
2941
2967
  }
2942
2968
  }
2969
+ // By its name too: a catalog that states no PID for it (learned at
2970
+ // subscribe time) must still keep its MoQ repair from the base.
2971
+ const overlayName = binding.overlay?.repairTrackName;
2972
+ if (overlayName !== undefined && ownedRepairNames.has(overlayName)) {
2973
+ this.#router.setKeyframeOverlayLeg(catalogTracksByName.get(overlayName)?.packetId, {
2974
+ sourcePacketId: track.packetId,
2975
+ sourceCatalogTrackName: track.name,
2976
+ repairTrackName: overlayName,
2977
+ });
2978
+ }
2943
2979
  }
2944
2980
  const wasmDecoder = fecDecoders.get(i);
2945
2981
  if (track.packetId !== undefined && wasmDecoder !== undefined) {
@@ -16,6 +16,7 @@ import {
16
16
  MMTP_PACKET_ID_MAX,
17
17
  MMTP_SIGNALING_PACKET_ID,
18
18
  readMMTPPacketId,
19
+ sameIpAddress,
19
20
  } from '@blockcast/mmt-base'
20
21
  import { isBcpV2RecordCandidate } from '@blockcast/mmt-manifest-verify'
21
22
 
@@ -41,6 +42,12 @@ export interface MulticastTransportConfig {
41
42
  * are "MHM2" passes ahead of the packet_id read and the allow-list, and is
42
43
  * never counted toward a packet_id. On an unprotected join it is an
43
44
  * unparseable MMTP packet, as before.
45
+ *
46
+ * A protected join admits only datagrams whose reported sender is the
47
+ * flow's S (`sourceAddress`, or `senderAddress` on ASM), compared as IP
48
+ * addresses, before anything hashes them (multicast-02 7.2.7). A datagram
49
+ * with no reported sender is dropped, and a join that cannot report one
50
+ * (no S, or an ATSC 3.0 tuner) is declined `invalid-request`.
44
51
  */
45
52
  protected?: boolean
46
53
  }
@@ -148,7 +155,8 @@ export interface MulticastTransportStats {
148
155
  * one (G, port, packet_id); the first sender whose packet is accepted owns
149
156
  * the sub-flow until the next join, and every other sender is dropped here,
150
157
  * before any gap, duplicate or FEC ledger sees it. A join or a silence
151
- * failback releases the lock.
158
+ * failback releases the lock. On a protected join it also counts every
159
+ * datagram whose sender is not the flow's S, or is not reported.
152
160
  */
153
161
  packetsDroppedForeignSender: number
154
162
  }
@@ -215,7 +223,14 @@ function hasMulticastGateway(): boolean {
215
223
  return typeof getMulticastGlobals().multicast?.subscribe === 'function'
216
224
  }
217
225
 
218
- /** The `bytes` event's sender (multicast-contract 1.3.0); undefined when unknown. */
226
+ /**
227
+ * The `bytes` event's sender (multicast-contract 1.3.0); undefined when unknown.
228
+ *
229
+ * The provider filters (S, G, port) and owns the multicast-02 7.2.7
230
+ * destination MUST: the event carries no destination address, so libmmt
231
+ * cannot discard a datagram whose IP destination is not G (pim BLO-51627).
232
+ * On a protected flow libmmt checks the sender itself, against S.
233
+ */
219
234
  function senderOf(event: CustomEvent<unknown>): string | undefined {
220
235
  const sourceAddress = (event as { sourceAddress?: unknown }).sourceAddress
221
236
  return typeof sourceAddress === 'string' ? sourceAddress : undefined
@@ -343,6 +358,10 @@ export class MulticastTransport<TPacketContext = never> {
343
358
  readonly #acceptedBytesByPacketId = new Map<number, { bytes: number }>()
344
359
  #packetIds: ReadonlySet<number> | null = null
345
360
  #protected = false
361
+ // A protected join's S, and the last sender spelling that matched it, so a
362
+ // matching sender parses once per spelling rather than per datagram.
363
+ #protectedSource: string | undefined = undefined
364
+ #matchedSource: string | undefined = undefined
346
365
  // The sender S this join's accepted packets came from, once known; see
347
366
  // packetsDroppedForeignSender. Cleared by every join and by a silence
348
367
  // failback, so a replacement publisher is not locked out.
@@ -406,6 +425,11 @@ export class MulticastTransport<TPacketContext = never> {
406
425
  // failure before it runs, so the catch classifies a decline by where the
407
426
  // join stopped, never by what the error says.
408
427
  let failure: MulticastJoinDeclineReason = 'invalid-request'
428
+ const protectedSource = config.protected === true ? config.sourceAddress ?? config.senderAddress : undefined
429
+ if (config.protected === true && (protectedSource === undefined || config.atsc3)) {
430
+ console.warn('[MulticastTransport] protected join refused: the path reports no per-datagram sender to check against S')
431
+ return this.#declined(generation, 'invalid-request')
432
+ }
409
433
  try {
410
434
  const packetIds = config.packetIds?.length ? new Set(config.packetIds) : null
411
435
  console.log(
@@ -454,8 +478,9 @@ export class MulticastTransport<TPacketContext = never> {
454
478
  // costs batching on every any-source join, including one on a pre-1.3.0
455
479
  // provider that sends no sender and so gets no lock for it; keep it
456
480
  // unless the provider can say it carries the sender.
481
+ // A protected join checks every datagram's sender, so it listens to `bytes` too.
457
482
  const anySource = !config.sourceAddress && !config.senderAddress && !config.atsc3
458
- const packetListener: PacketListener = !anySource && subscriptionDeliversPacketBatches(subscription)
483
+ const packetListener: PacketListener = !anySource && protectedSource === undefined && subscriptionDeliversPacketBatches(subscription)
459
484
  ? {
460
485
  type: 'packets',
461
486
  listener: (event) => {
@@ -473,6 +498,8 @@ export class MulticastTransport<TPacketContext = never> {
473
498
  this.#packetListener = packetListener
474
499
  this.#packetIds = packetIds
475
500
  this.#protected = config.protected === true
501
+ this.#protectedSource = protectedSource
502
+ this.#matchedSource = undefined
476
503
  this.#providerFilter = {
477
504
  acknowledged: packetIds,
478
505
  sent: 0,
@@ -1009,6 +1036,8 @@ export class MulticastTransport<TPacketContext = never> {
1009
1036
  this.#delivery = undefined
1010
1037
  this.#packetIds = null
1011
1038
  this.#protected = false
1039
+ this.#protectedSource = undefined
1040
+ this.#matchedSource = undefined
1012
1041
  this.#providerFilter = null
1013
1042
  this.#context = undefined
1014
1043
  this.#withdrawnPacketIds.clear()
@@ -1016,8 +1045,18 @@ export class MulticastTransport<TPacketContext = never> {
1016
1045
 
1017
1046
  /** The ingress filter; an accepted packet is counted here, once. */
1018
1047
  #acceptPacket(packet: Uint8Array, sourceAddress?: string): boolean {
1048
+ const source = this.#protectedSource
1049
+ if (source !== undefined && (sourceAddress === undefined || sourceAddress !== this.#matchedSource)) {
1050
+ if (sourceAddress === undefined || !sameIpAddress(sourceAddress, source)) {
1051
+ this.#stats.packetsDroppedForeignSender++
1052
+ return false
1053
+ }
1054
+ this.#matchedSource = sourceAddress
1055
+ }
1019
1056
  // Absent means unknown (a pre-1.3.0 provider, a tuner): never a new sender.
1020
- const owner = this.#lockedTo?.() ?? this.#sender
1057
+ // A protected flow's S check above replaces the first-seen lock, which
1058
+ // compares spellings and would refuse S written another way.
1059
+ const owner = source === undefined ? this.#lockedTo?.() ?? this.#sender : undefined
1021
1060
  if (sourceAddress !== undefined && owner !== undefined && sourceAddress !== owner) {
1022
1061
  this.#stats.packetsDroppedForeignSender++
1023
1062
  return false