@zlink-systems/framework 0.17.0 → 0.18.1

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.
@@ -65,5 +65,5 @@ export declare class ZLinkUserSpotCreationCoordinator {
65
65
  private awaitReady;
66
66
  private remoteCreate;
67
67
  }
68
- export declare function encodeLocationCreationContent(payload: Uint8Array, checksum?: number): string;
69
- export declare function decodeLocationCreationContent(reference: string, expectedSha256?: Uint8Array, expectedEncodedSize?: bigint): Buffer;
68
+ export declare function encodeLocationCreationContent(payload: Uint8Array): string;
69
+ export declare function decodeLocationCreationContent(reference: string, expectedSha256: Uint8Array, expectedEncodedSize: bigint): Buffer;
@@ -25,9 +25,8 @@ class ZLinkUserSpotCreationCoordinator {
25
25
  const deadlineUnixMs = Date.now() + request.timeoutMs;
26
26
  const signal = deadline.signal;
27
27
  signal.throwIfAborted();
28
- const checksum = crc32c(request.requestPayload);
29
28
  const sha256 = (0, node_crypto_1.createHash)('sha256').update(request.requestPayload).digest();
30
- const contentReference = encodeLocationCreationContent(request.requestPayload, checksum);
29
+ const contentReference = encodeLocationCreationContent(request.requestPayload);
31
30
  const excludedNodeRids = new Set();
32
31
  let target;
33
32
  let authorityTarget;
@@ -334,12 +333,7 @@ class ZLinkUserSpotCreationCoordinator {
334
333
  || pending.reservationId !== record.reservation.reservationId) {
335
334
  throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'Remote User Spot Pending creation projection is missing.');
336
335
  }
337
- const requestPayload = decodeLocationCreationContent(pending.requestContentReference);
338
- if (BigInt(requestPayload.byteLength) !== pending.requestEncodedSize
339
- || !(0, node_crypto_1.createHash)('sha256').update(requestPayload).digest()
340
- .equals(Buffer.from(pending.requestSha256))) {
341
- throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'Remote User Spot Pending creation content failed integrity validation.');
342
- }
336
+ const requestPayload = decodeLocationCreationContent(pending.requestContentReference, pending.requestSha256, pending.requestEncodedSize);
343
337
  let local;
344
338
  try {
345
339
  local = await materialize(requestPayload, current, signal ?? new AbortController().signal);
@@ -647,24 +641,27 @@ function localCreationRecord(request, snapshot, targetNodeRid, targetNodeGenerat
647
641
  deadlineUnixMs: BigInt(deadlineUnixMs)
648
642
  };
649
643
  }
650
- function encodeLocationCreationContent(payload, checksum = crc32c(payload)) {
651
- return `inline-v1:${checksum.toString(16).padStart(8, '0')}:${Buffer.from(payload).toString('base64url')}`;
644
+ // 21-location-runtime.md#2.4: requestContentReference is `inline-v1:{base64url}`
645
+ // over `A-Z a-z 0-9 - _` with no `=` padding, and no other form is recognized.
646
+ // The same atomic record's requestEncodedSize and requestSha256 decide the
647
+ // content's integrity, so the reference carries no checksum segment of its own.
648
+ function encodeLocationCreationContent(payload) {
649
+ return `inline-v1:${Buffer.from(payload).toString('base64url')}`;
652
650
  }
651
+ // Decodes the reference and verifies it against the same record's
652
+ // requestEncodedSize and requestSha256. A reference outside the specified form,
653
+ // a length mismatch or a digest mismatch all throw, and the caller must record
654
+ // the creation as failed without running the factory.
653
655
  function decodeLocationCreationContent(reference, expectedSha256, expectedEncodedSize) {
654
- const match = /^inline-v1:([0-9a-f]{8}):([A-Za-z0-9_-]+)$/.exec(reference);
655
- if (match === null) {
656
- throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'User Spot creation content reference is invalid.');
657
- }
658
- const payload = Buffer.from(match[2], 'base64url');
659
- if (crc32c(payload) !== Number.parseInt(match[1], 16)) {
660
- throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'User Spot creation content checksum does not match.');
656
+ const match = /^inline-v1:([A-Za-z0-9_-]*)$/.exec(reference);
657
+ if (match === null || match[1].length % 4 === 1) {
658
+ throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'Creation content reference is not in the inline-v1 form.');
661
659
  }
