@actuallab/rpc 12.3.14 → 12.3.31

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/dist/index.js CHANGED
@@ -1,3 +1,30 @@
1
+ // src/logging.ts
2
+ import { createLogProvider, LogLevel } from "@actuallab/core";
3
+ var scopeDefaults = {
4
+ RpcCallTracker: LogLevel.Warn,
5
+ RpcClient: LogLevel.Warn,
6
+ RpcClientPeerReconnectDelayer: LogLevel.Warn,
7
+ RpcConnection: LogLevel.Warn,
8
+ RpcHub: LogLevel.Warn,
9
+ RpcInterceptor: LogLevel.Warn,
10
+ RpcMessageChannelConnection: LogLevel.Warn,
11
+ RpcMethodRegistry: LogLevel.Warn,
12
+ RpcOutboundCall: LogLevel.Warn,
13
+ RpcPeer: LogLevel.Info,
14
+ RpcPeerStateMonitor: LogLevel.Warn,
15
+ RpcRemoteObjectTracker: LogLevel.Warn,
16
+ RpcSerialization: LogLevel.Warn,
17
+ RpcServiceHost: LogLevel.Warn,
18
+ RpcSharedObjectTracker: LogLevel.Warn,
19
+ RpcSharedStream: LogLevel.Warn,
20
+ RpcStream: LogLevel.Warn,
21
+ RpcStreamSender: LogLevel.Warn,
22
+ RpcSystemCallHandler: LogLevel.Warn,
23
+ RpcSystemCallSender: LogLevel.Warn,
24
+ RpcWebSocketClient: LogLevel.Warn
25
+ };
26
+ var getLogs = createLogProvider("rpc.", scopeDefaults);
27
+
1
28
  // src/rpc-message.ts
