@actuallab/rpc 12.3.6 → 12.3.29

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.cjs CHANGED
@@ -33,7 +33,9 @@ __export(index_exports, {
33
33
  ARG_DELIMITER: () => ARG_DELIMITER,
34
34
  ENVELOPE_DELIMITER: () => ENVELOPE_DELIMITER,
35
35
  FRAME_DELIMITER: () => FRAME_DELIMITER,
36
+ IncreasingSeqCompressor: () => IncreasingSeqCompressor,
36
37
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT: () => RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
38
+ RpcCallStage: () => RpcCallStage,
37
39
  RpcCallTypeId: () => RpcCallTypeId,
38
40
  RpcClientPeer: () => RpcClientPeer,
39
41
  RpcClientPeerReconnectDelayer: () => RpcClientPeerReconnectDelayer,
@@ -52,6 +54,7 @@ __export(index_exports, {
52
54
  RpcPeerConnectionKind: () => RpcPeerConnectionKind,
53
55
  RpcPeerStateKind: () => RpcPeerStateKind,
54
56
  RpcPeerStateMonitor: () => RpcPeerStateMonitor,
57
+ RpcRemoteExecutionMode: () => RpcRemoteExecutionMode,
55
58
  RpcRemoteObjectTracker: () => RpcRemoteObjectTracker,
56
59
  RpcSerializationFormat: () => RpcSerializationFormat,
57
60
  RpcSerializationFormatResolver: () => RpcSerializationFormatResolver,
@@ -124,7 +127,43 @@ var ARG_DELIMITER = "";
124
127
  var FRAME_DELIMITER = "\n";
125
128
 
126
129
  // src/rpc-serialization.ts
130
+ var import_msgpack2 = require("@msgpack/msgpack");
131
+
132
+ // src/msgpack-map-patch.ts
127
133
  var import_msgpack = require("@msgpack/msgpack");
134
+ function patchMsgpackEncoderForMaps() {
135
+ const proto = import_msgpack.Encoder.prototype;
136
+ if (proto._hasMapPatch) return;
137
+ proto._hasMapPatch = true;
138
+ const originalDoEncode = proto.doEncode;
139
+ proto.doEncode = function(object, depth) {
140
+ if (object instanceof Map) {
141
+ _encodeMapInstance.call(this, object, depth);
142
+ return;
143
+ }
144
+ originalDoEncode.call(this, object, depth);
145
+ };
146
+ function _encodeMapInstance(m, depth) {
147
+ const self = this;
148
+ const size = m.size;
149
+ if (size < 16) {
150
+ self.writeU8(128 + size);
151
+ } else if (size < 65536) {
152
+ self.writeU8(222);
153
+ self.writeU16(size);
154
+ } else {
155
+ self.writeU8(223);
156
+ self.writeU32(size);
157
+ }
158
+ for (const [key, value] of m) {
159
+ self.doEncode(key, depth + 1);
160
+ self.doEncode(value, depth + 1);
161
+ }
162
+ }
163
+ }
164
+ patchMsgpackEncoderForMaps();
165
+
166
+ // src/rpc-serialization.ts
128
167
  function serializeMessage(message, args) {
129
168
  const envelope = JSON.stringify(message);
130
169
  if (args === void 0 || args.length === 0) {
@@ -165,15 +204,15 @@ function deserializeMessage(raw) {
165
204
  var textEncoder = new TextEncoder();
166
205
  var textDecoder = new TextDecoder();
167
206
  var INITIAL_ENCODER_BUFFER_SIZE = 32 * 1024;
168
- var defaultBinaryEncoder = new import_msgpack.Encoder(
207
+ var defaultBinaryEncoder = new import_msgpack2.Encoder(
169
208
  void 0,
170
209
  void 0,
171
210
  void 0,
172
211
  INITIAL_ENCODER_BUFFER_SIZE
173
212
  );
174
- var defaultBinaryDecoder = new import_msgpack.Decoder();
213
+ var defaultBinaryDecoder = new import_msgpack2.Decoder();
175
214
  function createBinaryEncoder() {
176
- return new import_msgpack.Encoder(
215
+ return new import_msgpack2.Encoder(
177
216
  void 0,
178
217
  void 0,
179
218
  void 0,
@@ -410,7 +449,7 @@ function splitCompactBinaryFrame(frame, registry, decoder = defaultBinaryDecoder
410
449
  }
411
450
 
412
451
  // src/rpc-connection.ts
413
- var import_msgpack2 = require("@msgpack/msgpack");
452
+ var import_msgpack3 = require("@msgpack/msgpack");
414
453
  var import_core = require("@actuallab/core");
415
454
  var WebSocketState = {
416
455
  CONNECTING: 0,
@@ -429,7 +468,7 @@ var RpcWebSocketConnection = class {
429
468
  _encoder = createBinaryEncoder();
430
469
  /** Per-connection msgpack decoder — reused across inbound messages
431
470
  * rather than reconstructing one for every `decodeMulti` call. */
432
- _decoder = new import_msgpack2.Decoder();
471
+ _decoder = new import_msgpack3.Decoder();
433
472
  /** Exposed for callers who need to encode outbound binary messages
434
473
  * through this connection (e.g. `serializeBinaryMessage`). Safe to
435
474
  * pass to synchronous encode calls on the same event-loop turn. */
@@ -614,6 +653,18 @@ function createMessageChannelPair() {
614
653
  }
615
654
 
616
655
  // src/rpc-service-def.ts
656
+ var RpcRemoteExecutionMode = {
657
+ /** Wait for connection if disconnected when the call is made. */
658
+ AwaitForConnection: 1,
659
+ /** Resend on reconnection to the same peer. */
660
+ AllowReconnect: 2,
661
+ /** Resend on reconnection to a different peer (implies AllowReconnect). */
662
+ AllowResend: 6,
663
+ // 4 | AllowReconnect
664
+ /** Default: wait + reconnect + resend. */
665
+ Default: 7
666
+ // AwaitForConnection | AllowResend
667
+ };
617
668
  var RpcType = {
618
669
  object: /* @__PURE__ */ Symbol("object"),
619
670
  stream: /* @__PURE__ */ Symbol("stream"),
@@ -630,6 +681,7 @@ function defineRpcService(name, methods) {
630
681
  const cleanName = colonIndex >= 0 ? key.substring(0, colonIndex) : key;
631
682
  const wireArgCount = input.wireArgCount ?? input.args.length + 1;
632
683
  const mapKey = `${cleanName}:${wireArgCount}`;
684
+ const isNoWait = input.returns === RpcType.noWait;
633
685
  map.set(mapKey, {
634
686
  name: cleanName,
635
687
  serviceName: name,
@@ -637,7 +689,8 @@ function defineRpcService(name, methods) {
637
689
  wireArgCount,
638
690
  callTypeId: input.callTypeId ?? 0,
639
691
  stream: input.returns === RpcType.stream,
640
- noWait: input.returns === RpcType.noWait
692
+ noWait: isNoWait,
693
+ remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
641
694
  });
642
695
  }
643
696
  return { name, methods: map };
@@ -645,18 +698,60 @@ function defineRpcService(name, methods) {
645
698
 
646
699
  // src/rpc-call-tracker.ts
647
700
  var import_core3 = require("@actuallab/core");
701
+
702
+ // src/rpc-call-stage.ts
703
+ var RpcCallStage = {
704
+ /** The call's result has been received from the peer. */
705
+ ResultReady: 1,
706
+ /** The compute call's result has been invalidated (compute-call specific). */
707
+ Invalidated: 3,
708
+ /**
709
+ * Bit flag added on top of ResultReady/Invalidated when the outbound
710
+ * call has been removed from the tracker (unregistered).
711
+ */
712
+ Unregistered: 4096
713
+ };
714
+
715
+ // src/rpc-call-tracker.ts
648
716
  var RpcOutboundCall = class {
649
717
  callId;
650
718
  method;
651
719
  result = new import_core3.PromiseSource();
720
+ /** Bitfield of RpcRemoteExecutionMode flags controlling reconnect/resend behavior. */
721
+ remoteExecutionMode;
652
722
  /** Whether to remove this call from the tracker on $sys.Ok. Default: true.
653
723
  * Subclasses (e.g. compute calls) override to false to stay in tracker for invalidation. */
654
724
  removeOnOk = true;
655
725
  /** The serialized wire data — stored for re-sending on reconnect. */
656
726
  serializedWireData = "";
657
- constructor(callId, method) {
727
+ /**
728
+ * Bitfield of {@link RpcCallStage} flags indicating how far this call
729
+ * has progressed. Used by the `$sys.Reconnect` protocol to tell the
730
+ * remote peer which calls are still pending its attention.
731
+ *
732
+ * - 0: brand new, no result yet (the peer is expected to be actively processing).
733
+ * - `ResultReady` (1): `$sys.Ok` received for this call.
734
+ * - `Invalidated` (3): result was invalidated (compute calls only).
735
+ * - `Unregistered` (0x1000): removed from the tracker.
736
+ */
737
+ completedStage = 0;
738
+ constructor(callId, method, remoteExecutionMode = 7) {
658
739
  this.callId = callId;
659
740
  this.method = method;
741
+ this.remoteExecutionMode = remoteExecutionMode;
742
+ }
743
+ /**
744
+ * Returns the stage to report to the remote peer via `$sys.Reconnect`,
745
+ * or `null` if this call should not be reconciled and must be aborted.
746
+ *
747
+ * Mirrors .NET `RpcOutboundCall.GetReconnectStage` at
748
+ * src/ActualLab.Rpc/Infrastructure/RpcOutboundCall.cs.
749
+ */
750
+ getReconnectStage(isPeerChanged) {
751
+ const mode = this.remoteExecutionMode;
752
+ if (!(mode & RpcRemoteExecutionMode.AllowReconnect)) return null;
753
+ if (isPeerChanged && !(mode & RpcRemoteExecutionMode.AllowResend)) return null;
754
+ return this.completedStage;
660
755
  }
661
756
  /** Called when the connection is lost. Subclasses can override to resolve invalidation promises. */
662
757
  onDisconnect() {
@@ -679,7 +774,10 @@ var RpcOutboundCallTracker = class {
679
774
  }
680
775
  remove(callId) {
681
776
  const call = this._calls.get(callId);
682
- if (call !== void 0) this._calls.delete(callId);
777
+ if (call !== void 0) {
778
+ this._calls.delete(callId);
779
+ call.completedStage |= RpcCallStage.Unregistered;
780
+ }
683
781
  return call;
684
782
  }
685
783
  values() {
@@ -742,6 +840,97 @@ var RpcInboundCallTracker = class {
742
840
  }
743
841
  };
744
842
 
843
+ // src/increasing-seq-compressor.ts
844
+ var IncreasingSeqCompressor = {
845
+ /**
846
+ * Serialize a non-decreasing sequence of non-negative integers as
847
+ * LEB128-encoded deltas.
848
+ *
849
+ * Throws `RangeError` if the input is not monotonically non-decreasing,
850
+ * or if a value exceeds `Number.MAX_SAFE_INTEGER`.
851
+ */
852
+ serialize(values) {
853
+ const deltas = [];
854
+ let last = 0;
855
+ let totalBytes = 0;
856
+ for (const value of values) {
857
+ if (!Number.isSafeInteger(value) || value < 0)
858
+ throw new RangeError(
859
+ `IncreasingSeqCompressor: value must be a non-negative safe integer (got ${value}).`
860
+ );
861
+ const delta = value - last;
862
+ if (delta < 0)
863
+ throw new RangeError(
864
+ `IncreasingSeqCompressor: sequence must be non-decreasing (saw ${value} after ${last}).`
865
+ );
866
+ deltas.push(delta);
867
+ last = value;
868
+ totalBytes += _varUintByteLength(delta);
869
+ }
870
+ const out = new Uint8Array(totalBytes);
871
+ let offset = 0;
872
+ for (const delta of deltas) {
873
+ offset = _writeVarUint(out, delta, offset);
874
+ }
875
+ return out;
876
+ },
877
+ /**
878
+ * Decode bytes produced by {@link serialize} back into the original
879
+ * sorted sequence of non-negative integers.
880
+ */
881
+ deserialize(data) {
882
+ const result = [];
883
+ let last = 0;
884
+ let offset = 0;
885
+ while (offset < data.length) {
886
+ const [delta, next] = _readVarUint(data, offset);
887
+ offset = next;
888
+ last += delta;
889
+ result.push(last);
890
+ }
891
+ return result;
892
+ }
893
+ };
894
+ var LOW_BITS = 127;
895
+ var HIGH_BIT = 128;
896
+ function _varUintByteLength(value) {
897
+ let n = 0;
898
+ let v = value;
899
+ while (v >= HIGH_BIT) {
900
+ n++;
901
+ v = Math.floor(v / 128);
902
+ }
903
+ return n + 1;
904
+ }
905
+ function _writeVarUint(out, value, offset) {
906
+ let v = value;
907
+ while (v >= HIGH_BIT) {
908
+ out[offset++] = (HIGH_BIT | v & LOW_BITS) & 255;
909
+ v = Math.floor(v / 128);
910
+ }
911
+ out[offset++] = v & 255;
912
+ return offset;
913
+ }
914
+ function _readVarUint(data, offset) {
915
+ let value = 0;
916
+ let shift = 0;
917
+ while (shift < 63) {
918
+ if (offset >= data.length)
919
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
920
+ const b2 = data[offset++];
921
+ value += (b2 & LOW_BITS) * Math.pow(2, shift);
922
+ if (b2 <= LOW_BITS) return [value, offset];
923
+ shift += 7;
924
+ }
925
+ if (offset >= data.length)
926
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
927
+ const b = data[offset++];
928
+ if (b > 1)
929
+ throw new RangeError("IncreasingSeqCompressor: VarUInt value exceeds 64 bits.");
930
+ value += b * Math.pow(2, 63);
931
+ return [value, offset];
932
+ }
933
+
745
934
  // src/rpc-stream.ts
746
935
  var import_denque = __toESM(require("denque"), 1);
747
936
  var import_core5 = require("@actuallab/core");
@@ -763,13 +952,29 @@ var RpcStreamSender = class _RpcStreamSender {
763
952
  peer;
764
953
  ackPeriod;
765
954
  ackAdvance;
955
+ /**
956
+ * If true, the source was produced by a factory `(abortSignal) => AsyncIterable<T>`
957
+ * and may honor the AbortSignal to exit gracefully. `disconnect()` first aborts
958
+ * the signal and gives the source up to `RpcStream.disconnectGracePeriodMs` ms
959
+ * before force-closing via `iterator.return()`.
960
+ *
961
+ * If false, the source is a plain AsyncIterable that cannot observe the signal,
962
+ * so `disconnect()` force-closes immediately.
963
+ */
964
+ sourceUsesAbortSignal;
965
+ // -- Pump state --
966
+ /** Next stream index that will be sent (matches .NET's `index`). */
766
967
  _nextIndex = 0;
767
968
  _ended = false;
768
969
  _started = new import_core4.PromiseSource();
970
+ /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
769
971
  _lastAckedIndex = 0;
770
- _ackWaiting = null;
771
- _resetRequested = false;
772
- constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true) {
972
+ _abortController = new AbortController();
973
+ _iterator = null;
974
+ // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
975
+ _acks = [];
976
+ _whenAckReady = null;
977
+ constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true, sourceUsesAbortSignal = false) {
773
978
  const localId = peer.sharedObjects.nextId();
774
979
  this.id = { hostId: peer.hub.hubId, localId };
775
980
  this.allowReconnect = allowReconnect;
@@ -778,62 +983,71 @@ var RpcStreamSender = class _RpcStreamSender {
778
983
  this.peer = peer;
779
984
  this.ackPeriod = ackPeriod;
780
985
  this.ackAdvance = ackAdvance;
986
+ this.sourceUsesAbortSignal = sourceUsesAbortSignal;
987
+ }
988
+ /** AbortSignal that is aborted when the sender is disconnected. */
989
+ get abortSignal() {
990
+ return this._abortController.signal;
781
991
  }
782
992
  /** Returns the stream reference string for the $sys.Ok response. */
783
993
  toRef() {
784
994
  return `${this.id.hostId},${this.id.localId},${this.ackPeriod},${this.ackAdvance},${this.allowReconnect ? "1" : "0"},${this.isRealTime ? "1" : "0"}`;
785
995
  }
786
- /** Called by system call handler when $sys.Ack is received from the client. */
996
+ /**
997
+ * Called by system call handler when $sys.Ack is received from the client.
998
+ *
999
+ * Mirrors .NET `RpcSharedStream<T>.OnAck` at
1000
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:68-101.
1001
+ */
787
1002
  onAck(nextIndex, hostId) {
788
1003
  const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
789
- if (!this._started.isCompleted) {
790
- this._started.resolve();
791
- }
792
- if (mustReset && this.isRealTime) {
793
- this._nextIndex = nextIndex;
794
- this._lastAckedIndex = nextIndex;
795
- this._resetRequested = true;
796
- } else {
797
- if (mustReset || nextIndex > this._lastAckedIndex) {
798
- this._lastAckedIndex = nextIndex;
799
- }
800
- }
801
- if (this._ackWaiting) {
802
- this._ackWaiting.resolve();
803
- this._ackWaiting = null;
1004
+ if (!this._started.isCompleted) this._started.resolve();
1005
+ this._acks.push({ nextIndex, mustReset });
1006
+ if (this._whenAckReady) {
1007
+ this._whenAckReady.resolve();
1008
+ this._whenAckReady = null;
804
1009
  }
805
1010
  }
806
1011
  /** Called by system call handler when $sys.AckEnd is received from the client. */
807
1012
  onAckEnd(_hostId) {
808
- this._ended = true;
809
- this.peer.sharedObjects.unregister(this);
1013
+ this.disconnect();
810
1014
  }
811
- /** Send a single item to the client. */
1015
+ /**
1016
+ * Send a single item to the client at the current `_nextIndex`.
1017
+ *
1018
+ * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1019
+ * the item is considered "sent" from the pump's perspective so the replay
1020
+ * buffer indexing stays consistent. If a connection is absent, the wire
1021
+ * message is dropped; the item will be resent from the replay buffer
1022
+ * after a reconnect (client sends `Ack(N, mustReset=true)`).
1023
+ */
812
1024
  sendItem(item) {
813
1025
  if (this._ended) return;
814
1026
  const conn = this.peer.connection;
815
- if (!conn) return;
816
- this.peer.hub.systemCallSender.item(
817
- conn,
818
- this.peer.format,
819
- this.id.localId,
820
- this._nextIndex,
821
- item
822
- );
1027
+ if (conn) {
1028
+ this.peer.hub.systemCallSender.item(
1029
+ conn,
1030
+ this.peer.format,
1031
+ this.id.localId,
1032
+ this._nextIndex,
1033
+ item
1034
+ );
1035
+ }
823
1036
  this._nextIndex++;
824
1037
  }
825
- /** Send a batch of items to the client. */
1038
+ /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
826
1039
  sendBatch(items) {
827
1040
  if (this._ended || items.length === 0) return;
828
1041
  const conn = this.peer.connection;
829
- if (!conn) return;
830
- this.peer.hub.systemCallSender.batch(
831
- conn,
832
- this.peer.format,
833
- this.id.localId,
834
- this._nextIndex,
835
- items
836
- );
1042
+ if (conn) {
1043
+ this.peer.hub.systemCallSender.batch(
1044
+ conn,
1045
+ this.peer.format,
1046
+ this.id.localId,
1047
+ this._nextIndex,
1048
+ items
1049
+ );
1050
+ }
837
1051
  this._nextIndex += items.length;
838
1052
  }
839
1053
  /** Signal stream completion to the client. */
@@ -853,87 +1067,313 @@ var RpcStreamSender = class _RpcStreamSender {
853
1067
  }
854
1068
  /**
855
1069
  * Consume an AsyncIterable and send all items to the client.
856
- * Waits for the client's initial Ack before starting to pump items.
857
1070
  *
858
- * When `isRealTime` is true, applies flow control based on `ackAdvance`:
859
- * if the sender is `ackAdvance` items ahead of the last ACK, it enters
860
- * skip mode — draining the source and discarding items until it finds
861
- * one where `canSkipTo` returns true.
1071
+ * Dispatches to the non-real-time or real-time pump, matching .NET
1072
+ * `RpcSharedStream<T>.OnRun` at
1073
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:105-108.
862
1074
  */
863
1075
  async writeFrom(source) {
864
1076
  await this._started.promise;
1077
+ if (this._ended) return;
865
1078
  const iterator = source[Symbol.asyncIterator]();
1079
+ this._iterator = iterator;
1080
+ const state = { iteratorDone: false };
866
1081
  try {
867
- for (; ; ) {
868
- const next = await iterator.next();
869
- if (next.done || this._ended) break;
870
- const item = next.value;
871
- if (this._resetRequested) {
872
- if (!this.canSkipTo(item)) continue;
873
- this._resetRequested = false;
874
- this.sendItem(item);
875
- continue;
1082
+ if (this.isRealTime) {
1083
+ await this._runRealTime(iterator, state);
1084
+ } else {
1085
+ await this._runNonRealTime(iterator, state);
1086
+ }
1087
+ } finally {
1088
+ if (state.iteratorDone && this._iterator === iterator) {
1089
+ this._iterator = null;
1090
+ }
1091
+ }
1092
+ }
1093
+ /**
1094
+ * Non-real-time pump — back-pressure through buffered ACKs.
1095
+ *
1096
+ * Direct port of .NET `OnRunNonRealTime` at
1097
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:110-217.
1098
+ */
1099
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1100
+ async _runNonRealTime(iterator, state) {
1101
+ const buffer = new import_core4.RingBuffer(this.ackAdvance + 1);
1102
+ let bufferStart = 0;
1103
+ let isFullyBuffered = false;
1104
+ while (true) {
1105
+ let ack = this._tryProcessAcks();
1106
+ if (!ack) {
1107
+ await this._waitAckReady();
1108
+ if (this._ended) return;
1109
+ ack = this._tryProcessAcks();
1110
+ if (!ack) {
1111
+ return;
876
1112
  }
877
- if (this._nextIndex < this._lastAckedIndex + this.ackAdvance) {
878
- this.sendItem(item);
1113
+ }
1114
+ this._lastAckedIndex = ack.nextIndex;
1115
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1116
+ if (shift > 0) {
1117
+ buffer.moveHead(shift);
1118
+ bufferStart += shift;
1119
+ }
1120
+ if (this._nextIndex < bufferStart) {
1121
+ if (!this._ended)
1122
+ this.sendEnd(new Error("Stream position unavailable."));
1123
+ return;
1124
+ }
1125
+ let bufferIndex = this._nextIndex - bufferStart;
1126
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1127
+ while (this._nextIndex < maxIndex) {
1128
+ if (this._ended) return;
1129
+ if (bufferIndex < buffer.count) {
1130
+ const item = buffer.get(bufferIndex++);
1131
+ if (item.kind === "value") {
1132
+ this.sendItem(item.value);
1133
+ } else if (item.kind === "end") {
1134
+ if (!this._ended) this.sendEnd();
1135
+ return;
1136
+ } else {
1137
+ if (!this._ended) this.sendEnd(item.error);
1138
+ return;
1139
+ }
879
1140
  continue;
880
1141
  }
881
- if (!this.isRealTime) {
882
- await this._waitForAckBudget();
1142
+ if (isFullyBuffered) break;
1143
+ if (!buffer.hasRemainingCapacity) break;
1144
+ let pulled;
1145
+ try {
1146
+ const r = await iterator.next();
883
1147
  if (this._ended) return;
884
- this.sendItem(item);
885
- continue;
1148
+ if (r.done) {
1149
+ pulled = _endItem;
1150
+ isFullyBuffered = true;
1151
+ state.iteratorDone = true;
1152
+ } else {
1153
+ pulled = { kind: "value", value: r.value };
1154
+ }
1155
+ } catch (e) {
1156
+ state.iteratorDone = true;
1157
+ isFullyBuffered = true;
1158
+ pulled = {
1159
+ kind: "error",
1160
+ error: e instanceof Error ? e : new Error(String(e))
1161
+ };
886
1162
  }
887
- let latestSkipTarget = this.canSkipTo(item) ? item : void 0;
888
- let sourceExhausted = false;
889
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
1163
+ buffer.pushTail(pulled);
1164
+ }
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Real-time pump — drops stale items to stay close to the source's head.
1169
+ *
1170
+ * Direct port of .NET `OnRunRealTime` at
1171
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:219-420.
1172
+ */
1173
+ async _runRealTime(iterator, state) {
1174
+ const buffer = new import_core4.RingBuffer(this.ackAdvance + 1);
1175
+ let bufferStart = 0;
1176
+ let isFullyBuffered = false;
1177
+ while (true) {
1178
+ let ack = this._tryProcessAcks();
1179
+ if (!ack) {
1180
+ await this._waitAckReady();
1181
+ if (this._ended) return;
1182
+ ack = this._tryProcessAcks();
1183
+ if (!ack) return;
1184
+ }
1185
+ this._lastAckedIndex = ack.nextIndex;
1186
+ if (ack.mustReset && !isFullyBuffered) {
1187
+ buffer.clear();
1188
+ bufferStart = this._nextIndex;
1189
+ while (true) {
890
1190
  if (this._ended) return;
891
- const n = await iterator.next();
892
- if (n.done) {
893
- sourceExhausted = true;
894
- break;
895
- }
896
- if (this.canSkipTo(n.value)) {
897
- latestSkipTarget = n.value;
1191
+ let item;
1192
+ let accepted = false;
1193
+ try {
1194
+ const r = await iterator.next();
1195
+ if (this._ended) return;
1196
+ if (r.done) {
1197
+ item = _endItem;
1198
+ isFullyBuffered = true;
1199
+ state.iteratorDone = true;
1200
+ } else if (this.canSkipTo(r.value)) {
1201
+ item = { kind: "value", value: r.value };
1202
+ accepted = true;
1203
+ } else {
1204
+ continue;
1205
+ }
1206
+ } catch (e) {
1207
+ state.iteratorDone = true;
1208
+ isFullyBuffered = true;
1209
+ item = {
1210
+ kind: "error",
1211
+ error: e instanceof Error ? e : new Error(String(e))
1212
+ };
898
1213
  }
1214
+ buffer.pushTail(item);
1215
+ if (accepted || isFullyBuffered) break;
899
1216
  }
1217
+ }
1218
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1219
+ if (shift > 0) {
1220
+ buffer.moveHead(shift);
1221
+ bufferStart += shift;
1222
+ }
1223
+ if (this._nextIndex < bufferStart) {
1224
+ if (!this._ended)
1225
+ this.sendEnd(new Error("Stream position unavailable."));
1226
+ return;
1227
+ }
1228
+ let bufferIndex = this._nextIndex - bufferStart;
1229
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1230
+ while (this._nextIndex < maxIndex) {
900
1231
  if (this._ended) return;
901
- if (latestSkipTarget !== void 0) {
902
- this.sendItem(latestSkipTarget);
1232
+ if (this._acks.length > 0) break;
1233
+ if (bufferIndex < buffer.count) {
1234
+ const item = buffer.get(bufferIndex++);
1235
+ if (item.kind === "value") {
1236
+ this.sendItem(item.value);
1237
+ } else if (item.kind === "end") {
1238
+ if (!this._ended) this.sendEnd();
1239
+ return;
1240
+ } else {
1241
+ if (!this._ended) this.sendEnd(item.error);
1242
+ return;
1243
+ }
1244
+ continue;
903
1245
  }
904
- if (sourceExhausted) {
905
- if (!this._ended) this.sendEnd();
906
- return;
1246
+ if (isFullyBuffered) break;
1247
+ if (!buffer.hasRemainingCapacity) break;
1248
+ let pulled;
1249
+ try {
1250
+ const r = await iterator.next();
1251
+ if (this._ended) return;
1252
+ if (r.done) {
1253
+ pulled = _endItem;
1254
+ isFullyBuffered = true;
1255
+ state.iteratorDone = true;
1256
+ } else {
1257
+ pulled = { kind: "value", value: r.value };
1258
+ }
1259
+ } catch (e) {
1260
+ state.iteratorDone = true;
1261
+ isFullyBuffered = true;
1262
+ pulled = {
1263
+ kind: "error",
1264
+ error: e instanceof Error ? e : new Error(String(e))
1265
+ };
907
1266
  }
1267
+ buffer.pushTail(pulled);
908
1268
  }
909
- if (!this._ended) {
910
- this.sendEnd();
911
- }
912
- } catch (e) {
913
- if (!this._ended) {
914
- this.sendEnd(e instanceof Error ? e : new Error(String(e)));
1269
+ if (this._nextIndex >= maxIndex && !isFullyBuffered && !this._ended) {
1270
+ let latestValue;
1271
+ let haveLatest = false;
1272
+ let exitReason = "ack";
1273
+ let exitError = null;
1274
+ drain: while (!this._ended) {
1275
+ if (this._acks.length > 0) {
1276
+ exitReason = "ack";
1277
+ break drain;
1278
+ }
1279
+ let r;
1280
+ try {
1281
+ r = await iterator.next();
1282
+ } catch (e) {
1283
+ state.iteratorDone = true;
1284
+ isFullyBuffered = true;
1285
+ exitReason = "error";
1286
+ exitError = e instanceof Error ? e : new Error(String(e));
1287
+ break drain;
1288
+ }
1289
+ if (this._ended) return;
1290
+ if (r.done) {
1291
+ state.iteratorDone = true;
1292
+ isFullyBuffered = true;
1293
+ exitReason = "end";
1294
+ break drain;
1295
+ }
1296
+ if (this.canSkipTo(r.value)) {
1297
+ latestValue = r.value;
1298
+ haveLatest = true;
1299
+ }
1300
+ }
1301
+ if (exitReason === "end" || exitReason === "error") {
1302
+ if (haveLatest && !this._ended) this.sendItem(latestValue);
1303
+ if (!this._ended) this.sendEnd(exitError);
1304
+ return;
1305
+ }
1306
+ if (haveLatest) {
1307
+ const wasFull = buffer.isFull;
1308
+ buffer.pushTailAndMoveHeadIfFull({ kind: "value", value: latestValue });
1309
+ if (wasFull) bufferStart++;
1310
+ }
915
1311
  }
916
1312
  }
917
1313
  }
918
- /** Wait until _nextIndex < _lastAckedIndex + ackAdvance. */
919
- async _waitForAckBudget() {
920
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
921
- if (this._ended) return;
922
- this._ackWaiting = new import_core4.PromiseSource();
923
- await this._ackWaiting.promise;
1314
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1315
+ /** Drain all queued ACKs, returning the most recent one (or null if none). */
1316
+ _tryProcessAcks() {
1317
+ if (this._acks.length === 0) return null;
1318
+ let last = null;
1319
+ while (this._acks.length > 0) {
1320
+ const a = this._acks.shift();
1321
+ last = a;
1322
+ if (a.mustReset || this._nextIndex < a.nextIndex) {
1323
+ this._nextIndex = a.nextIndex;
1324
+ }
924
1325
  }
1326
+ return last;
1327
+ }
1328
+ /** Wait for the ACK queue to have at least one entry (or `_ended`). */
1329
+ async _waitAckReady() {
1330
+ if (this._acks.length > 0 || this._ended) return;
1331
+ this._whenAckReady = new import_core4.PromiseSource();
1332
+ await this._whenAckReady.promise;
1333
+ this._whenAckReady = null;
925
1334
  }
926
1335
  // -- IRpcObject --
927
1336
  reconnect() {
928
1337
  }
929
1338
  disconnect() {
1339
+ if (this._ended) return;
930
1340
  this._ended = true;
1341
+ this._abortController.abort();
931
1342
  if (!this._started.isCompleted) {
932
1343
  this._started.resolve();
933
1344
  }
1345
+ if (this._whenAckReady) {
1346
+ this._whenAckReady.resolve();
1347
+ this._whenAckReady = null;
1348
+ }
934
1349
  this.peer.sharedObjects.unregister(this);
1350
+ const iterator = this._iterator;
1351
+ if (!iterator) return;
1352
+ if (!this.sourceUsesAbortSignal) {
1353
+ this._iterator = null;
1354
+ this._forceCloseIterator(iterator);
1355
+ return;
1356
+ }
1357
+ setTimeout(() => {
1358
+ if (this._iterator === iterator) {
1359
+ this._iterator = null;
1360
+ this._forceCloseIterator(iterator);
1361
+ }
1362
+ }, RpcStream.disconnectGracePeriodMs);
1363
+ }
1364
+ _forceCloseIterator(iterator) {
1365
+ try {
1366
+ const ret = iterator.return?.(void 0);
1367
+ if (ret) void ret.catch(() => {
1368
+ });
1369
+ } catch {
1370
+ }
935
1371
  }
936
1372
  };
1373
+ var _endItem = { kind: "end" };
1374
+ function _clamp(value, min, max) {
1375
+ return value < min ? min : value > max ? max : value;
1376
+ }
937
1377
 
938
1378
  // src/rpc-stream.ts
939
1379
  var DEFAULT_ACK_PERIOD2 = 30;
@@ -969,6 +1409,15 @@ function parseStreamRef(value) {
969
1409
  return null;
970
1410
  }
971
1411
  var RpcStream = class _RpcStream {
1412
+ /**
1413
+ * Grace period (ms) given to a factory-form local source to honor its
1414
+ * AbortSignal and exit naturally before the sender force-closes the
1415
+ * iterator via `iterator.return()`.
1416
+ *
1417
+ * Only applies to sources provided as `(abortSignal) => AsyncIterable<T>`.
1418
+ * Plain `AsyncIterable<T>` sources are force-closed immediately on disconnect.
1419
+ */
1420
+ static disconnectGracePeriodMs = 100;
972
1421
  id;
973
1422
  kind;
974
1423
  allowReconnect;
@@ -995,7 +1444,7 @@ var RpcStream = class _RpcStream {
995
1444
  _iterating = false;
996
1445
  _ackSentUpTo = 0;
997
1446
  constructor(sourceOrRef, optionsOrPeer) {
998
- if (_isAsyncIterable(sourceOrRef)) {
1447
+ if (_isStreamSource(sourceOrRef)) {
999
1448
  const options = optionsOrPeer ?? {};
1000
1449
  this.kind = RpcObjectKind.Local;
1001
1450
  this.localSource = sourceOrRef;
@@ -1046,17 +1495,20 @@ var RpcStream = class _RpcStream {
1046
1495
  throw new Error("toRef() was already called with a different peer.");
1047
1496
  return this._toRefValue;
1048
1497
  }
1498
+ const isFactory = typeof this.localSource === "function";
1049
1499
  const sender = new RpcStreamSender(
1050
1500
  peer,
1051
1501
  this.ackPeriod,
1052
1502
  this.ackAdvance,
1053
1503
  this.allowReconnect,
1054
1504
  this.isRealTime,
1055
- this.canSkipTo
1505
+ this.canSkipTo,
1506
+ isFactory
1056
1507
  );
1057
1508
  peer.sharedObjects.register(sender);
1058
1509
  this._sender = sender;
1059
- this._whenSent = sender.writeFrom(this.localSource);
1510
+ const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
1511
+ this._whenSent = sender.writeFrom(source);
1060
1512
  this._toRefValue = peer.format.isBinary ? {
1061
1513
  SerializedId: [sender.id.hostId, sender.id.localId],
1062
1514
  AckPeriod: sender.ackPeriod,
@@ -1136,7 +1588,10 @@ var RpcStream = class _RpcStream {
1136
1588
  }
1137
1589
  }
1138
1590
  disconnect() {
1139
- if (this.kind === RpcObjectKind.Local) return;
1591
+ if (this.kind === RpcObjectKind.Local) {
1592
+ this._sender?.disconnect();
1593
+ return;
1594
+ }
1140
1595
  if (this._completed) return;
1141
1596
  this._completed = true;
1142
1597
  this._completionError = new Error("Peer disconnected.");
@@ -1145,7 +1600,8 @@ var RpcStream = class _RpcStream {
1145
1600
  // -- AsyncIterable --
1146
1601
  [Symbol.asyncIterator]() {
1147
1602
  if (this.kind === RpcObjectKind.Local) {
1148
- return this.localSource[Symbol.asyncIterator]();
1603
+ const source = typeof this.localSource === "function" ? this.localSource(new AbortController().signal) : this.localSource;
1604
+ return source[Symbol.asyncIterator]();
1149
1605
  }
1150
1606
  if (this._iterating)
1151
1607
  throw new Error("RpcStream can only be iterated once.");
@@ -1226,7 +1682,8 @@ var RpcStream = class _RpcStream {
1226
1682
  }
1227
1683
  }
1228
1684
  };
1229
- function _isAsyncIterable(value) {
1685
+ function _isStreamSource(value) {
1686
+ if (typeof value === "function") return true;
1230
1687
  return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
1231
1688
  }
1232
1689
  function resolveStreamRefs(value, peer) {
@@ -1260,6 +1717,25 @@ function resolveStreamRefs(value, peer) {
1260
1717
  return value;
1261
1718
  }
1262
1719
 
1720
+ // src/base64.ts
1721
+ function base64Encode(bytes) {
1722
+ const chunkSize = 32768;
1723
+ let binary = "";
1724
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1725
+ binary += String.fromCharCode.apply(
1726
+ null,
1727
+ bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1728
+ );
1729
+ }
1730
+ return btoa(binary);
1731
+ }
1732
+ function base64Decode(encoded) {
1733
+ const binary = atob(encoded);
1734
+ const out = new Uint8Array(binary.length);
1735
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1736
+ return out;
1737
+ }
1738
+
1263
1739
  // src/rpc-system-call-handler.ts
1264
1740
  var RpcSystemCallHandler = class {
1265
1741
  handle(message, args, peer) {
@@ -1269,6 +1745,7 @@ var RpcSystemCallHandler = class {
1269
1745
  case RpcSystemCalls.ok: {
1270
1746
  const call = peer.outbound.get(relatedId);
1271
1747
  if (call !== void 0) {
1748
+ call.completedStage |= RpcCallStage.ResultReady;
1272
1749
  if (call.removeOnOk) {
1273
1750
  peer.outbound.remove(relatedId);
1274
1751
  }
@@ -1276,6 +1753,10 @@ var RpcSystemCallHandler = class {
1276
1753
  }
1277
1754
  break;
1278
1755
  }
1756
+ case RpcSystemCalls.reconnect: {
1757
+ this._handleReconnect(relatedId, args, peer);
1758
+ break;
1759
+ }
1279
1760
  case RpcSystemCalls.error: {
1280
1761
  const call = peer.outbound.remove(relatedId);
1281
1762
  if (call !== void 0) {
@@ -1364,7 +1845,54 @@ var RpcSystemCallHandler = class {
1364
1845
  }
1365
1846
  }
1366
1847
  }
1848
+ /**
1849
+ * Server-side handler for the `$sys.Reconnect` call. Parses the peer's
1850
+ * report of in-flight call IDs (grouped by stage, IncreasingSeqCompressor-
1851
+ * encoded, base64-wrapped for JSON transport), computes the subset this
1852
+ * peer does not recognize, and replies with a compressed byte[] of the
1853
+ * unknown IDs wrapped in `$sys.Ok`.
1854
+ *
1855
+ * @param relatedId The outbound call ID the client used for this
1856
+ * `$sys.Reconnect` invocation — we send `$sys.Ok` back
1857
+ * with the same `RelatedId`.
1858
+ */
1859
+ _handleReconnect(relatedId, args, peer) {
1860
+ if (peer.connection === void 0) return;
1861
+ let unknownIds = [];
1862
+ try {
1863
+ const stagesRaw = args[1];
1864
+ if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1865
+ const unknownSet = /* @__PURE__ */ new Set();
1866
+ for (const [, rawValue] of Object.entries(stagesRaw)) {
1867
+ const bytes = _toBytes(rawValue);
1868
+ if (bytes === null) continue;
1869
+ const callIds = IncreasingSeqCompressor.deserialize(bytes);
1870
+ for (const callId of callIds) {
1871
+ if (peer.inbound.get(callId) === void 0) {
1872
+ unknownSet.add(callId);
1873
+ }
1874
+ }
1875
+ }
1876
+ unknownIds = [...unknownSet].sort((a, b) => a - b);
1877
+ }
1878
+ } catch {
1879
+ }
1880
+ const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1881
+ const responseValue = peer.format.isBinary ? responseBytes : base64Encode(responseBytes);
1882
+ peer.hub.systemCallSender.ok(peer.connection, peer.format, relatedId, responseValue);
1883
+ }
1367
1884
  };
1885
+ function _toBytes(value) {
1886
+ if (value instanceof Uint8Array) return value;
1887
+ if (typeof value === "string") {
1888
+ try {
1889
+ return base64Decode(value);
1890
+ } catch {
1891
+ return null;
1892
+ }
1893
+ }
1894
+ return null;
1895
+ }
1368
1896
 
1369
1897
  // src/rpc-system-call-sender.ts
1370
1898
  var RpcSystemCallSender = class _RpcSystemCallSender {
@@ -1552,7 +2080,7 @@ var RpcClientPeerReconnectDelayer = class extends import_core6.RetryDelayer {
1552
2080
  };
1553
2081
 
1554
2082
  // src/rpc-serialization-format.ts
1555
- var import_msgpack3 = require("@msgpack/msgpack");
2083
+ var import_msgpack4 = require("@msgpack/msgpack");
1556
2084
  var RpcSerializationFormat = class {
1557
2085
  /** Create a reusable binary encoder for this format. Returns undefined for text formats. */
1558
2086
  createEncoder() {
@@ -1626,7 +2154,7 @@ var RpcMessagePackSerializationFormat = class extends RpcSerializationFormat {
1626
2154
  return createBinaryEncoder();
1627
2155
  }
1628
2156
  createDecoder() {
1629
- return new import_msgpack3.Decoder();
2157
+ return new import_msgpack4.Decoder();
1630
2158
  }
1631
2159
  };
1632
2160
  var RpcMessagePackCompactSerializationFormat = class extends RpcSerializationFormat {
@@ -1658,7 +2186,7 @@ var RpcMessagePackCompactSerializationFormat = class extends RpcSerializationFor
1658
2186
  return createBinaryEncoder();
1659
2187
  }
1660
2188
  createDecoder() {
1661
- return new import_msgpack3.Decoder();
2189
+ return new import_msgpack4.Decoder();
1662
2190
  }
1663
2191
  };
1664
2192
  RpcSerializationFormat.SystemJsonV5 = new RpcJsonSerializationFormat("json5");
@@ -1755,13 +2283,21 @@ var RpcPeer = class {
1755
2283
  this._startKeepAlive();
1756
2284
  });
1757
2285
  }
1758
- /** The serialization format used by this peer. Override in subclasses. */
2286
+ /**
2287
+ * The serialization format used by this peer for outgoing messages.
2288
+ * If not set explicitly, defaults to the resolver's default (JSON /
2289
+ * json5np). Subclasses can pre-assign in their constructor.
2290
+ */
2291
+ _format;
1759
2292
  get format() {
1760
- return RpcSerializationFormatResolver.Default.defaultFormat;
2293
+ return this._format ?? RpcSerializationFormatResolver.Default.defaultFormat;
2294
+ }
2295
+ set format(value) {
2296
+ this._format = value;
1761
2297
  }
1762
2298
  call(method, args, options) {
1763
2299
  const callId = this.outbound.nextId();
1764
- const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method);
2300
+ const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method, options?.remoteExecutionMode ?? 7);
1765
2301
  const envelope = {
1766
2302
  Method: method,
1767
2303
  RelatedId: callId,
@@ -1866,8 +2402,16 @@ var RpcPeer = class {
1866
2402
  }
1867
2403
  const method = message.Method ?? "";
1868
2404
  if (method === RpcSystemCalls.handshake) {
1869
- const handshake = args[0];
1870
- if (handshake !== void 0) this._onHandshakeReceived(handshake);
2405
+ const raw = args[0];
2406
+ if (raw !== void 0) {
2407
+ const handshake = Array.isArray(raw) ? {
2408
+ RemotePeerId: raw[0],
2409
+ RemoteHubId: raw[2],
2410
+ ProtocolVersion: raw[3],
2411
+ Index: raw[4]
2412
+ } : raw;
2413
+ this._onHandshakeReceived(handshake);
2414
+ }
1871
2415
  return;
1872
2416
  }
1873
2417
  if (method.startsWith("$sys")) {
@@ -1957,6 +2501,12 @@ var RpcClientPeer = class extends RpcPeer {
1957
2501
  _connectionKind = 0 /* Disconnected */;
1958
2502
  _tryIndex = 0;
1959
2503
  _handshakeIndex = 0;
2504
+ /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
2505
+ * to the server in `$sys.Reconnect:3` so the server can verify we're
2506
+ * talking about the current connection. Mirrors .NET's
2507
+ * `connectionState.Handshake.Index` path in
2508
+ * `RpcOutboundCallTracker.TryReconnect`. */
2509
+ _remoteHandshakeIndex = 0;
1960
2510
  _lastRemoteHubId;
1961
2511
  _pendingHandshake;
1962
2512
  _reconnectsAt = 0;
@@ -1983,24 +2533,24 @@ var RpcClientPeer = class extends RpcPeer {
1983
2533
  this._reconnectsAt = value;
1984
2534
  this.reconnectsAtChanged.trigger();
1985
2535
  }
1986
- _format;
1987
2536
  constructor(hub, url, serializationFormat) {
1988
2537
  super(url, hub);
1989
2538
  this.clientId = guidToBase64Url(this.id);
1990
2539
  const resolver = RpcSerializationFormatResolver.Default;
1991
2540
  this.serializationFormat = serializationFormat ?? resolver.defaultFormatKey;
1992
- this._format = resolver.get(this.serializationFormat);
1993
- }
1994
- get format() {
1995
- return this._format;
2541
+ this.format = resolver.get(this.serializationFormat);
1996
2542
  }
1997
2543
  get connectionKind() {
1998
2544
  return this._connectionKind;
1999
2545
  }
2000
- /** One-shot connection for tests — no reconnection loop, no handshake. */
2001
- connectWith(conn) {
2546
+ /** One-shot connection for tests — no reconnection loop, no handshake.
2547
+ * @param isPeerChanged Whether to treat this as a peer-identity change
2548
+ * (default `true` — no handshake was exchanged, so stage-3 compute calls
2549
+ * self-invalidate and remote/shared objects are disposed). Pass `false`
2550
+ * to simulate a same-peer reconnect in tests. */
2551
+ connectWith(conn, isPeerChanged = true) {
2002
2552
  this.setupConnection(conn);
2003
- this._reconnect(true);
2553
+ void this._reconnect(isPeerChanged);
2004
2554
  }
2005
2555
  /** Start the reconnection loop — runs until disposed. */
2006
2556
  async run(wsFactory) {
@@ -2044,6 +2594,7 @@ var RpcClientPeer = class extends RpcPeer {
2044
2594
  closedRejection
2045
2595
  ]);
2046
2596
  this._pendingHandshake = void 0;
2597
+ this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2047
2598
  const remoteHubId = remoteHandshake.RemoteHubId;
2048
2599
  let isPeerChanged = false;
2049
2600
  if (remoteHubId !== void 0) {
@@ -2057,7 +2608,7 @@ var RpcClientPeer = class extends RpcPeer {
2057
2608
  this._connection = conn;
2058
2609
  this._connectionKind = 2 /* Connected */;
2059
2610
  this._tryIndex = 0;
2060
- this._reconnect(isPeerChanged);
2611
+ await this._reconnect(isPeerChanged);
2061
2612
  await new Promise((r) => conn.closed.add(() => r()));
2062
2613
  } catch {
2063
2614
  }
@@ -2082,31 +2633,129 @@ var RpcClientPeer = class extends RpcPeer {
2082
2633
  }
2083
2634
  }
2084
2635
  }
2085
- /** Re-send outbound calls after reconnection + flush pending sends.
2086
- * Stage-3 compute calls are always self-invalidated: without $sys.Reconnect
2087
- * protocol, the server's invalidation tracking is lost on disconnect, and
2088
- * re-sending would get a duplicate $sys.Ok that is ignored (PromiseSource
2089
- * already resolved). Self-invalidation forces a fresh recompute that
2090
- * establishes new invalidation tracking on the new connection.
2091
- * Regular in-flight calls are re-sent transparently. */
2092
- _reconnect(_isPeerChanged) {
2636
+ /**
2637
+ * Reconcile outbound calls after reconnection, then flush pending sends.
2638
+ *
2639
+ * On peer change (different server hubId) or with binary wire format —
2640
+ * we blind-resend every eligible call.
2641
+ *
2642
+ * On a same-peer reconnect with JSON transport, first call
2643
+ * `$sys.Reconnect` to ask the server which call IDs it no longer
2644
+ * recognizes (e.g. inbound tracker was cleared, or the call never
2645
+ * reached the server). Only those get resent; the rest stay in the
2646
+ * tracker awaiting their $sys.Ok. This prevents duplicate server-side
2647
+ * invocation of in-flight streaming calls on every transient
2648
+ * reconnect — mirrors .NET `RpcOutboundCallTracker.Reconnect` at
2649
+ * src/ActualLab.Rpc/Infrastructure/RpcCallTrackers.cs:209-279.
2650
+ *
2651
+ * Stage-3 compute calls (result already resolved, awaiting invalidation)
2652
+ * are always self-invalidated — TS doesn't track compute-call stages
2653
+ * across reconnect, so the safest behavior is to force a fresh compute.
2654
+ */
2655
+ async _reconnect(isPeerChanged) {
2093
2656
  if (this._connection === void 0) return;
2094
- if (_isPeerChanged) {
2657
+ if (isPeerChanged) {
2095
2658
  this.remoteObjects.disconnectAll();
2659
+ this.sharedObjects.disconnectAll();
2096
2660
  } else {
2097
2661
  this.remoteObjects.reconnectAll();
2098
2662
  }
2099
- const trackerCalls = [...this.outbound.values()];
2100
- for (const call of trackerCalls) {
2663
+ const eligible = [];
2664
+ for (const call of [...this.outbound.values()]) {
2665
+ const mode = call.remoteExecutionMode;
2666
+ if (!(mode & 2)) {
2667
+ if (!call.result.isCompleted)
2668
+ call.result.reject(new Error(
2669
+ "Outbound call failed: disconnected and AllowReconnect is not set."
2670
+ ));
2671
+ call.onDisconnect();
2672
+ this.outbound.remove(call.callId);
2673
+ continue;
2674
+ }
2675
+ if (isPeerChanged && !(mode & 4)) {
2676
+ if (!call.result.isCompleted)
2677
+ call.result.reject(new Error(
2678
+ "Outbound call failed: reconnected to a different peer and AllowResend is not set."
2679
+ ));
2680
+ call.onDisconnect();
2681
+ this.outbound.remove(call.callId);
2682
+ continue;
2683
+ }
2684
+ eligible.push(call);
2685
+ }
2686
+ let unknownIds = null;
2687
+ if (!isPeerChanged && eligible.length > 0) {
2688
+ unknownIds = await this._reconcileReconnect(eligible);
2689
+ }
2690
+ for (const call of eligible) {
2101
2691
  if (!call.removeOnOk && call.result.isCompleted) {
2102
2692
  call.onDisconnect();
2103
2693
  this.outbound.remove(call.callId);
2104
- } else {
2105
- this._sendWireData(call.serializedWireData);
2694
+ continue;
2106
2695
  }
2696
+ if (unknownIds !== null && !unknownIds.has(call.callId)) {
2697
+ continue;
2698
+ }
2699
+ this._sendWireData(call.serializedWireData);
2107
2700
  }
2108
2701
  this._flushPendingSends();
2109
2702
  }
2703
+ /**
2704
+ * Ask the server which call IDs in `eligible` it no longer recognizes
2705
+ * via `$sys.Reconnect:3`. Returns the set of call IDs that need to be
2706
+ * resent. On any failure, falls back to "resend everything".
2707
+ *
2708
+ * Wire format of the `completedStages` argument is chosen per wire format:
2709
+ * - JSON (json5np): plain object `{"<stage>": "<base64-of-bytes>"}` —
2710
+ * matches .NET `System.Text.Json`'s default for `Dictionary<int, byte[]>`.
2711
+ * - MessagePack (msgpack6/msgpack6c): `Map<number, Uint8Array>` —
2712
+ * the patched encoder (see `./msgpack-map-patch.ts`) writes this as a
2713
+ * native msgpack `map<int, bin>`, matching .NET MessagePack-CSharp's
2714
+ * default for `Dictionary<int, byte[]>`.
2715
+ */
2716
+ async _reconcileReconnect(eligible) {
2717
+ try {
2718
+ const byStage = /* @__PURE__ */ new Map();
2719
+ for (const call of eligible) {
2720
+ const stage = call.getReconnectStage(false);
2721
+ if (stage === null) continue;
2722
+ let arr = byStage.get(stage);
2723
+ if (!arr) {
2724
+ arr = [];
2725
+ byStage.set(stage, arr);
2726
+ }
2727
+ arr.push(call.callId);
2728
+ }
2729
+ if (byStage.size === 0) {
2730
+ return new Set(eligible.map((c) => c.callId));
2731
+ }
2732
+ const isBinary = this.format.isBinary;
2733
+ const stagesArg = isBinary ? /* @__PURE__ */ new Map() : {};
2734
+ for (const [stage, ids] of byStage) {
2735
+ ids.sort((a, b) => a - b);
2736
+ const bytes2 = IncreasingSeqCompressor.serialize(ids);
2737
+ if (isBinary) {
2738
+ stagesArg.set(stage, bytes2);
2739
+ } else {
2740
+ stagesArg[String(stage)] = base64Encode(bytes2);
2741
+ }
2742
+ }
2743
+ const reconnectCall = this.call(
2744
+ RpcSystemCalls.reconnect,
2745
+ [this._remoteHandshakeIndex, stagesArg],
2746
+ {
2747
+ remoteExecutionMode: 1
2748
+ /* AwaitForConnection only; must not itself reconnect */
2749
+ }
2750
+ );
2751
+ const result = await reconnectCall.result.promise;
2752
+ const bytes = _toBytes2(result);
2753
+ if (bytes === null) return new Set(eligible.map((c) => c.callId));
2754
+ return new Set(IncreasingSeqCompressor.deserialize(bytes));
2755
+ } catch {
2756
+ return new Set(eligible.map((c) => c.callId));
2757
+ }
2758
+ }
2110
2759
  _onHandshakeReceived(handshake) {
2111
2760
  this._pendingHandshake?.resolve(handshake);
2112
2761
  }
@@ -2122,6 +2771,9 @@ var RpcServerPeer = class extends RpcPeer {
2122
2771
  /** Accept an incoming connection — sets up message handling and handshake response. */
2123
2772
  accept(conn) {
2124
2773
  this.setupConnection(conn);
2774
+ conn.closed.add(() => {
2775
+ this.sharedObjects.disconnectAll();
2776
+ });
2125
2777
  }
2126
2778
  _onHandshakeReceived(_handshake) {
2127
2779
  if (this._connection !== void 0) {
@@ -2151,6 +2803,17 @@ function guidToBase64Url(uuid) {
2151
2803
  const binary = String.fromCharCode(...bytes);
2152
2804
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2153
2805
  }
2806
+ function _toBytes2(value) {
2807
+ if (value instanceof Uint8Array) return value;
2808
+ if (typeof value === "string") {
2809
+ try {
2810
+ return base64Decode(value);
2811
+ } catch {
2812
+ return null;
2813
+ }
2814
+ }
2815
+ return null;
2816
+ }
2154
2817
 
2155
2818
  // src/rpc-service-host.ts
2156
2819
  var RpcServiceHost = class {
@@ -2201,7 +2864,8 @@ function rpcMethod(options) {
2201
2864
  methods[methodName] = {
2202
2865
  ...methods[methodName],
2203
2866
  argCount: target.length,
2204
- returns: options?.returns
2867
+ returns: options?.returns,
2868
+ remoteExecutionMode: options?.remoteExecutionMode
2205
2869
  };
2206
2870
  return target;
2207
2871
  };
@@ -2816,7 +3480,8 @@ var RpcHub = class {
2816
3480
  const methods = /* @__PURE__ */ new Map();
2817
3481
  for (const [name, byArgCount] of overloads) {
2818
3482
  if (byArgCount.size === 1) {
2819
- methods.set(name, byArgCount.values().next().value);
3483
+ const [[, singleFn]] = byArgCount;
3484
+ methods.set(name, singleFn);
2820
3485
  } else {
2821
3486
  methods.set(name, (...args) => {
2822
3487
  const fn = byArgCount.get(args.length);
@@ -2846,6 +3511,7 @@ var RpcHub = class {
2846
3511
  for (const [name, meta] of Object.entries(methodsMeta)) {
2847
3512
  const wireArgCount = meta.argCount + 1;
2848
3513
  const mapKey = `${name}:${wireArgCount}`;
3514
+ const isNoWait = meta.returns === RpcType.noWait;
2849
3515
  methods.set(mapKey, {
2850
3516
  name,
2851
3517
  serviceName: svcMeta.name,
@@ -2853,7 +3519,8 @@ var RpcHub = class {
2853
3519
  wireArgCount,
2854
3520
  callTypeId: 0,
2855
3521
  stream: meta.returns === RpcType.stream,
2856
- noWait: meta.returns === RpcType.noWait
3522
+ noWait: isNoWait,
3523
+ remoteExecutionMode: isNoWait ? 0 : meta.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
2857
3524
  });
2858
3525
  }
2859
3526
  return { name: svcMeta.name, methods };
@@ -2871,6 +3538,8 @@ var RpcHub = class {
2871
3538
  /** Create a client method for the given methodDef. Override in FusionHub for custom call types. */
2872
3539
  _createClientMethod(peer, methodDef) {
2873
3540
  const wireName = wireMethodName(methodDef);
3541
+ const mode = methodDef.remoteExecutionMode;
3542
+ const mustCheckConnection = !(mode & RpcRemoteExecutionMode.AwaitForConnection);
2874
3543
  if (methodDef.noWait) {
2875
3544
  return (...args) => {
2876
3545
  const callArgs = args.slice(0, methodDef.argCount);
@@ -2879,6 +3548,8 @@ var RpcHub = class {
2879
3548
  }
2880
3549
  if (methodDef.stream) {
2881
3550
  return async (...args) => {
3551
+ if (mustCheckConnection && !peer.isConnected)
3552
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2882
3553
  const callArgs = args.slice(0, methodDef.argCount);
2883
3554
  const outboundCall = peer.call(wireName, callArgs);
2884
3555
  const result = await outboundCall.result.promise;
@@ -2892,8 +3563,10 @@ var RpcHub = class {
2892
3563
  return stream;
2893
3564
  };
2894
3565
  }
2895
- const callOptions = methodDef.callTypeId !== 0 ? { callTypeId: methodDef.callTypeId } : void 0;
3566
+ const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
2896
3567
  return async (...args) => {
3568
+ if (mustCheckConnection && !peer.isConnected)
3569
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2897
3570
  const callArgs = args.slice(0, methodDef.argCount);
2898
3571
  const outboundCall = peer.call(wireName, callArgs, callOptions);
2899
3572
  const result = await outboundCall.result.promise;
@@ -2916,7 +3589,7 @@ function createRpcClient(peer, serviceDef) {
2916
3589
  const methods = /* @__PURE__ */ new Map();
2917
3590
  for (const [name, byArgCount] of overloads) {
2918
3591
  if (byArgCount.size === 1) {
2919
- const methodDef = byArgCount.values().next().value;
3592
+ const [[, methodDef]] = byArgCount;
2920
3593
  methods.set(name, createMethod(peer, methodDef));
2921
3594
  } else {
2922
3595
  const fns = /* @__PURE__ */ new Map();
@@ -2941,10 +3614,14 @@ function createRpcClient(peer, serviceDef) {
2941
3614
  });
2942
3615
  }
2943
3616
  function createMethod(peer, methodDef) {
3617
+ const mode = methodDef.remoteExecutionMode;
3618
+ const mustCheckConnection = !(mode & RpcRemoteExecutionMode.AwaitForConnection);
2944
3619
  return async (...args) => {
3620
+ if (mustCheckConnection && !peer.isConnected)
3621
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2945
3622
  const callArgs = args.slice(0, methodDef.argCount);
2946
3623
  const wireName = wireMethodName(methodDef);
2947
- const options = methodDef.callTypeId !== 0 ? { callTypeId: methodDef.callTypeId } : void 0;
3624
+ const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
2948
3625
  const outboundCall = peer.call(wireName, callArgs, options);
2949
3626
  return outboundCall.result.promise;
2950
3627
  };
@@ -3140,7 +3817,9 @@ var RpcPeerStateMonitor = class {
3140
3817
  ARG_DELIMITER,
3141
3818
  ENVELOPE_DELIMITER,
3142
3819
  FRAME_DELIMITER,
3820
+ IncreasingSeqCompressor,
3143
3821
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
3822
+ RpcCallStage,
3144
3823
  RpcCallTypeId,
3145
3824
  RpcClientPeer,
3146
3825
  RpcClientPeerReconnectDelayer,
@@ -3159,6 +3838,7 @@ var RpcPeerStateMonitor = class {
3159
3838
  RpcPeerConnectionKind,
3160
3839
  RpcPeerStateKind,
3161
3840
  RpcPeerStateMonitor,
3841
+ RpcRemoteExecutionMode,
3162
3842
  RpcRemoteObjectTracker,
3163
3843
  RpcSerializationFormat,
3164
3844
  RpcSerializationFormatResolver,