662
- if (expectedEncodedSize !== undefined
663
- && BigInt(payload.byteLength) !== expectedEncodedSize) {
660
+ const payload = Buffer.from(match[1], 'base64url');
661
+ if (BigInt(payload.byteLength) !== expectedEncodedSize) {
664
662
  throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'Creation content encoded size does not match its Pending reservation.');
665
663
  }
666
- if (expectedSha256 !== undefined
667
- && !(0, node_crypto_1.createHash)('sha256').update(payload).digest().equals(Buffer.from(expectedSha256))) {
664
+ if (!(0, node_crypto_1.createHash)('sha256').update(payload).digest().equals(Buffer.from(expectedSha256))) {
668
665
  throw (0, framework_errors_internal_1.createInternalFrameworkException)(framework_errors_internal_1.ZLinkFrameworkInternalErrorKind.RequestFailed, 'Creation content SHA-256 does not match its Pending reservation.');
669
666
  }
670
667
  return payload;
@@ -695,16 +692,6 @@ function userSpotAuthorityPayload(snapshot, spotId, state) {
695
692
  ownerNodeGeneration: snapshot.allocation.descriptorLifecycleGeneration
696
693
  });
697
694
  }
698
- function crc32c(payload) {
699
- let crc = 0xffff_ffff;
700
- for (const value of payload) {
701
- crc ^= value;
702
- for (let bit = 0; bit < 8; bit++) {
703
- crc = (crc >>> 1) ^ (0x82f6_3b78 & -(crc & 1));
704
- }
705
- }
706
- return (~crc) >>> 0;
707
- }
708
695
  function remoteUserSpotFailure(terminalResult, failureCode, message) {
709
696
  // Route every User Spot create terminal through the shared ownership-aware
710
697
  // translator (spec 32-framework-error-model:81-118, 99-108) rather than an
@@ -131,6 +131,7 @@ export declare class ZLinkStreamSessionNodeRuntime {
131
131
  private stopped;
132
132
  private readonly receiveAbortController;
133
133
  private receiveLoop;
134
+ private monitorLoop;
134
135
  private readonly receiveWake;
135
136
  private receiveWorkSinceYield;
136
137
  constructor(options: ZLinkStreamSessionNodeRuntimeOptions & ZLinkStreamLivenessOptions);
@@ -140,11 +141,12 @@ export declare class ZLinkStreamSessionNodeRuntime {
140
141
  findSession(routingId: unknown): ZLinkStreamSessionRuntime | undefined;
141
142
  dispose(): Promise<void>;
142
143
  drainCloseSessions(): Promise<void>;
144
+ private runMonitorLoop;
145
+ private monitorIdleDelay;
143
146
  private runReceiveLoop;
144
147
  private acceptPacket;
145
148
  private takePacket;
146
149
  private recyclePacket;
147
- private drainMonitorEvents;
148
150
  private handleMalformedPacket;
149
151
  private isReceiveStopped;
150
152
  private onMonitorEvent;
@@ -25,6 +25,9 @@ const ZLINK_STREAM_APPLICATION_IDLE_TIMEOUT_MS = 30_000;
25
25
  const ZLINK_STREAM_RECEIVE_FRAME_BATCH_LIMIT = 64;
26
26
  const ZLINK_STREAM_ACTOR_BINDING_REPLACEMENT_CALLBACK_TIMEOUT_MS = 30_000;
27
27
  const ZLINK_STREAM_ACTOR_BINDING_REPLACEMENT_CLOSE_DELAY_MS = 100;
28
+ const ZLINK_STREAM_MONITOR_IDLE_MIN_DELAY_MS = 1;
29
+ const ZLINK_STREAM_MONITOR_IDLE_MAX_DELAY_MS = 20;
30
+ const ZLINK_STREAM_MONITOR_IDLE_MAX_STEPS = 7;
28
31
  const systemLivenessClock = {
29
32
  now: () => performance.now(),
30
33
  setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -654,6 +657,7 @@ class ZLinkStreamSessionNodeRuntime {
654
657
  stopped = false;
655
658
  receiveAbortController = new AbortController();
656
659
  receiveLoop;
660
+ monitorLoop;
657
661
  receiveWake = new ZLinkStreamReceiveWake();
658
662
  receiveWorkSinceYield = 0;
659
663
  constructor(options) {
@@ -663,7 +667,8 @@ class ZLinkStreamSessionNodeRuntime {
663
667
  if (this.stopped || this.receiveLoop !== undefined) {
664
668
  return;
665
669
  }
666
- this.options.monitor?.onEvent(event => this.onMonitorEvent(event));
670
+ const monitor = this.options.monitor;
671
+ monitor?.onEvent(event => this.onMonitorEvent(event));
667
672
  const running = this.runReceiveLoop(this.receiveAbortController.signal)
668
673
  .catch((error) => {
669
674
  if (!this.stopped) {
@@ -671,6 +676,14 @@ class ZLinkStreamSessionNodeRuntime {
671
676
  }
672
677
  });
673
678
  this.receiveLoop = running;
679
+ if (monitor !== undefined) {
680
+ this.monitorLoop = this.runMonitorLoop(monitor, this.receiveAbortController.signal)
681
+ .catch((error) => {
682
+ if (!this.stopped) {
683
+ this.options.onError?.(error);
684
+ }
685
+ });
686
+ }
674
687
  }
675
688
  markConnected(routingId, localAddr, remoteAddr) {
676
689
  const session = this.getOrCreateSession(routingId);
@@ -689,6 +702,7 @@ class ZLinkStreamSessionNodeRuntime {
689
702
  this.wakeReceiveLoop();
690
703
  try {
691
704
  await this.receiveLoop;
705
+ await this.monitorLoop;
692
706
  const sessions = [...this.sessions.values()];
693
707
  this.sessions.clear();
694
708
  for (const packet of this.availablePackets.splice(0))
@@ -710,9 +724,47 @@ class ZLinkStreamSessionNodeRuntime {
710
724
  async drainCloseSessions() {
711
725
  await Promise.allSettled([...this.sessions.values()].map((session) => session.drainClose()));
712
726
  }
727
+ // Spec server 04-session §2/§7: a session exists once the handshake succeeds,
728
+ // and connect/disconnect callbacks are its base surface. A stream node learns
729
+ // of both from the socket monitor, which is a source of its own with no
730
+ // event-loop readiness callback. Draining it from the receive loop would tie
731
+ // a connection event to inbound traffic, so a session that never sends - a
732
+ // push-only console - would stay unknown to the node until it happened to
733
+ // write something. The .NET runtime already drains the monitor in its own
734
+ // loop (ZLinkStreamNodeRuntime.RunMonitorLoopAsync); this is the same rule.
735
+ async runMonitorLoop(monitor, signal) {
736
+ let misses = 0;
737
+ while (!this.isReceiveStopped(signal)) {
738
+ if (monitor.drain() > 0) {
739
+ misses = 0;
740
+ // Yield so a burst of monitor events cannot starve the event loop.
741
+ await this.monitorIdleDelay(0, signal);
742
+ continue;
743
+ }
744
+ misses = Math.min(misses + 1, ZLINK_STREAM_MONITOR_IDLE_MAX_STEPS);
745
+ await this.monitorIdleDelay(monitorIdleDelayMs(misses), signal);
746
+ }
747
+ }
748
+ monitorIdleDelay(delayMs, signal) {
749
+ if (signal.aborted)
750
+ return Promise.resolve();
751
+ return new Promise((resolve) => {
752
+ const onAbort = () => {
753
+ clearTimeout(timer);
754
+ resolve();
755
+ };
756
+ const timer = setTimeout(() => {
757
+ signal.removeEventListener('abort', onAbort);
758
+ resolve();
759
+ }, delayMs);
760
+ // The bound socket already holds the event loop open for this node. The
761
+ // idle poll must not become a second reason for a process to stay alive.
762
+ timer.unref();
763
+ signal.addEventListener('abort', onAbort, { once: true });
764
+ });
765
+ }
713
766
  async runReceiveLoop(signal) {
714
767
  while (!this.isReceiveStopped(signal)) {
715
- this.drainMonitorEvents();
716
768
  let permit;
717
769
  let packet;
718
770
  let packetTransferred = false;
@@ -839,9 +891,6 @@ class ZLinkStreamSessionNodeRuntime {
839
891
  if (!this.stopped)
840
892
  this.availablePackets.push(packet);
841
893
  }
842
- drainMonitorEvents() {
843
- this.options.monitor?.drain();
844
- }
845
894
  handleMalformedPacket(routingId, session, error) {
846
895
  this.options.onError?.(error);
847
896
  try {
@@ -1112,6 +1161,10 @@ class ZLinkStreamSessionNodeRuntime {
1112
1161
  }
1113
1162
  }
1114
1163
  exports.ZLinkStreamSessionNodeRuntime = ZLinkStreamSessionNodeRuntime;
1164
+ function monitorIdleDelayMs(misses) {
1165
+ const scaled = ZLINK_STREAM_MONITOR_IDLE_MIN_DELAY_MS * 2 ** (misses - 1);
1166
+ return Math.min(scaled, ZLINK_STREAM_MONITOR_IDLE_MAX_DELAY_MS);
1167
+ }
1115
1168
  function streamMonitorEndpointKey(localAddr, remoteAddr) {
1116
1169
  return `${localAddr ?? ''}\n${remoteAddr ?? ''}`;
1117
1170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zlink-systems/framework",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@opentelemetry/api": "^1.9.1",
21
21
  "@opentelemetry/api-logs": "^0.221.0",
22
- "@zlink-systems/stream-wire": "0.17.0",
23
- "@zlink-systems/zlink": "1.2.0"
22
+ "@zlink-systems/stream-wire": "0.18.1",
23
+ "@zlink-systems/zlink": "1.2.1"
24
24
  }
25
25
  }
@@ -104,9 +104,8 @@ export class ZLinkUserSpotCreationCoordinator {
104
104
  const deadlineUnixMs = Date.now() + request.timeoutMs;
105
105
  const signal = deadline.signal;
106
106
  signal.throwIfAborted();
107
- const checksum = crc32c(request.requestPayload);
108
107
  const sha256 = createHash('sha256').update(request.requestPayload).digest();
109
- const contentReference = encodeLocationCreationContent(request.requestPayload, checksum);
108
+ const contentReference = encodeLocationCreationContent(request.requestPayload);
110
109
  const excludedNodeRids = new Set<string>();
111
110
  let target: Awaited<ReturnType<ZLinkUserSpotCreationCoordinatorOptions['target']>>;
112
111
  let authorityTarget;
@@ -542,18 +541,10 @@ export class ZLinkUserSpotCreationCoordinator {
542
541
  );
543
542
  }
544
543
  const requestPayload = decodeLocationCreationContent(
545
- pending.requestContentReference
544
+ pending.requestContentReference,
545
+ pending.requestSha256,
546
+ pending.requestEncodedSize
546
547
  );
547
- if (
548
- BigInt(requestPayload.byteLength) !== pending.requestEncodedSize
549
- || !createHash('sha256').update(requestPayload).digest()
550
- .equals(Buffer.from(pending.requestSha256))
551
- ) {
552
- throw createInternalFrameworkException(
553
- ZLinkFrameworkInternalErrorKind.RequestFailed,
554
- 'Remote User Spot Pending creation content failed integrity validation.'
555
- );
556
- }
557
548
  let local: ZLinkLocalSpotCreateResult | undefined;
558
549
  try {
559
550
  local = await materialize(
@@ -1002,45 +993,38 @@ function localCreationRecord(
1002
993
  };
1003
994
  }
1004
995
 
1005
- export function encodeLocationCreationContent(
1006
- payload: Uint8Array,
1007
- checksum = crc32c(payload)
1008
- ): string {
1009
- return `inline-v1:${checksum.toString(16).padStart(8, '0')}:${Buffer.from(payload).toString('base64url')}`;
996
+ // 21-location-runtime.md#2.4: requestContentReference is `inline-v1:{base64url}`
997
+ // over `A-Z a-z 0-9 - _` with no `=` padding, and no other form is recognized.
998
+ // The same atomic record's requestEncodedSize and requestSha256 decide the
999
+ // content's integrity, so the reference carries no checksum segment of its own.
1000
+ export function encodeLocationCreationContent(payload: Uint8Array): string {
1001
+ return `inline-v1:${Buffer.from(payload).toString('base64url')}`;
1010
1002
  }
1011
1003
 
1004
+ // Decodes the reference and verifies it against the same record's
1005
+ // requestEncodedSize and requestSha256. A reference outside the specified form,
1006
+ // a length mismatch or a digest mismatch all throw, and the caller must record
1007
+ // the creation as failed without running the factory.
1012
1008
  export function decodeLocationCreationContent(
1013
1009
  reference: string,
1014
- expectedSha256?: Uint8Array,
1015
- expectedEncodedSize?: bigint
1010
+ expectedSha256: Uint8Array,
1011
+ expectedEncodedSize: bigint
1016
1012
  ): Buffer {
1017
- const match = /^inline-v1:([0-9a-f]{8}):([A-Za-z0-9_-]+)$/.exec(reference);
1018
- if (match === null) {
1019
- throw createInternalFrameworkException(
1020
- ZLinkFrameworkInternalErrorKind.RequestFailed,
1021
- 'User Spot creation content reference is invalid.'
1022
- );
1023
- }
1024
- const payload = Buffer.from(match[2]!, 'base64url');
1025
- if (crc32c(payload) !== Number.parseInt(match[1]!, 16)) {
1013
+ const match = /^inline-v1:([A-Za-z0-9_-]*)$/.exec(reference);
1014
+ if (match === null || match[1]!.length % 4 === 1) {
1026
1015
  throw createInternalFrameworkException(
1027
1016
  ZLinkFrameworkInternalErrorKind.RequestFailed,
1028
- 'User Spot creation content checksum does not match.'
1017
+ 'Creation content reference is not in the inline-v1 form.'
1029
1018
  );
1030
1019
  }
1031
- if (
1032
- expectedEncodedSize !== undefined
1033
- && BigInt(payload.byteLength) !== expectedEncodedSize
1034
- ) {
1020
+ const payload = Buffer.from(match[1]!, 'base64url');
1021
+ if (BigInt(payload.byteLength) !== expectedEncodedSize) {
1035
1022
  throw createInternalFrameworkException(
1036
1023
  ZLinkFrameworkInternalErrorKind.RequestFailed,
1037
1024
  'Creation content encoded size does not match its Pending reservation.'
1038
1025
  );
1039
1026
  }
1040
- if (
1041
- expectedSha256 !== undefined
1042
- && !createHash('sha256').update(payload).digest().equals(Buffer.from(expectedSha256))
1043
- ) {
1027
+ if (!createHash('sha256').update(payload).digest().equals(Buffer.from(expectedSha256))) {
1044
1028
  throw createInternalFrameworkException(
1045
1029
  ZLinkFrameworkInternalErrorKind.RequestFailed,
1046
1030
  'Creation content SHA-256 does not match its Pending reservation.'
@@ -1088,17 +1072,6 @@ function userSpotAuthorityPayload(
1088
1072
  });
1089
1073
  }
1090
1074
 
1091
- function crc32c(payload: Uint8Array): number {
1092
- let crc = 0xffff_ffff;
1093
- for (const value of payload) {
1094
- crc ^= value;
1095
- for (let bit = 0; bit < 8; bit++) {
1096
- crc = (crc >>> 1) ^ (0x82f6_3b78 & -(crc & 1));
1097
- }
1098
- }
1099
- return (~crc) >>> 0;
1100
- }
1101
-
1102
1075
  function remoteUserSpotFailure(
1103
1076
  terminalResult: number,
1104
1077
  failureCode: number,
@@ -69,6 +69,9 @@ const ZLINK_STREAM_APPLICATION_IDLE_TIMEOUT_MS = 30_000;
69
69
  const ZLINK_STREAM_RECEIVE_FRAME_BATCH_LIMIT = 64;
70
70
  const ZLINK_STREAM_ACTOR_BINDING_REPLACEMENT_CALLBACK_TIMEOUT_MS = 30_000;
71
71
  const ZLINK_STREAM_ACTOR_BINDING_REPLACEMENT_CLOSE_DELAY_MS = 100;
72
+ const ZLINK_STREAM_MONITOR_IDLE_MIN_DELAY_MS = 1;
73
+ const ZLINK_STREAM_MONITOR_IDLE_MAX_DELAY_MS = 20;
74
+ const ZLINK_STREAM_MONITOR_IDLE_MAX_STEPS = 7;
72
75
 
73
76
  interface ZLinkStreamLivenessClock {
74
77
  now(): number;
@@ -884,6 +887,7 @@ export class ZLinkStreamSessionNodeRuntime {
884
887
  private stopped = false;
885
888
  private readonly receiveAbortController = new AbortController();
886
889
  private receiveLoop: Promise<void> | undefined;
890
+ private monitorLoop: Promise<void> | undefined;
887
891
  private readonly receiveWake = new ZLinkStreamReceiveWake();
888
892
  private receiveWorkSinceYield = 0;
889
893
  constructor(
@@ -894,7 +898,8 @@ export class ZLinkStreamSessionNodeRuntime {
894
898
  if (this.stopped || this.receiveLoop !== undefined) {
895
899
  return;
896
900
  }
897
- this.options.monitor?.onEvent(event => this.onMonitorEvent(event));
901
+ const monitor = this.options.monitor;
902
+ monitor?.onEvent(event => this.onMonitorEvent(event));
898
903
  const running = this.runReceiveLoop(this.receiveAbortController.signal)
899
904
  .catch((error) => {
900
905
  if (!this.stopped) {
@@ -902,6 +907,14 @@ export class ZLinkStreamSessionNodeRuntime {
902
907
  }
903
908
  });
904
909
  this.receiveLoop = running;
910
+ if (monitor !== undefined) {
911
+ this.monitorLoop = this.runMonitorLoop(monitor, this.receiveAbortController.signal)
912
+ .catch((error) => {
913
+ if (!this.stopped) {
914
+ this.options.onError?.(error);
915
+ }
916
+ });
917
+ }
905
918
  }
906
919
 
907
920
  markConnected(routingId: unknown, localAddr?: string, remoteAddr?: string): void {
@@ -924,6 +937,7 @@ export class ZLinkStreamSessionNodeRuntime {
924
937
  this.wakeReceiveLoop();
925
938
  try {
926
939
  await this.receiveLoop;
940
+ await this.monitorLoop;
927
941
  const sessions = [...this.sessions.values()];
928
942
  this.sessions.clear();
929
943
  for (const packet of this.availablePackets.splice(0)) packet.close();
@@ -945,9 +959,51 @@ export class ZLinkStreamSessionNodeRuntime {
945
959
  await Promise.allSettled([...this.sessions.values()].map((session) => session.drainClose()));
946
960
  }
947
961
 
962
+ // Spec server 04-session §2/§7: a session exists once the handshake succeeds,
963
+ // and connect/disconnect callbacks are its base surface. A stream node learns
964
+ // of both from the socket monitor, which is a source of its own with no
965
+ // event-loop readiness callback. Draining it from the receive loop would tie
966
+ // a connection event to inbound traffic, so a session that never sends - a
967
+ // push-only console - would stay unknown to the node until it happened to
968
+ // write something. The .NET runtime already drains the monitor in its own
969
+ // loop (ZLinkStreamNodeRuntime.RunMonitorLoopAsync); this is the same rule.
970
+ private async runMonitorLoop(
971
+ monitor: ZLinkBackendSocketMonitor,
972
+ signal: AbortSignal
973
+ ): Promise<void> {
974
+ let misses = 0;
975
+ while (!this.isReceiveStopped(signal)) {
976
+ if (monitor.drain() > 0) {
977
+ misses = 0;
978
+ // Yield so a burst of monitor events cannot starve the event loop.
979
+ await this.monitorIdleDelay(0, signal);
980
+ continue;
981
+ }
982
+ misses = Math.min(misses + 1, ZLINK_STREAM_MONITOR_IDLE_MAX_STEPS);
983
+ await this.monitorIdleDelay(monitorIdleDelayMs(misses), signal);
984
+ }
985
+ }
986
+
987
+ private monitorIdleDelay(delayMs: number, signal: AbortSignal): Promise<void> {
988
+ if (signal.aborted) return Promise.resolve();
989
+ return new Promise((resolve) => {
990
+ const onAbort = (): void => {
991
+ clearTimeout(timer);
992
+ resolve();
993
+ };
994
+ const timer = setTimeout(() => {
995
+ signal.removeEventListener('abort', onAbort);
996
+ resolve();
997
+ }, delayMs);
998
+ // The bound socket already holds the event loop open for this node. The
999
+ // idle poll must not become a second reason for a process to stay alive.
1000
+ timer.unref();
1001
+ signal.addEventListener('abort', onAbort, { once: true });
1002
+ });
1003
+ }
1004
+
948
1005
  private async runReceiveLoop(signal: AbortSignal): Promise<void> {
949
1006
  while (!this.isReceiveStopped(signal)) {
950
- this.drainMonitorEvents();
951
1007
  let permit: ApplicationJobPermitPort | undefined;
952
1008
  let packet: ZLinkBackendStreamPacket | undefined;
953
1009
  let packetTransferred = false;
@@ -1078,10 +1134,6 @@ export class ZLinkStreamSessionNodeRuntime {
1078
1134
  if (!this.stopped) this.availablePackets.push(packet);
1079
1135
  }
1080
1136
 
1081
- private drainMonitorEvents(): void {
1082
- this.options.monitor?.drain();
1083
- }
1084
-
1085
1137
  private handleMalformedPacket(
1086
1138
  routingId: unknown,
1087
1139
  session: ZLinkStreamSessionRuntime,
@@ -1387,6 +1439,11 @@ export class ZLinkStreamSessionNodeRuntime {
1387
1439
  }
1388
1440
  }
1389
1441
 
1442
+ function monitorIdleDelayMs(misses: number): number {
1443
+ const scaled = ZLINK_STREAM_MONITOR_IDLE_MIN_DELAY_MS * 2 ** (misses - 1);
1444
+ return Math.min(scaled, ZLINK_STREAM_MONITOR_IDLE_MAX_DELAY_MS);
1445
+ }
1446
+
1390
1447
  function streamMonitorEndpointKey(localAddr: string | undefined, remoteAddr: string | undefined): string {
1391
1448
  return `${localAddr ?? ''}\n${remoteAddr ?? ''}`;
1392
1449
  }