2
29
  var RpcCallTypeId = /* @__PURE__ */ ((RpcCallTypeId2) => {
3
30
  RpcCallTypeId2[RpcCallTypeId2["Regular"] = 0] = "Regular";
@@ -23,9 +50,46 @@ var FRAME_DELIMITER = "\n";
23
50
 
24
51
  // src/rpc-serialization.ts
25
52
  import {
26
- Encoder,
53
+ Encoder as Encoder2,
27
54
  Decoder
28
55
  } from "@msgpack/msgpack";
56
+
57
+ // src/msgpack-map-patch.ts
58
+ import { Encoder } from "@msgpack/msgpack";
59
+ function patchMsgpackEncoderForMaps() {
60
+ const proto = Encoder.prototype;
61
+ if (proto._hasMapPatch) return;
62
+ proto._hasMapPatch = true;
63
+ const originalDoEncode = proto.doEncode;
64
+ proto.doEncode = function(object, depth) {
65
+ if (object instanceof Map) {
66
+ _encodeMapInstance.call(this, object, depth);
67
+ return;
68
+ }
69
+ originalDoEncode.call(this, object, depth);
70
+ };
71
+ function _encodeMapInstance(m, depth) {
72
+ const self = this;
73
+ const size = m.size;
74
+ if (size < 16) {
75
+ self.writeU8(128 + size);
76
+ } else if (size < 65536) {
77
+ self.writeU8(222);
78
+ self.writeU16(size);
79
+ } else {
80
+ self.writeU8(223);
81
+ self.writeU32(size);
82
+ }
83
+ for (const [key, value] of m) {
84
+ self.doEncode(key, depth + 1);
85
+ self.doEncode(value, depth + 1);
86
+ }
87
+ }
88
+ }
89
+ patchMsgpackEncoderForMaps();
90
+
91
+ // src/rpc-serialization.ts
92
+ var { warnLog } = getLogs("RpcSerialization");
29
93
  function serializeMessage(message, args) {
30
94
  const envelope = JSON.stringify(message);
31
95
  if (args === void 0 || args.length === 0) {
@@ -55,9 +119,7 @@ function deserializeMessage(raw) {
55
119
  try {
56
120
  return JSON.parse(s);
57
121
  } catch {
58
- console.warn(
59
- `[RpcSerialization] failed to parse arg as JSON: ${s.substring(0, 100)}`
60
- );
122
+ warnLog?.log(`failed to parse arg as JSON: ${s.substring(0, 100)}`);
61
123
  return s;
62
124
  }
63
125
  });
@@ -66,7 +128,7 @@ function deserializeMessage(raw) {
66
128
  var textEncoder = new TextEncoder();
67
129
  var textDecoder = new TextDecoder();
68
130
  var INITIAL_ENCODER_BUFFER_SIZE = 32 * 1024;
69
- var defaultBinaryEncoder = new Encoder(
131
+ var defaultBinaryEncoder = new Encoder2(
70
132
  void 0,
71
133
  void 0,
72
134
  void 0,
@@ -74,7 +136,7 @@ var defaultBinaryEncoder = new Encoder(
74
136
  );
75
137
  var defaultBinaryDecoder = new Decoder();
76
138
  function createBinaryEncoder() {
77
- return new Encoder(
139
+ return new Encoder2(
78
140
  void 0,
79
141
  void 0,
80
142
  void 0,
@@ -313,6 +375,7 @@ function splitCompactBinaryFrame(frame, registry, decoder = defaultBinaryDecoder
313
375
  // src/rpc-connection.ts
314
376
  import { Decoder as Decoder2 } from "@msgpack/msgpack";
315
377
  import { PromiseSource, EventHandlerSet } from "@actuallab/core";
378
+ var { warnLog: warnLog2 } = getLogs("RpcConnection");
316
379
  var WebSocketState = {
317
380
  CONNECTING: 0,
318
381
  OPEN: 1,
@@ -374,6 +437,7 @@ var RpcWebSocketConnection = class {
374
437
  });
375
438
  }
376
439
  } catch (e) {
440
+ warnLog2?.log("Failed to split binary frame:", e);
377
441
  this.error.trigger(e);
378
442
  }
379
443
  } else if (typeof Blob !== "undefined" && ev.data instanceof Blob && this.binaryMode) {
@@ -389,6 +453,7 @@ var RpcWebSocketConnection = class {
389
453
  });
390
454
  }
391
455
  } catch (e) {
456
+ warnLog2?.log("Failed to split binary blob frame:", e);
392
457
  this.error.trigger(e);
393
458
  }
394
459
  });
@@ -560,6 +625,21 @@ function defineRpcService(name, methods) {
560
625
 
561
626
  // src/rpc-call-tracker.ts
562
627
  import { PromiseSource as PromiseSource2 } from "@actuallab/core";
628
+
629
+ // src/rpc-call-stage.ts
630
+ var RpcCallStage = {
631
+ /** The call's result has been received from the peer. */
632
+ ResultReady: 1,
633
+ /** The compute call's result has been invalidated (compute-call specific). */
634
+ Invalidated: 3,
635
+ /**
636
+ * Bit flag added on top of ResultReady/Invalidated when the outbound
637
+ * call has been removed from the tracker (unregistered).
638
+ */
639
+ Unregistered: 4096
640
+ };
641
+
642
+ // src/rpc-call-tracker.ts
563
643
  var RpcOutboundCall = class {
564
644
  callId;
565
645
  method;
@@ -571,11 +651,35 @@ var RpcOutboundCall = class {
571
651
  removeOnOk = true;
572
652
  /** The serialized wire data — stored for re-sending on reconnect. */
573
653
  serializedWireData = "";
654
+ /**
655
+ * Bitfield of {@link RpcCallStage} flags indicating how far this call
656
+ * has progressed. Used by the `$sys.Reconnect` protocol to tell the
657
+ * remote peer which calls are still pending its attention.
658
+ *
659
+ * - 0: brand new, no result yet (the peer is expected to be actively processing).
660
+ * - `ResultReady` (1): `$sys.Ok` received for this call.
661
+ * - `Invalidated` (3): result was invalidated (compute calls only).
662
+ * - `Unregistered` (0x1000): removed from the tracker.
663
+ */
664
+ completedStage = 0;
574
665
  constructor(callId, method, remoteExecutionMode = 7) {
575
666
  this.callId = callId;
576
667
  this.method = method;
577
668
  this.remoteExecutionMode = remoteExecutionMode;
578
669
  }
670
+ /**
671
+ * Returns the stage to report to the remote peer via `$sys.Reconnect`,
672
+ * or `null` if this call should not be reconciled and must be aborted.
673
+ *
674
+ * Mirrors .NET `RpcOutboundCall.GetReconnectStage` at
675
+ * src/ActualLab.Rpc/Infrastructure/RpcOutboundCall.cs.
676
+ */
677
+ getReconnectStage(isPeerChanged) {
678
+ const mode = this.remoteExecutionMode;
679
+ if (!(mode & RpcRemoteExecutionMode.AllowReconnect)) return null;
680
+ if (isPeerChanged && !(mode & RpcRemoteExecutionMode.AllowResend)) return null;
681
+ return this.completedStage;
682
+ }
579
683
  /** Called when the connection is lost. Subclasses can override to resolve invalidation promises. */
580
684
  onDisconnect() {
581
685
  }
@@ -597,7 +701,10 @@ var RpcOutboundCallTracker = class {
597
701
  }
598
702
  remove(callId) {
599
703
  const call = this._calls.get(callId);
600
- if (call !== void 0) this._calls.delete(callId);
704
+ if (call !== void 0) {
705
+ this._calls.delete(callId);
706
+ call.completedStage |= RpcCallStage.Unregistered;
707
+ }
601
708
  return call;
602
709
  }
603
710
  values() {
@@ -660,6 +767,97 @@ var RpcInboundCallTracker = class {
660
767
  }
661
768
  };
662
769
 
770
+ // src/increasing-seq-compressor.ts
771
+ var IncreasingSeqCompressor = {
772
+ /**
773
+ * Serialize a non-decreasing sequence of non-negative integers as
774
+ * LEB128-encoded deltas.
775
+ *
776
+ * Throws `RangeError` if the input is not monotonically non-decreasing,
777
+ * or if a value exceeds `Number.MAX_SAFE_INTEGER`.
778
+ */
779
+ serialize(values) {
780
+ const deltas = [];
781
+ let last = 0;
782
+ let totalBytes = 0;
783
+ for (const value of values) {
784
+ if (!Number.isSafeInteger(value) || value < 0)
785
+ throw new RangeError(
786
+ `IncreasingSeqCompressor: value must be a non-negative safe integer (got ${value}).`
787
+ );
788
+ const delta = value - last;
789
+ if (delta < 0)
790
+ throw new RangeError(
791
+ `IncreasingSeqCompressor: sequence must be non-decreasing (saw ${value} after ${last}).`
792
+ );
793
+ deltas.push(delta);
794
+ last = value;
795
+ totalBytes += _varUintByteLength(delta);
796
+ }
797
+ const out = new Uint8Array(totalBytes);
798
+ let offset = 0;
799
+ for (const delta of deltas) {
800
+ offset = _writeVarUint(out, delta, offset);
801
+ }
802
+ return out;
803
+ },
804
+ /**
805
+ * Decode bytes produced by {@link serialize} back into the original
806
+ * sorted sequence of non-negative integers.
807
+ */
808
+ deserialize(data) {
809
+ const result = [];
810
+ let last = 0;
811
+ let offset = 0;
812
+ while (offset < data.length) {
813
+ const [delta, next] = _readVarUint(data, offset);
814
+ offset = next;
815
+ last += delta;
816
+ result.push(last);
817
+ }
818
+ return result;
819
+ }
820
+ };
821
+ var LOW_BITS = 127;
822
+ var HIGH_BIT = 128;
823
+ function _varUintByteLength(value) {
824
+ let n = 0;
825
+ let v = value;
826
+ while (v >= HIGH_BIT) {
827
+ n++;
828
+ v = Math.floor(v / 128);
829
+ }
830
+ return n + 1;
831
+ }
832
+ function _writeVarUint(out, value, offset) {
833
+ let v = value;
834
+ while (v >= HIGH_BIT) {
835
+ out[offset++] = (HIGH_BIT | v & LOW_BITS) & 255;
836
+ v = Math.floor(v / 128);
837
+ }
838
+ out[offset++] = v & 255;
839
+ return offset;
840
+ }
841
+ function _readVarUint(data, offset) {
842
+ let value = 0;
843
+ let shift = 0;
844
+ while (shift < 63) {
845
+ if (offset >= data.length)
846
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
847
+ const b2 = data[offset++];
848
+ value += (b2 & LOW_BITS) * Math.pow(2, shift);
849
+ if (b2 <= LOW_BITS) return [value, offset];
850
+ shift += 7;
851
+ }
852
+ if (offset >= data.length)
853
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
854
+ const b = data[offset++];
855
+ if (b > 1)
856
+ throw new RangeError("IncreasingSeqCompressor: VarUInt value exceeds 64 bits.");
857
+ value += b * Math.pow(2, 63);
858
+ return [value, offset];
859
+ }
860
+
663
861
  // src/rpc-stream.ts
664
862
  import Denque from "denque";
665
863
  import { PromiseSource as PromiseSource4 } from "@actuallab/core";
@@ -668,7 +866,8 @@ import { PromiseSource as PromiseSource4 } from "@actuallab/core";
668
866
  var RpcObjectKind = { Local: 0, Remote: 1 };
669
867
 
670
868
  // src/rpc-stream-sender.ts
671
- import { PromiseSource as PromiseSource3 } from "@actuallab/core";
869
+ import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
870
+ var { warnLog: warnLog3 } = getLogs("RpcSharedStream");
672
871
  var DEFAULT_ACK_PERIOD = 256;
673
872
  var DEFAULT_ACK_ADVANCE = 128;
674
873
  var RpcStreamSender = class _RpcStreamSender {
@@ -681,15 +880,29 @@ var RpcStreamSender = class _RpcStreamSender {
681
880
  peer;
682
881
  ackPeriod;
683
882
  ackAdvance;
883
+ /**
884
+ * If true, the source was produced by a factory `(abortSignal) => AsyncIterable<T>`
885
+ * and may honor the AbortSignal to exit gracefully. `disconnect()` first aborts
886
+ * the signal and gives the source up to `RpcStream.disconnectGracePeriodMs` ms
887
+ * before force-closing via `iterator.return()`.
888
+ *
889
+ * If false, the source is a plain AsyncIterable that cannot observe the signal,
890
+ * so `disconnect()` force-closes immediately.
891
+ */
892
+ sourceUsesAbortSignal;
893
+ // -- Pump state --
894
+ /** Next stream index that will be sent (matches .NET's `index`). */
684
895
  _nextIndex = 0;
685
896
  _ended = false;
686
897
  _started = new PromiseSource3();
898
+ /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
687
899
  _lastAckedIndex = 0;
688
- _ackWaiting = null;
689
- _resetRequested = false;
690
900
  _abortController = new AbortController();
691
901
  _iterator = null;
692
- constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true) {
902
+ // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
903
+ _acks = [];
904
+ _whenAckReady = null;
905
+ constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true, sourceUsesAbortSignal = false) {
693
906
  const localId = peer.sharedObjects.nextId();
694
907
  this.id = { hostId: peer.hub.hubId, localId };
695
908
  this.allowReconnect = allowReconnect;
@@ -698,6 +911,7 @@ var RpcStreamSender = class _RpcStreamSender {
698
911
  this.peer = peer;
699
912
  this.ackPeriod = ackPeriod;
700
913
  this.ackAdvance = ackAdvance;
914
+ this.sourceUsesAbortSignal = sourceUsesAbortSignal;
701
915
  }
702
916
  /** AbortSignal that is aborted when the sender is disconnected. */
703
917
  get abortSignal() {
@@ -707,56 +921,61 @@ var RpcStreamSender = class _RpcStreamSender {
707
921
  toRef() {
708
922
  return `${this.id.hostId},${this.id.localId},${this.ackPeriod},${this.ackAdvance},${this.allowReconnect ? "1" : "0"},${this.isRealTime ? "1" : "0"}`;
709
923
  }
710
- /** Called by system call handler when $sys.Ack is received from the client. */
924
+ /**
925
+ * Called by system call handler when $sys.Ack is received from the client.
926
+ *
927
+ * Mirrors .NET `RpcSharedStream<T>.OnAck` at
928
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:68-101.
929
+ */
711
930
  onAck(nextIndex, hostId) {
712
931
  const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
713
- if (!this._started.isCompleted) {
714
- this._started.resolve();
715
- }
716
- if (mustReset && this.isRealTime) {
717
- this._nextIndex = nextIndex;
718
- this._lastAckedIndex = nextIndex;
719
- this._resetRequested = true;
720
- } else {
721
- if (mustReset || nextIndex > this._lastAckedIndex) {
722
- this._lastAckedIndex = nextIndex;
723
- }
724
- }
725
- if (this._ackWaiting) {
726
- this._ackWaiting.resolve();
727
- this._ackWaiting = null;
932
+ if (!this._started.isCompleted) this._started.resolve();
933
+ this._acks.push({ nextIndex, mustReset });
934
+ if (this._whenAckReady) {
935
+ this._whenAckReady.resolve();
936
+ this._whenAckReady = null;
728
937
  }
729
938
  }
730
939
  /** Called by system call handler when $sys.AckEnd is received from the client. */
731
940
  onAckEnd(_hostId) {
732
941
  this.disconnect();
733
942
  }
734
- /** Send a single item to the client. */
943
+ /**
944
+ * Send a single item to the client at the current `_nextIndex`.
945
+ *
946
+ * Advances `_nextIndex` unconditionally — even if the peer is disconnected
947
+ * the item is considered "sent" from the pump's perspective so the replay
948
+ * buffer indexing stays consistent. If a connection is absent, the wire
949
+ * message is dropped; the item will be resent from the replay buffer
950
+ * after a reconnect (client sends `Ack(N, mustReset=true)`).
951
+ */
735
952
  sendItem(item) {
736
953
  if (this._ended) return;
737
954
  const conn = this.peer.connection;
738
- if (!conn) return;
739
- this.peer.hub.systemCallSender.item(
740
- conn,
741
- this.peer.format,
742
- this.id.localId,
743
- this._nextIndex,
744
- item
745
- );
955
+ if (conn) {
956
+ this.peer.hub.systemCallSender.item(
957
+ conn,
958
+ this.peer.format,
959
+ this.id.localId,
960
+ this._nextIndex,
961
+ item
962
+ );
963
+ }
746
964
  this._nextIndex++;
747
965
  }
748
- /** Send a batch of items to the client. */
966
+ /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
749
967
  sendBatch(items) {
750
968
  if (this._ended || items.length === 0) return;
751
969
  const conn = this.peer.connection;
752
- if (!conn) return;
753
- this.peer.hub.systemCallSender.batch(
754
- conn,
755
- this.peer.format,
756
- this.id.localId,
757
- this._nextIndex,
758
- items
759
- );
970
+ if (conn) {
971
+ this.peer.hub.systemCallSender.batch(
972
+ conn,
973
+ this.peer.format,
974
+ this.id.localId,
975
+ this._nextIndex,
976
+ items
977
+ );
978
+ }
760
979
  this._nextIndex += items.length;
761
980
  }
762
981
  /** Signal stream completion to the client. */
@@ -776,76 +995,274 @@ var RpcStreamSender = class _RpcStreamSender {
776
995
  }
777
996
  /**
778
997
  * Consume an AsyncIterable and send all items to the client.
779
- * Waits for the client's initial Ack before starting to pump items.
780
998
  *
781
- * When `isRealTime` is true, applies flow control based on `ackAdvance`:
782
- * if the sender is `ackAdvance` items ahead of the last ACK, it enters
783
- * skip mode — draining the source and discarding items until it finds
784
- * one where `canSkipTo` returns true.
999
+ * Dispatches to the non-real-time or real-time pump, matching .NET
1000
+ * `RpcSharedStream<T>.OnRun` at
1001
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:105-108.
785
1002
  */
786
1003
  async writeFrom(source) {
787
1004
  await this._started.promise;
1005
+ if (this._ended) return;
788
1006
  const iterator = source[Symbol.asyncIterator]();
789
1007
  this._iterator = iterator;
1008
+ const state = { iteratorDone: false };
790
1009
  try {
791
- for (; ; ) {
792
- const next = await iterator.next();
793
- if (next.done || this._ended) break;
794
- const item = next.value;
795
- if (this._resetRequested) {
796
- if (!this.canSkipTo(item)) continue;
797
- this._resetRequested = false;
798
- this.sendItem(item);
799
- continue;
1010
+ if (this.isRealTime) {
1011
+ await this._runRealTime(iterator, state);
1012
+ } else {
1013
+ await this._runNonRealTime(iterator, state);
1014
+ }
1015
+ } finally {
1016
+ if (state.iteratorDone && this._iterator === iterator) {
1017
+ this._iterator = null;
1018
+ }
1019
+ }
1020
+ }
1021
+ /**
1022
+ * Non-real-time pump — back-pressure through buffered ACKs.
1023
+ *
1024
+ * Direct port of .NET `OnRunNonRealTime` at
1025
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:110-217.
1026
+ */
1027
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1028
+ async _runNonRealTime(iterator, state) {
1029
+ const buffer = new RingBuffer(this.ackAdvance + 1);
1030
+ let bufferStart = 0;
1031
+ let isFullyBuffered = false;
1032
+ while (true) {
1033
+ let ack = this._tryProcessAcks();
1034
+ if (!ack) {
1035
+ await this._waitAckReady();
1036
+ if (this._ended) return;
1037
+ ack = this._tryProcessAcks();
1038
+ if (!ack) {
1039
+ warnLog3?.log("Something is off: couldn't read an acknowledgement");
1040
+ return;
800
1041
  }
801
- if (this._nextIndex < this._lastAckedIndex + this.ackAdvance) {
802
- this.sendItem(item);
1042
+ }
1043
+ this._lastAckedIndex = ack.nextIndex;
1044
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1045
+ if (shift > 0) {
1046
+ buffer.moveHead(shift);
1047
+ bufferStart += shift;
1048
+ }
1049
+ if (this._nextIndex < bufferStart) {
1050
+ if (!this._ended)
1051
+ this.sendEnd(new Error("Stream position unavailable."));
1052
+ return;
1053
+ }
1054
+ let bufferIndex = this._nextIndex - bufferStart;
1055
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1056
+ while (this._nextIndex < maxIndex) {
1057
+ if (this._ended) return;
1058
+ if (bufferIndex < buffer.count) {
1059
+ const item = buffer.get(bufferIndex++);
1060
+ if (item.kind === "value") {
1061
+ this.sendItem(item.value);
1062
+ } else if (item.kind === "end") {
1063
+ if (!this._ended) this.sendEnd();
1064
+ return;
1065
+ } else {
1066
+ if (!this._ended) this.sendEnd(item.error);
1067
+ return;
1068
+ }
803
1069
  continue;
804
1070
  }
805
- if (!this.isRealTime) {
806
- await this._waitForAckBudget();
1071
+ if (isFullyBuffered) break;
1072
+ if (!buffer.hasRemainingCapacity) break;
1073
+ let pulled;
1074
+ try {
1075
+ const r = await iterator.next();
807
1076
  if (this._ended) return;
808
- this.sendItem(item);
809
- continue;
1077
+ if (r.done) {
1078
+ pulled = _endItem;
1079
+ isFullyBuffered = true;
1080
+ state.iteratorDone = true;
1081
+ } else {
1082
+ pulled = { kind: "value", value: r.value };
1083
+ }
1084
+ } catch (e) {
1085
+ state.iteratorDone = true;
1086
+ isFullyBuffered = true;
1087
+ pulled = {
1088
+ kind: "error",
1089
+ error: e instanceof Error ? e : new Error(String(e))
1090
+ };
810
1091
  }
811
- let latestSkipTarget = this.canSkipTo(item) ? item : void 0;
812
- let sourceExhausted = false;
813
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
1092
+ buffer.pushTail(pulled);
1093
+ }
1094
+ }
1095
+ }
1096
+ /**
1097
+ * Real-time pump — drops stale items to stay close to the source's head.
1098
+ *
1099
+ * Direct port of .NET `OnRunRealTime` at
1100
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:219-420.
1101
+ */
1102
+ async _runRealTime(iterator, state) {
1103
+ const buffer = new RingBuffer(this.ackAdvance + 1);
1104
+ let bufferStart = 0;
1105
+ let isFullyBuffered = false;
1106
+ while (true) {
1107
+ let ack = this._tryProcessAcks();
1108
+ if (!ack) {
1109
+ await this._waitAckReady();
1110
+ if (this._ended) return;
1111
+ ack = this._tryProcessAcks();
1112
+ if (!ack) {
1113
+ warnLog3?.log("Something is off: couldn't read an acknowledgement");
1114
+ return;
1115
+ }
1116
+ }
1117
+ this._lastAckedIndex = ack.nextIndex;
1118
+ if (ack.mustReset && !isFullyBuffered) {
1119
+ buffer.clear();
1120
+ bufferStart = this._nextIndex;
1121
+ while (true) {
814
1122
  if (this._ended) return;
815
- const n = await iterator.next();
816
- if (n.done) {
817
- sourceExhausted = true;
818
- break;
819
- }
820
- if (this.canSkipTo(n.value)) {
821
- latestSkipTarget = n.value;
1123
+ let item;
1124
+ let accepted = false;
1125
+ try {
1126
+ const r = await iterator.next();
1127
+ if (this._ended) return;
1128
+ if (r.done) {
1129
+ item = _endItem;
1130
+ isFullyBuffered = true;
1131
+ state.iteratorDone = true;
1132
+ } else if (this.canSkipTo(r.value)) {
1133
+ item = { kind: "value", value: r.value };
1134
+ accepted = true;
1135
+ } else {
1136
+ continue;
1137
+ }
1138
+ } catch (e) {
1139
+ state.iteratorDone = true;
1140
+ isFullyBuffered = true;
1141
+ item = {
1142
+ kind: "error",
1143
+ error: e instanceof Error ? e : new Error(String(e))
1144
+ };
822
1145
  }
1146
+ buffer.pushTail(item);
1147
+ if (accepted || isFullyBuffered) break;
823
1148
  }
1149
+ }
1150
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1151
+ if (shift > 0) {
1152
+ buffer.moveHead(shift);
1153
+ bufferStart += shift;
1154
+ }
1155
+ if (this._nextIndex < bufferStart) {
1156
+ if (!this._ended)
1157
+ this.sendEnd(new Error("Stream position unavailable."));
1158
+ return;
1159
+ }
1160
+ let bufferIndex = this._nextIndex - bufferStart;
1161
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1162
+ while (this._nextIndex < maxIndex) {
824
1163
  if (this._ended) return;
825
- if (latestSkipTarget !== void 0) {
826
- this.sendItem(latestSkipTarget);
1164
+ if (this._acks.length > 0) break;
1165
+ if (bufferIndex < buffer.count) {
1166
+ const item = buffer.get(bufferIndex++);
1167
+ if (item.kind === "value") {
1168
+ this.sendItem(item.value);
1169
+ } else if (item.kind === "end") {
1170
+ if (!this._ended) this.sendEnd();
1171
+ return;
1172
+ } else {
1173
+ if (!this._ended) this.sendEnd(item.error);
1174
+ return;
1175
+ }
1176
+ continue;
827
1177
  }
828
- if (sourceExhausted) {
829
- if (!this._ended) this.sendEnd();
830
- return;
1178
+ if (isFullyBuffered) break;
1179
+ if (!buffer.hasRemainingCapacity) break;
1180
+ let pulled;
1181
+ try {
1182
+ const r = await iterator.next();
1183
+ if (this._ended) return;
1184
+ if (r.done) {
1185
+ pulled = _endItem;
1186
+ isFullyBuffered = true;
1187
+ state.iteratorDone = true;
1188
+ } else {
1189
+ pulled = { kind: "value", value: r.value };
1190
+ }
1191
+ } catch (e) {
1192
+ state.iteratorDone = true;
1193
+ isFullyBuffered = true;
1194
+ pulled = {
1195
+ kind: "error",
1196
+ error: e instanceof Error ? e : new Error(String(e))
1197
+ };
831
1198
  }
1199
+ buffer.pushTail(pulled);
832
1200
  }
833
- if (!this._ended) {
834
- this.sendEnd();
835
- }
836
- } catch (e) {
837
- if (!this._ended) {
838
- this.sendEnd(e instanceof Error ? e : new Error(String(e)));
1201
+ if (this._nextIndex >= maxIndex && !isFullyBuffered && !this._ended) {
1202
+ let latestValue;
1203
+ let haveLatest = false;
1204
+ let exitReason = "ack";
1205
+ let exitError = null;
1206
+ drain: while (!this._ended) {
1207
+ if (this._acks.length > 0) {
1208
+ exitReason = "ack";
1209
+ break drain;
1210
+ }
1211
+ let r;
1212
+ try {
1213
+ r = await iterator.next();
1214
+ } catch (e) {
1215
+ state.iteratorDone = true;
1216
+ isFullyBuffered = true;
1217
+ exitReason = "error";
1218
+ exitError = e instanceof Error ? e : new Error(String(e));
1219
+ break drain;
1220
+ }
1221
+ if (this._ended) return;
1222
+ if (r.done) {
1223
+ state.iteratorDone = true;
1224
+ isFullyBuffered = true;
1225
+ exitReason = "end";
1226
+ break drain;
1227
+ }
1228
+ if (this.canSkipTo(r.value)) {
1229
+ latestValue = r.value;
1230
+ haveLatest = true;
1231
+ }
1232
+ }
1233
+ if (exitReason === "end" || exitReason === "error") {
1234
+ if (haveLatest && !this._ended) this.sendItem(latestValue);
1235
+ if (!this._ended) this.sendEnd(exitError);
1236
+ return;
1237
+ }
1238
+ if (haveLatest) {
1239
+ const wasFull = buffer.isFull;
1240
+ buffer.pushTailAndMoveHeadIfFull({ kind: "value", value: latestValue });
1241
+ if (wasFull) bufferStart++;
1242
+ }
839
1243
  }
840
1244
  }
841
1245
  }
842
- /** Wait until _nextIndex < _lastAckedIndex + ackAdvance. */
843
- async _waitForAckBudget() {
844
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
845
- if (this._ended) return;
846
- this._ackWaiting = new PromiseSource3();
847
- await this._ackWaiting.promise;
1246
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1247
+ /** Drain all queued ACKs, returning the most recent one (or null if none). */
1248
+ _tryProcessAcks() {
1249
+ if (this._acks.length === 0) return null;
1250
+ let last = null;
1251
+ while (this._acks.length > 0) {
1252
+ const a = this._acks.shift();
1253
+ last = a;
1254
+ if (a.mustReset || this._nextIndex < a.nextIndex) {
1255
+ this._nextIndex = a.nextIndex;
1256
+ }
848
1257
  }
1258
+ return last;
1259
+ }
1260
+ /** Wait for the ACK queue to have at least one entry (or `_ended`). */
1261
+ async _waitAckReady() {
1262
+ if (this._acks.length > 0 || this._ended) return;
1263
+ this._whenAckReady = new PromiseSource3();
1264
+ await this._whenAckReady.promise;
1265
+ this._whenAckReady = null;
849
1266
  }
850
1267
  // -- IRpcObject --
851
1268
  reconnect() {
@@ -857,19 +1274,41 @@ var RpcStreamSender = class _RpcStreamSender {
857
1274
  if (!this._started.isCompleted) {
858
1275
  this._started.resolve();
859
1276
  }
860
- if (this._ackWaiting) {
861
- this._ackWaiting.resolve();
862
- this._ackWaiting = null;
1277
+ if (this._whenAckReady) {
1278
+ this._whenAckReady.resolve();
1279
+ this._whenAckReady = null;
863
1280
  }
864
- if (this._iterator) {
865
- void this._iterator.return?.(void 0);
1281
+ this.peer.sharedObjects.unregister(this);
1282
+ const iterator = this._iterator;
1283
+ if (!iterator) return;
1284
+ if (!this.sourceUsesAbortSignal) {
866
1285
  this._iterator = null;
1286
+ this._forceCloseIterator(iterator);
1287
+ return;
1288
+ }
1289
+ setTimeout(() => {
1290
+ if (this._iterator === iterator) {
1291
+ this._iterator = null;
1292
+ this._forceCloseIterator(iterator);
1293
+ }
1294
+ }, RpcStream.disconnectGracePeriodMs);
1295
+ }
1296
+ _forceCloseIterator(iterator) {
1297
+ try {
1298
+ const ret = iterator.return?.(void 0);
1299
+ if (ret) void ret.catch(() => {
1300
+ });
1301
+ } catch {
867
1302
  }
868
- this.peer.sharedObjects.unregister(this);
869
1303
  }
870
1304
  };
1305
+ var _endItem = { kind: "end" };
1306
+ function _clamp(value, min, max) {
1307
+ return value < min ? min : value > max ? max : value;
1308
+ }
871
1309
 
872
1310
  // src/rpc-stream.ts
1311
+ var { warnLog: warnLog4 } = getLogs("RpcStream");
873
1312
  var DEFAULT_ACK_PERIOD2 = 30;
874
1313
  var DEFAULT_ACK_ADVANCE2 = 61;
875
1314
  function parseStreamRef(value) {
@@ -903,6 +1342,15 @@ function parseStreamRef(value) {
903
1342
  return null;
904
1343
  }
905
1344
  var RpcStream = class _RpcStream {
1345
+ /**
1346
+ * Grace period (ms) given to a factory-form local source to honor its
1347
+ * AbortSignal and exit naturally before the sender force-closes the
1348
+ * iterator via `iterator.return()`.
1349
+ *
1350
+ * Only applies to sources provided as `(abortSignal) => AsyncIterable<T>`.
1351
+ * Plain `AsyncIterable<T>` sources are force-closed immediately on disconnect.
1352
+ */
1353
+ static disconnectGracePeriodMs = 100;
906
1354
  id;
907
1355
  kind;
908
1356
  allowReconnect;
@@ -980,17 +1428,19 @@ var RpcStream = class _RpcStream {
980
1428
  throw new Error("toRef() was already called with a different peer.");
981
1429
  return this._toRefValue;
982
1430
  }
1431
+ const isFactory = typeof this.localSource === "function";
983
1432
  const sender = new RpcStreamSender(
984
1433
  peer,
985
1434
  this.ackPeriod,
986
1435
  this.ackAdvance,
987
1436
  this.allowReconnect,
988
1437
  this.isRealTime,
989
- this.canSkipTo
1438
+ this.canSkipTo,
1439
+ isFactory
990
1440
  );
991
1441
  peer.sharedObjects.register(sender);
992
1442
  this._sender = sender;
993
- const source = typeof this.localSource === "function" ? this.localSource(sender.abortSignal) : this.localSource;
1443
+ const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
994
1444
  this._whenSent = sender.writeFrom(source);
995
1445
  this._toRefValue = peer.format.isBinary ? {
996
1446
  SerializedId: [sender.id.hostId, sender.id.localId],
@@ -1004,9 +1454,7 @@ var RpcStream = class _RpcStream {
1004
1454
  onItem(index, item) {
1005
1455
  if (this._disposed || this._completed) return;
1006
1456
  if (index > this._nextExpectedIndex) {
1007
- console.warn(
1008
- `[RpcStream] item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`
1009
- );
1457
+ warnLog4?.log(`item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1010
1458
  if (!this.allowReconnect) {
1011
1459
  this._completed = true;
1012
1460
  this._completionError = new Error(
@@ -1031,9 +1479,7 @@ var RpcStream = class _RpcStream {
1031
1479
  onBatch(index, items) {
1032
1480
  if (this._disposed || this._completed) return;
1033
1481
  if (index > this._nextExpectedIndex) {
1034
- console.warn(
1035
- `[RpcStream] batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`
1036
- );
1482
+ warnLog4?.log(`batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1037
1483
  if (!this.allowReconnect) {
1038
1484
  this._completed = true;
1039
1485
  this._completionError = new Error(
@@ -1200,15 +1646,37 @@ function resolveStreamRefs(value, peer) {
1200
1646
  return value;
1201
1647
  }
1202
1648
 
1649
+ // src/base64.ts
1650
+ function base64Encode(bytes) {
1651
+ const chunkSize = 32768;
1652
+ let binary = "";
1653
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1654
+ binary += String.fromCharCode.apply(
1655
+ null,
1656
+ bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1657
+ );
1658
+ }
1659
+ return btoa(binary);
1660
+ }
1661
+ function base64Decode(encoded) {
1662
+ const binary = atob(encoded);
1663
+ const out = new Uint8Array(binary.length);
1664
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1665
+ return out;
1666
+ }
1667
+
1203
1668
  // src/rpc-system-call-handler.ts
1669
+ var { debugLog, warnLog: warnLog5 } = getLogs("RpcSystemCallHandler");
1204
1670
  var RpcSystemCallHandler = class {
1205
1671
  handle(message, args, peer) {
1206
1672
  const method = message.Method;
1207
1673
  const relatedId = message.RelatedId ?? 0;
1674
+ debugLog?.log(`'${peer.ref}': handle ${method}#${relatedId}`);
1208
1675
  switch (method) {
1209
1676
  case RpcSystemCalls.ok: {
1210
1677
  const call = peer.outbound.get(relatedId);
1211
1678
  if (call !== void 0) {
1679
+ call.completedStage |= RpcCallStage.ResultReady;
1212
1680
  if (call.removeOnOk) {
1213
1681
  peer.outbound.remove(relatedId);
1214
1682
  }
@@ -1216,11 +1684,21 @@ var RpcSystemCallHandler = class {
1216
1684
  }
1217
1685
  break;
1218
1686
  }
1687
+ case RpcSystemCalls.reconnect: {
1688
+ this._handleReconnect(relatedId, args, peer);
1689
+ break;
1690
+ }
1219
1691
  case RpcSystemCalls.error: {
1220
1692
  const call = peer.outbound.remove(relatedId);
1221
1693
  if (call !== void 0) {
1222
1694
  const errorInfo = args[0];
1223
1695
  const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
1696
+ const errorType = errorInfo?.TypeRef ?? errorInfo?.typeRef;
1697
+ if (errorType !== null && typeof errorType === "object") {
1698
+ const t = errorType;
1699
+ if (t.TypeName === "RpcRerouteException" || t.typeName === "RpcRerouteException")
1700
+ warnLog5?.log("Got RpcRerouteException from remote peer:", msg);
1701
+ }
1224
1702
  call.result.reject(new Error(msg));
1225
1703
  }
1226
1704
  break;
@@ -1234,25 +1712,20 @@ var RpcSystemCallHandler = class {
1234
1712
  }
1235
1713
  case RpcSystemCalls.item: {
1236
1714
  const stream = peer.remoteObjects.get(relatedId);
1237
- if (!stream) {
1238
- console.warn(
1239
- `[RpcSysHandler] $sys.I: no stream for relatedId=${relatedId}`
1240
- );
1241
- } else {
1715
+ if (!stream)
1716
+ debugLog?.log(`$sys.I: no stream for relatedId=${relatedId}`);
1717
+ else
1242
1718
  stream.onItem(
1243
1719
  args[0],
1244
1720
  resolveStreamRefs(args[1], peer)
1245
1721
  );
1246
- }
1247
1722
  break;
1248
1723
  }
1249
1724
  case RpcSystemCalls.batch: {
1250
1725
  const stream = peer.remoteObjects.get(relatedId);
1251
- if (!stream) {
1252
- console.warn(
1253
- `[RpcSysHandler] $sys.B: no stream for relatedId=${relatedId}`
1254
- );
1255
- } else if (Array.isArray(args[1])) {
1726
+ if (!stream)
1727
+ debugLog?.log(`$sys.B: no stream for relatedId=${relatedId}`);
1728
+ else if (Array.isArray(args[1])) {
1256
1729
  const items = args[1];
1257
1730
  for (let i = 0; i < items.length; i++)
1258
1731
  items[i] = resolveStreamRefs(items[i], peer);
@@ -1263,9 +1736,7 @@ var RpcSystemCallHandler = class {
1263
1736
  case RpcSystemCalls.end: {
1264
1737
  const stream = peer.remoteObjects.get(relatedId);
1265
1738
  if (!stream) {
1266
- console.warn(
1267
- `[RpcSysHandler] $sys.End: no stream for relatedId=${relatedId}`
1268
- );
1739
+ debugLog?.log(`$sys.End: no stream for relatedId=${relatedId}`);
1269
1740
  } else {
1270
1741
  const errorInfo = args[1];
1271
1742
  const msg = errorInfo?.Message ?? errorInfo?.message;
@@ -1304,9 +1775,57 @@ var RpcSystemCallHandler = class {
1304
1775
  }
1305
1776
  }
1306
1777
  }
1778
+ /**
1779
+ * Server-side handler for the `$sys.Reconnect` call. Parses the peer's
1780
+ * report of in-flight call IDs (grouped by stage, IncreasingSeqCompressor-
1781
+ * encoded, base64-wrapped for JSON transport), computes the subset this
1782
+ * peer does not recognize, and replies with a compressed byte[] of the
1783
+ * unknown IDs wrapped in `$sys.Ok`.
1784
+ *
1785
+ * @param relatedId The outbound call ID the client used for this
1786
+ * `$sys.Reconnect` invocation — we send `$sys.Ok` back
1787
+ * with the same `RelatedId`.
1788
+ */
1789
+ _handleReconnect(relatedId, args, peer) {
1790
+ if (peer.connection === void 0) return;
1791
+ let unknownIds = [];
1792
+ try {
1793
+ const stagesRaw = args[1];
1794
+ if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1795
+ const unknownSet = /* @__PURE__ */ new Set();
1796
+ for (const [, rawValue] of Object.entries(stagesRaw)) {
1797
+ const bytes = _toBytes(rawValue);
1798
+ if (bytes === null) continue;
1799
+ const callIds = IncreasingSeqCompressor.deserialize(bytes);
1800
+ for (const callId of callIds) {
1801
+ if (peer.inbound.get(callId) === void 0) {
1802
+ unknownSet.add(callId);
1803
+ }
1804
+ }
1805
+ }
1806
+ unknownIds = [...unknownSet].sort((a, b) => a - b);
1807
+ }
1808
+ } catch {
1809
+ }
1810
+ const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1811
+ const responseValue = peer.format.isBinary ? responseBytes : base64Encode(responseBytes);
1812
+ peer.hub.systemCallSender.ok(peer.connection, peer.format, relatedId, responseValue);
1813
+ }
1307
1814
  };
1815
+ function _toBytes(value) {
1816
+ if (value instanceof Uint8Array) return value;
1817
+ if (typeof value === "string") {
1818
+ try {
1819
+ return base64Decode(value);
1820
+ } catch {
1821
+ return null;
1822
+ }
1823
+ }
1824
+ return null;
1825
+ }
1308
1826
 
1309
1827
  // src/rpc-system-call-sender.ts
1828
+ var { errorLog } = getLogs("RpcSystemCallSender");
1310
1829
  var RpcSystemCallSender = class _RpcSystemCallSender {
1311
1830
  // Reusable envelopes — one per hot-path method.
1312
1831
  _itemEnv = {
@@ -1363,12 +1882,17 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
1363
1882
  ]);
1364
1883
  }
1365
1884
  ok(conn, format, relatedId, result) {
1366
- this._send(
1367
- conn,
1368
- format,
1369
- { Method: RpcSystemCalls.ok, RelatedId: relatedId },
1370
- [result]
1371
- );
1885
+ try {
1886
+ this._send(
1887
+ conn,
1888
+ format,
1889
+ { Method: RpcSystemCalls.ok, RelatedId: relatedId },
1890
+ [result]
1891
+ );
1892
+ } catch (e) {
1893
+ errorLog?.log(`Failed to send Ok response for call #${relatedId}`, e);
1894
+ this.error(conn, format, relatedId, e);
1895
+ }
1372
1896
  }
1373
1897
  error(conn, format, relatedId, error) {
1374
1898
  const message = error instanceof Error ? error.message : String(error);
@@ -1650,6 +2174,7 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
1650
2174
 
1651
2175
  // src/rpc-peer.ts
1652
2176
  var RPC_CLOSE_CODE_UNSUPPORTED_FORMAT = 4001;
2177
+ var { debugLog: debugLog2, infoLog, warnLog: warnLog6, errorLog: errorLog2 } = getLogs("RpcPeer");
1653
2178
  var defaultConnectionUrlResolver = (peer) => {
1654
2179
  const sep = peer.ref.includes("?") ? "&" : "?";
1655
2180
  return peer.ref + sep + `clientId=${peer.clientId}&f=${peer.serializationFormat}`;
@@ -1688,16 +2213,29 @@ var RpcPeer = class {
1688
2213
  conn.closed.add((ev) => {
1689
2214
  this._connection = void 0;
1690
2215
  this._stopKeepAlive();
2216
+ if (ev.reason)
2217
+ infoLog?.log(`'${this.ref}': Disconnected: ${ev.reason} (code=${ev.code})`);
2218
+ else
2219
+ infoLog?.log(`'${this.ref}': Disconnected`);
1691
2220
  this.disconnected.trigger(ev);
1692
2221
  });
1693
2222
  void conn.whenConnected.then(() => {
2223
+ infoLog?.log(`'${this.ref}': Connected`);
1694
2224
  this.connected.trigger();
1695
2225
  this._startKeepAlive();
1696
2226
  });
1697
2227
  }
1698
- /** The serialization format used by this peer. Override in subclasses. */
2228
+ /**
2229
+ * The serialization format used by this peer for outgoing messages.
2230
+ * If not set explicitly, defaults to the resolver's default (JSON /
2231
+ * json5np). Subclasses can pre-assign in their constructor.
2232
+ */
2233
+ _format;
1699
2234
  get format() {
1700
- return RpcSerializationFormatResolver.Default.defaultFormat;
2235
+ return this._format ?? RpcSerializationFormatResolver.Default.defaultFormat;
2236
+ }
2237
+ set format(value) {
2238
+ this._format = value;
1701
2239
  }
1702
2240
  call(method, args, options) {
1703
2241
  const callId = this.outbound.nextId();
@@ -1757,6 +2295,7 @@ var RpcPeer = class {
1757
2295
  this._sendWireData(wireData);
1758
2296
  }
1759
2297
  close() {
2298
+ infoLog?.log(`'${this.ref}': Stopping`);
1760
2299
  this._stopKeepAlive();
1761
2300
  this.remoteObjects.disconnectAll();
1762
2301
  this.sharedObjects.disconnectAll();
@@ -1770,6 +2309,7 @@ var RpcPeer = class {
1770
2309
  this.outbound.invalidateAll();
1771
2310
  this._connection?.close();
1772
2311
  this._hub.peers.delete(this.ref);
2312
+ infoLog?.log(`'${this.ref}': Stopped`);
1773
2313
  }
1774
2314
  /** Send any messages buffered while disconnected. Call after connection + handshake are ready. */
1775
2315
  _flushPendingSends() {
@@ -1796,18 +2336,32 @@ var RpcPeer = class {
1796
2336
  _handleMessage(received) {
1797
2337
  let message;
1798
2338
  let args;
1799
- if (received.kind === "binary") {
1800
- message = received.message;
1801
- args = received.args;
1802
- } else {
1803
- const parsed = deserializeMessage(received.raw);
1804
- message = parsed.message;
1805
- args = parsed.args;
2339
+ try {
2340
+ if (received.kind === "binary") {
2341
+ message = received.message;
2342
+ args = received.args;
2343
+ } else {
2344
+ const parsed = deserializeMessage(received.raw);
2345
+ message = parsed.message;
2346
+ args = parsed.args;
2347
+ }
2348
+ } catch (e) {
2349
+ errorLog2?.log(`'${this.ref}': Failed to process inbound message:`, received, e);
2350
+ return;
1806
2351
  }
1807
2352
  const method = message.Method ?? "";
2353
+ debugLog2?.log(`'${this.ref}': <- ${method}#${message.RelatedId ?? 0}`);
1808
2354
  if (method === RpcSystemCalls.handshake) {
1809
- const handshake = args[0];
1810
- if (handshake !== void 0) this._onHandshakeReceived(handshake);
2355
+ const raw = args[0];
2356
+ if (raw !== void 0) {
2357
+ const handshake = Array.isArray(raw) ? {
2358
+ RemotePeerId: raw[0],
2359
+ RemoteHubId: raw[2],
2360
+ ProtocolVersion: raw[3],
2361
+ Index: raw[4]
2362
+ } : raw;
2363
+ this._onHandshakeReceived(handshake);
2364
+ }
1811
2365
  return;
1812
2366
  }
1813
2367
  if (method.startsWith("$sys")) {
@@ -1863,6 +2417,7 @@ var RpcPeer = class {
1863
2417
  e
1864
2418
  );
1865
2419
  }
2420
+ debugLog2?.log(`'${this.ref}': Inbound call failed: ${method}#${relatedId}`, e);
1866
2421
  } finally {
1867
2422
  if (!isNoWait) {
1868
2423
  this.inbound.remove(relatedId);
@@ -1897,6 +2452,12 @@ var RpcClientPeer = class extends RpcPeer {
1897
2452
  _connectionKind = 0 /* Disconnected */;
1898
2453
  _tryIndex = 0;
1899
2454
  _handshakeIndex = 0;
2455
+ /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
2456
+ * to the server in `$sys.Reconnect:3` so the server can verify we're
2457
+ * talking about the current connection. Mirrors .NET's
2458
+ * `connectionState.Handshake.Index` path in
2459
+ * `RpcOutboundCallTracker.TryReconnect`. */
2460
+ _remoteHandshakeIndex = 0;
1900
2461
  _lastRemoteHubId;
1901
2462
  _pendingHandshake;
1902
2463
  _reconnectsAt = 0;
@@ -1923,32 +2484,34 @@ var RpcClientPeer = class extends RpcPeer {
1923
2484
  this._reconnectsAt = value;
1924
2485
  this.reconnectsAtChanged.trigger();
1925
2486
  }
1926
- _format;
1927
2487
  constructor(hub, url, serializationFormat) {
1928
2488
  super(url, hub);
1929
2489
  this.clientId = guidToBase64Url(this.id);
1930
2490
  const resolver = RpcSerializationFormatResolver.Default;
1931
2491
  this.serializationFormat = serializationFormat ?? resolver.defaultFormatKey;
1932
- this._format = resolver.get(this.serializationFormat);
1933
- }
1934
- get format() {
1935
- return this._format;
2492
+ this.format = resolver.get(this.serializationFormat);
1936
2493
  }
1937
2494
  get connectionKind() {
1938
2495
  return this._connectionKind;
1939
2496
  }
1940
- /** One-shot connection for tests — no reconnection loop, no handshake. */
1941
- connectWith(conn) {
2497
+ /** One-shot connection for tests — no reconnection loop, no handshake.
2498
+ * @param isPeerChanged Whether to treat this as a peer-identity change
2499
+ * (default `true` — no handshake was exchanged, so stage-3 compute calls
2500
+ * self-invalidate and remote/shared objects are disposed). Pass `false`
2501
+ * to simulate a same-peer reconnect in tests. */
2502
+ connectWith(conn, isPeerChanged = true) {
1942
2503
  this.setupConnection(conn);
1943
- this._reconnect(true);
2504
+ void this._reconnect(isPeerChanged);
1944
2505
  }
1945
2506
  /** Start the reconnection loop — runs until disposed. */
1946
2507
  async run(wsFactory) {
1947
2508
  const connUrl = this.connectionUrlResolver(this);
2509
+ infoLog?.log(`'${this.ref}': Started (Client)`);
1948
2510
  while (!this._disposed) {
1949
2511
  let lastCloseCode = 0;
1950
2512
  let lastCloseReason = "";
1951
2513
  try {
2514
+ infoLog?.log(`'${this.ref}': Connecting to ${connUrl}`);
1952
2515
  const ws = wsFactory?.(connUrl) ?? new WebSocket(connUrl);
1953
2516
  const conn = new RpcWebSocketConnection(
1954
2517
  ws,
@@ -1972,38 +2535,48 @@ var RpcClientPeer = class extends RpcPeer {
1972
2535
  closedRejection.catch(() => {
1973
2536
  });
1974
2537
  await Promise.race([conn.whenConnected, closedRejection]);
1975
- this._hub.systemCallSender.handshake(
1976
- conn,
1977
- this.format,
1978
- this.id,
1979
- this._hub.hubId,
1980
- ++this._handshakeIndex
1981
- );
2538
+ try {
2539
+ this._hub.systemCallSender.handshake(
2540
+ conn,
2541
+ this.format,
2542
+ this.id,
2543
+ this._hub.hubId,
2544
+ ++this._handshakeIndex
2545
+ );
2546
+ } catch (e) {
2547
+ warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
2548
+ throw e;
2549
+ }
1982
2550
  const remoteHandshake = await Promise.race([
1983
2551
  this._pendingHandshake.promise,
1984
2552
  closedRejection
1985
2553
  ]);
1986
2554
  this._pendingHandshake = void 0;
2555
+ this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
1987
2556
  const remoteHubId = remoteHandshake.RemoteHubId;
1988
2557
  let isPeerChanged = false;
1989
2558
  if (remoteHubId !== void 0) {
1990
2559
  isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
1991
2560
  if (isPeerChanged) {
1992
2561
  this.inbound.clear();
2562
+ infoLog?.log(`'${this.ref}': Peer changed`);
1993
2563
  this.peerChanged.trigger();
1994
2564
  }
1995
2565
  this._lastRemoteHubId = remoteHubId;
1996
2566
  }
2567
+ infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
1997
2568
  this._connection = conn;
1998
2569
  this._connectionKind = 2 /* Connected */;
1999
2570
  this._tryIndex = 0;
2000
- this._reconnect(isPeerChanged);
2571
+ await this._reconnect(isPeerChanged);
2001
2572
  await new Promise((r) => conn.closed.add(() => r()));
2002
- } catch {
2573
+ } catch (e) {
2574
+ debugLog2?.log(`'${this.ref}': run() iteration failed:`, e);
2003
2575
  }
2004
2576
  this._connectionKind = 0 /* Disconnected */;
2005
2577
  if (this._disposed) break;
2006
2578
  if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
2579
+ warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
2007
2580
  this.unsupportedFormat.trigger({ reason: lastCloseReason });
2008
2581
  this._disposed = true;
2009
2582
  break;
@@ -2011,6 +2584,7 @@ var RpcClientPeer = class extends RpcPeer {
2011
2584
  this._tryIndex++;
2012
2585
  const delay = this.reconnectDelayer.getDelay(this._tryIndex);
2013
2586
  if (delay.isLimitExceeded) {
2587
+ warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2014
2588
  this._disposed = true;
2015
2589
  break;
2016
2590
  }
@@ -2022,42 +2596,129 @@ var RpcClientPeer = class extends RpcPeer {
2022
2596
  }
2023
2597
  }
2024
2598
  }
2025
- /** Re-send outbound calls after reconnection + flush pending sends.
2026
- * Stage-3 compute calls are always self-invalidated: without $sys.Reconnect
2027
- * protocol, the server's invalidation tracking is lost on disconnect, and
2028
- * re-sending would get a duplicate $sys.Ok that is ignored (PromiseSource
2029
- * already resolved). Self-invalidation forces a fresh recompute that
2030
- * establishes new invalidation tracking on the new connection.
2031
- * Regular in-flight calls are re-sent transparently. */
2032
- _reconnect(_isPeerChanged) {
2599
+ /**
2600
+ * Reconcile outbound calls after reconnection, then flush pending sends.
2601
+ *
2602
+ * On peer change (different server hubId) or with binary wire format —
2603
+ * we blind-resend every eligible call.
2604
+ *
2605
+ * On a same-peer reconnect with JSON transport, first call
2606
+ * `$sys.Reconnect` to ask the server which call IDs it no longer
2607
+ * recognizes (e.g. inbound tracker was cleared, or the call never
2608
+ * reached the server). Only those get resent; the rest stay in the
2609
+ * tracker awaiting their $sys.Ok. This prevents duplicate server-side
2610
+ * invocation of in-flight streaming calls on every transient
2611
+ * reconnect — mirrors .NET `RpcOutboundCallTracker.Reconnect` at
2612
+ * src/ActualLab.Rpc/Infrastructure/RpcCallTrackers.cs:209-279.
2613
+ *
2614
+ * Stage-3 compute calls (result already resolved, awaiting invalidation)
2615
+ * are always self-invalidated — TS doesn't track compute-call stages
2616
+ * across reconnect, so the safest behavior is to force a fresh compute.
2617
+ */
2618
+ async _reconnect(isPeerChanged) {
2033
2619
  if (this._connection === void 0) return;
2034
- if (_isPeerChanged) {
2620
+ if (isPeerChanged) {
2035
2621
  this.remoteObjects.disconnectAll();
2622
+ this.sharedObjects.disconnectAll();
2036
2623
  } else {
2037
2624
  this.remoteObjects.reconnectAll();
2038
2625
  }
2039
- const trackerCalls = [...this.outbound.values()];
2040
- for (const call of trackerCalls) {
2626
+ const eligible = [];
2627
+ for (const call of [...this.outbound.values()]) {
2041
2628
  const mode = call.remoteExecutionMode;
2042
2629
  if (!(mode & 2)) {
2043
2630
  if (!call.result.isCompleted)
2044
- call.result.reject(new Error("Outbound call failed: disconnected and AllowReconnect is not set."));
2631
+ call.result.reject(new Error(
2632
+ "Outbound call failed: disconnected and AllowReconnect is not set."
2633
+ ));
2045
2634
  call.onDisconnect();
2046
2635
  this.outbound.remove(call.callId);
2047
- } else if (_isPeerChanged && !(mode & 4)) {
2636
+ continue;
2637
+ }
2638
+ if (isPeerChanged && !(mode & 4)) {
2048
2639
  if (!call.result.isCompleted)
2049
- call.result.reject(new Error("Outbound call failed: reconnected to a different peer and AllowResend is not set."));
2640
+ call.result.reject(new Error(
2641
+ "Outbound call failed: reconnected to a different peer and AllowResend is not set."
2642
+ ));
2050
2643
  call.onDisconnect();
2051
2644
  this.outbound.remove(call.callId);
2052
- } else if (!call.removeOnOk && call.result.isCompleted) {
2645
+ continue;
2646
+ }
2647
+ eligible.push(call);
2648
+ }
2649
+ let unknownIds = null;
2650
+ if (!isPeerChanged && eligible.length > 0) {
2651
+ unknownIds = await this._reconcileReconnect(eligible);
2652
+ }
2653
+ for (const call of eligible) {
2654
+ if (!call.removeOnOk && call.result.isCompleted) {
2053
2655
  call.onDisconnect();
2054
2656
  this.outbound.remove(call.callId);
2055
- } else {
2056
- this._sendWireData(call.serializedWireData);
2657
+ continue;
2658
+ }
2659
+ if (unknownIds !== null && !unknownIds.has(call.callId)) {
2660
+ continue;
2057
2661
  }
2662
+ this._sendWireData(call.serializedWireData);
2058
2663
  }
2059
2664
  this._flushPendingSends();
2060
2665
  }
2666
+ /**
2667
+ * Ask the server which call IDs in `eligible` it no longer recognizes
2668
+ * via `$sys.Reconnect:3`. Returns the set of call IDs that need to be
2669
+ * resent. On any failure, falls back to "resend everything".
2670
+ *
2671
+ * Wire format of the `completedStages` argument is chosen per wire format:
2672
+ * - JSON (json5np): plain object `{"<stage>": "<base64-of-bytes>"}` —
2673
+ * matches .NET `System.Text.Json`'s default for `Dictionary<int, byte[]>`.
2674
+ * - MessagePack (msgpack6/msgpack6c): `Map<number, Uint8Array>` —
2675
+ * the patched encoder (see `./msgpack-map-patch.ts`) writes this as a
2676
+ * native msgpack `map<int, bin>`, matching .NET MessagePack-CSharp's
2677
+ * default for `Dictionary<int, byte[]>`.
2678
+ */
2679
+ async _reconcileReconnect(eligible) {
2680
+ try {
2681
+ const byStage = /* @__PURE__ */ new Map();
2682
+ for (const call of eligible) {
2683
+ const stage = call.getReconnectStage(false);
2684
+ if (stage === null) continue;
2685
+ let arr = byStage.get(stage);
2686
+ if (!arr) {
2687
+ arr = [];
2688
+ byStage.set(stage, arr);
2689
+ }
2690
+ arr.push(call.callId);
2691
+ }
2692
+ if (byStage.size === 0) {
2693
+ return new Set(eligible.map((c) => c.callId));
2694
+ }
2695
+ const isBinary = this.format.isBinary;
2696
+ const stagesArg = isBinary ? /* @__PURE__ */ new Map() : {};
2697
+ for (const [stage, ids] of byStage) {
2698
+ ids.sort((a, b) => a - b);
2699
+ const bytes2 = IncreasingSeqCompressor.serialize(ids);
2700
+ if (isBinary) {
2701
+ stagesArg.set(stage, bytes2);
2702
+ } else {
2703
+ stagesArg[String(stage)] = base64Encode(bytes2);
2704
+ }
2705
+ }
2706
+ const reconnectCall = this.call(
2707
+ RpcSystemCalls.reconnect,
2708
+ [this._remoteHandshakeIndex, stagesArg],
2709
+ {
2710
+ remoteExecutionMode: 1
2711
+ /* AwaitForConnection only; must not itself reconnect */
2712
+ }
2713
+ );
2714
+ const result = await reconnectCall.result.promise;
2715
+ const bytes = _toBytes2(result);
2716
+ if (bytes === null) return new Set(eligible.map((c) => c.callId));
2717
+ return new Set(IncreasingSeqCompressor.deserialize(bytes));
2718
+ } catch {
2719
+ return new Set(eligible.map((c) => c.callId));
2720
+ }
2721
+ }
2061
2722
  _onHandshakeReceived(handshake) {
2062
2723
  this._pendingHandshake?.resolve(handshake);
2063
2724
  }
@@ -2105,8 +2766,20 @@ function guidToBase64Url(uuid) {
2105
2766
  const binary = String.fromCharCode(...bytes);
2106
2767
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2107
2768
  }
2769
+ function _toBytes2(value) {
2770
+ if (value instanceof Uint8Array) return value;
2771
+ if (typeof value === "string") {
2772
+ try {
2773
+ return base64Decode(value);
2774
+ } catch {
2775
+ return null;
2776
+ }
2777
+ }
2778
+ return null;
2779
+ }
2108
2780
 
2109
2781
  // src/rpc-service-host.ts
2782
+ var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
2110
2783
  var RpcServiceHost = class {
2111
2784
  _methods = /* @__PURE__ */ new Map();
2112
2785
  register(def, impl) {
@@ -2121,8 +2794,10 @@ var RpcServiceHost = class {
2121
2794
  }
2122
2795
  async dispatch(wireMethod, args, context) {
2123
2796
  const entry = this._methods.get(wireMethod);
2124
- if (entry === void 0)
2797
+ if (entry === void 0) {
2798
+ warnLog7?.log(`Method not found: ${wireMethod}`);
2125
2799
  throw new Error(`Method not found: ${wireMethod}`);
2800
+ }
2126
2801
  if (context !== void 0 && entry.def.callTypeId !== 0) {
2127
2802
  return await entry.fn(...args, context);
2128
2803
  }
@@ -2652,6 +3327,7 @@ function computeMethodHash(methodName) {
2652
3327
  }
2653
3328
 
2654
3329
  // src/rpc-method-registry.ts
3330
+ var { warnLog: warnLog8 } = getLogs("RpcMethodRegistry");
2655
3331
  var RpcMethodRegistry = class {
2656
3332
  _nameToHash = /* @__PURE__ */ new Map();
2657
3333
  _hashToName = /* @__PURE__ */ new Map();
@@ -2661,9 +3337,7 @@ var RpcMethodRegistry = class {
2661
3337
  const hash = computeMethodHash(methodName);
2662
3338
  const existing = this._hashToName.get(hash);
2663
3339
  if (existing !== void 0 && existing !== methodName) {
2664
- console.warn(
2665
- `[RpcMethodRegistry] hash collision: "${methodName}" and "${existing}" both hash to ${hash}`
2666
- );
3340
+ warnLog8?.log(`hash collision: "${methodName}" and "${existing}" both hash to ${hash}`);
2667
3341
  return;
2668
3342
  }
2669
3343
  this._nameToHash.set(methodName, hash);
@@ -2696,6 +3370,7 @@ var RpcMethodRegistry = class {
2696
3370
  };
2697
3371
 
2698
3372
  // src/rpc-hub.ts
3373
+ var { warnLog: warnLog9 } = getLogs("RpcHub");
2699
3374
  var RpcHub = class {
2700
3375
  hubId;
2701
3376
  peers = /* @__PURE__ */ new Map();
@@ -2721,6 +3396,7 @@ var RpcHub = class {
2721
3396
  if (peer !== void 0) {
2722
3397
  peer.close();
2723
3398
  this.peers.delete(ref);
3399
+ warnLog9?.log(`'${ref}': peer is removed from RpcHub`);
2724
3400
  }
2725
3401
  }
2726
3402
  /** Get or create a peer by ref. Server peers have "server://" prefix, everything else is a client peer. */
@@ -2953,6 +3629,7 @@ function getStateDescription(state, useLastError = false) {
2953
3629
 
2954
3630
  // src/rpc-peer-state-monitor.ts
2955
3631
  import { EventHandlerSet as EventHandlerSet4 } from "@actuallab/core";
3632
+ var { infoLog: infoLog2 } = getLogs("RpcPeerStateMonitor");
2956
3633
  var JustConnectedPeriodMs = 1500;
2957
3634
  var JustDisconnectedPeriodMs = 3e3;
2958
3635
  var MinReconnectsInMs = 1e3;
@@ -3014,6 +3691,7 @@ var RpcPeerStateMonitor = class {
3014
3691
  this._scheduleRecompute(
3015
3692
  this._rawConnected ? JustConnectedPeriodMs : JustDisconnectedPeriodMs
3016
3693
  );
3694
+ infoLog2?.log(`'${peer.ref}': monitor started`);
3017
3695
  }
3018
3696
  get state() {
3019
3697
  return this._state;
@@ -3029,6 +3707,7 @@ var RpcPeerStateMonitor = class {
3029
3707
  this.peer.reconnectDelayer.cancelDelaysChanged.remove(
3030
3708
  this._onCancelDelaysChanged
3031
3709
  );
3710
+ infoLog2?.log(`'${this.peer.ref}': monitor stopped`);
3032
3711
  }
3033
3712
  _recompute() {
3034
3713
  if (this._timer !== void 0) {
@@ -3107,7 +3786,9 @@ export {
3107
3786
  ARG_DELIMITER,
3108
3787
  ENVELOPE_DELIMITER,
3109
3788
  FRAME_DELIMITER,
3789
+ IncreasingSeqCompressor,
3110
3790
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
3791
+ RpcCallStage,
3111
3792
  RpcCallTypeId,
3112
3793
  RpcClientPeer,
3113
3794
  RpcClientPeerReconnectDelayer,
@@ -3152,6 +3833,7 @@ export {
3152
3833
  deserializeBinaryMessage,
3153
3834
  deserializeCompactBinaryMessage,
3154
3835
  deserializeMessage,
3836
+ getLogs,
3155
3837
  getMethodsMeta,
3156
3838
  getServiceMeta,
3157
3839
  getStateDescription,