@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.js CHANGED
@@ -23,9 +23,45 @@ var FRAME_DELIMITER = "\n";
23
23
 
24
24
  // src/rpc-serialization.ts
25
25
  import {
26
- Encoder,
26
+ Encoder as Encoder2,
27
27
  Decoder
28
28
  } from "@msgpack/msgpack";
29
+
30
+ // src/msgpack-map-patch.ts
31
+ import { Encoder } from "@msgpack/msgpack";
32
+ function patchMsgpackEncoderForMaps() {
33
+ const proto = Encoder.prototype;
34
+ if (proto._hasMapPatch) return;
35
+ proto._hasMapPatch = true;
36
+ const originalDoEncode = proto.doEncode;
37
+ proto.doEncode = function(object, depth) {
38
+ if (object instanceof Map) {
39
+ _encodeMapInstance.call(this, object, depth);
40
+ return;
41
+ }
42
+ originalDoEncode.call(this, object, depth);
43
+ };
44
+ function _encodeMapInstance(m, depth) {
45
+ const self = this;
46
+ const size = m.size;
47
+ if (size < 16) {
48
+ self.writeU8(128 + size);
49
+ } else if (size < 65536) {
50
+ self.writeU8(222);
51
+ self.writeU16(size);
52
+ } else {
53
+ self.writeU8(223);
54
+ self.writeU32(size);
55
+ }
56
+ for (const [key, value] of m) {
57
+ self.doEncode(key, depth + 1);
58
+ self.doEncode(value, depth + 1);
59
+ }
60
+ }
61
+ }
62
+ patchMsgpackEncoderForMaps();
63
+
64
+ // src/rpc-serialization.ts
29
65
  function serializeMessage(message, args) {
30
66
  const envelope = JSON.stringify(message);
31
67
  if (args === void 0 || args.length === 0) {
@@ -66,7 +102,7 @@ function deserializeMessage(raw) {
66
102
  var textEncoder = new TextEncoder();
67
103
  var textDecoder = new TextDecoder();
68
104
  var INITIAL_ENCODER_BUFFER_SIZE = 32 * 1024;
69
- var defaultBinaryEncoder = new Encoder(
105
+ var defaultBinaryEncoder = new Encoder2(
70
106
  void 0,
71
107
  void 0,
72
108
  void 0,
@@ -74,7 +110,7 @@ var defaultBinaryEncoder = new Encoder(
74
110
  );
75
111
  var defaultBinaryDecoder = new Decoder();
76
112
  function createBinaryEncoder() {
77
- return new Encoder(
113
+ return new Encoder2(
78
114
  void 0,
79
115
  void 0,
80
116
  void 0,
@@ -515,6 +551,18 @@ function createMessageChannelPair() {
515
551
  }
516
552
 
517
553
  // src/rpc-service-def.ts
554
+ var RpcRemoteExecutionMode = {
555
+ /** Wait for connection if disconnected when the call is made. */
556
+ AwaitForConnection: 1,
557
+ /** Resend on reconnection to the same peer. */
558
+ AllowReconnect: 2,
559
+ /** Resend on reconnection to a different peer (implies AllowReconnect). */
560
+ AllowResend: 6,
561
+ // 4 | AllowReconnect
562
+ /** Default: wait + reconnect + resend. */
563
+ Default: 7
564
+ // AwaitForConnection | AllowResend
565
+ };
518
566
  var RpcType = {
519
567
  object: /* @__PURE__ */ Symbol("object"),
520
568
  stream: /* @__PURE__ */ Symbol("stream"),
@@ -531,6 +579,7 @@ function defineRpcService(name, methods) {
531
579
  const cleanName = colonIndex >= 0 ? key.substring(0, colonIndex) : key;
532
580
  const wireArgCount = input.wireArgCount ?? input.args.length + 1;
533
581
  const mapKey = `${cleanName}:${wireArgCount}`;
582
+ const isNoWait = input.returns === RpcType.noWait;
534
583
  map.set(mapKey, {
535
584
  name: cleanName,
536
585
  serviceName: name,
@@ -538,7 +587,8 @@ function defineRpcService(name, methods) {
538
587
  wireArgCount,
539
588
  callTypeId: input.callTypeId ?? 0,
540
589
  stream: input.returns === RpcType.stream,
541
- noWait: input.returns === RpcType.noWait
590
+ noWait: isNoWait,
591
+ remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
542
592
  });
543
593
  }
544
594
  return { name, methods: map };
@@ -546,18 +596,60 @@ function defineRpcService(name, methods) {
546
596
 
547
597
  // src/rpc-call-tracker.ts
548
598
  import { PromiseSource as PromiseSource2 } from "@actuallab/core";
599
+
600
+ // src/rpc-call-stage.ts
601
+ var RpcCallStage = {
602
+ /** The call's result has been received from the peer. */
603
+ ResultReady: 1,
604
+ /** The compute call's result has been invalidated (compute-call specific). */
605
+ Invalidated: 3,
606
+ /**
607
+ * Bit flag added on top of ResultReady/Invalidated when the outbound
608
+ * call has been removed from the tracker (unregistered).
609
+ */
610
+ Unregistered: 4096
611
+ };
612
+
613
+ // src/rpc-call-tracker.ts
549
614
  var RpcOutboundCall = class {
550
615
  callId;
551
616
  method;
552
617
  result = new PromiseSource2();
618
+ /** Bitfield of RpcRemoteExecutionMode flags controlling reconnect/resend behavior. */
619
+ remoteExecutionMode;
553
620
  /** Whether to remove this call from the tracker on $sys.Ok. Default: true.
554
621
  * Subclasses (e.g. compute calls) override to false to stay in tracker for invalidation. */
555
622
  removeOnOk = true;
556
623
  /** The serialized wire data — stored for re-sending on reconnect. */
557
624
  serializedWireData = "";
558
- constructor(callId, method) {
625
+ /**
626
+ * Bitfield of {@link RpcCallStage} flags indicating how far this call
627
+ * has progressed. Used by the `$sys.Reconnect` protocol to tell the
628
+ * remote peer which calls are still pending its attention.
629
+ *
630
+ * - 0: brand new, no result yet (the peer is expected to be actively processing).
631
+ * - `ResultReady` (1): `$sys.Ok` received for this call.
632
+ * - `Invalidated` (3): result was invalidated (compute calls only).
633
+ * - `Unregistered` (0x1000): removed from the tracker.
634
+ */
635
+ completedStage = 0;
636
+ constructor(callId, method, remoteExecutionMode = 7) {
559
637
  this.callId = callId;
560
638
  this.method = method;
639
+ this.remoteExecutionMode = remoteExecutionMode;
640
+ }
641
+ /**
642
+ * Returns the stage to report to the remote peer via `$sys.Reconnect`,
643
+ * or `null` if this call should not be reconciled and must be aborted.
644
+ *
645
+ * Mirrors .NET `RpcOutboundCall.GetReconnectStage` at
646
+ * src/ActualLab.Rpc/Infrastructure/RpcOutboundCall.cs.
647
+ */
648
+ getReconnectStage(isPeerChanged) {
649
+ const mode = this.remoteExecutionMode;
650
+ if (!(mode & RpcRemoteExecutionMode.AllowReconnect)) return null;
651
+ if (isPeerChanged && !(mode & RpcRemoteExecutionMode.AllowResend)) return null;
652
+ return this.completedStage;
561
653
  }
562
654
  /** Called when the connection is lost. Subclasses can override to resolve invalidation promises. */
563
655
  onDisconnect() {
@@ -580,7 +672,10 @@ var RpcOutboundCallTracker = class {
580
672
  }
581
673
  remove(callId) {
582
674
  const call = this._calls.get(callId);
583
- if (call !== void 0) this._calls.delete(callId);
675
+ if (call !== void 0) {
676
+ this._calls.delete(callId);
677
+ call.completedStage |= RpcCallStage.Unregistered;
678
+ }
584
679
  return call;
585
680
  }
586
681
  values() {
@@ -643,6 +738,97 @@ var RpcInboundCallTracker = class {
643
738
  }
644
739
  };
645
740
 
741
+ // src/increasing-seq-compressor.ts
742
+ var IncreasingSeqCompressor = {
743
+ /**
744
+ * Serialize a non-decreasing sequence of non-negative integers as
745
+ * LEB128-encoded deltas.
746
+ *
747
+ * Throws `RangeError` if the input is not monotonically non-decreasing,
748
+ * or if a value exceeds `Number.MAX_SAFE_INTEGER`.
749
+ */
750
+ serialize(values) {
751
+ const deltas = [];
752
+ let last = 0;
753
+ let totalBytes = 0;
754
+ for (const value of values) {
755
+ if (!Number.isSafeInteger(value) || value < 0)
756
+ throw new RangeError(
757
+ `IncreasingSeqCompressor: value must be a non-negative safe integer (got ${value}).`
758
+ );
759
+ const delta = value - last;
760
+ if (delta < 0)
761
+ throw new RangeError(
762
+ `IncreasingSeqCompressor: sequence must be non-decreasing (saw ${value} after ${last}).`
763
+ );
764
+ deltas.push(delta);
765
+ last = value;
766
+ totalBytes += _varUintByteLength(delta);
767
+ }
768
+ const out = new Uint8Array(totalBytes);
769
+ let offset = 0;
770
+ for (const delta of deltas) {
771
+ offset = _writeVarUint(out, delta, offset);
772
+ }
773
+ return out;
774
+ },
775
+ /**
776
+ * Decode bytes produced by {@link serialize} back into the original
777
+ * sorted sequence of non-negative integers.
778
+ */
779
+ deserialize(data) {
780
+ const result = [];
781
+ let last = 0;
782
+ let offset = 0;
783
+ while (offset < data.length) {
784
+ const [delta, next] = _readVarUint(data, offset);
785
+ offset = next;
786
+ last += delta;
787
+ result.push(last);
788
+ }
789
+ return result;
790
+ }
791
+ };
792
+ var LOW_BITS = 127;
793
+ var HIGH_BIT = 128;
794
+ function _varUintByteLength(value) {
795
+ let n = 0;
796
+ let v = value;
797
+ while (v >= HIGH_BIT) {
798
+ n++;
799
+ v = Math.floor(v / 128);
800
+ }
801
+ return n + 1;
802
+ }
803
+ function _writeVarUint(out, value, offset) {
804
+ let v = value;
805
+ while (v >= HIGH_BIT) {
806
+ out[offset++] = (HIGH_BIT | v & LOW_BITS) & 255;
807
+ v = Math.floor(v / 128);
808
+ }
809
+ out[offset++] = v & 255;
810
+ return offset;
811
+ }
812
+ function _readVarUint(data, offset) {
813
+ let value = 0;
814
+ let shift = 0;
815
+ while (shift < 63) {
816
+ if (offset >= data.length)
817
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
818
+ const b2 = data[offset++];
819
+ value += (b2 & LOW_BITS) * Math.pow(2, shift);
820
+ if (b2 <= LOW_BITS) return [value, offset];
821
+ shift += 7;
822
+ }
823
+ if (offset >= data.length)
824
+ throw new RangeError("IncreasingSeqCompressor: truncated VarUInt.");
825
+ const b = data[offset++];
826
+ if (b > 1)
827
+ throw new RangeError("IncreasingSeqCompressor: VarUInt value exceeds 64 bits.");
828
+ value += b * Math.pow(2, 63);
829
+ return [value, offset];
830
+ }
831
+
646
832
  // src/rpc-stream.ts
647
833
  import Denque from "denque";
648
834
  import { PromiseSource as PromiseSource4 } from "@actuallab/core";
@@ -651,7 +837,7 @@ import { PromiseSource as PromiseSource4 } from "@actuallab/core";
651
837
  var RpcObjectKind = { Local: 0, Remote: 1 };
652
838
 
653
839
  // src/rpc-stream-sender.ts
654
- import { PromiseSource as PromiseSource3 } from "@actuallab/core";
840
+ import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
655
841
  var DEFAULT_ACK_PERIOD = 256;
656
842
  var DEFAULT_ACK_ADVANCE = 128;
657
843
  var RpcStreamSender = class _RpcStreamSender {
@@ -664,13 +850,29 @@ var RpcStreamSender = class _RpcStreamSender {
664
850
  peer;
665
851
  ackPeriod;
666
852
  ackAdvance;
853
+ /**
854
+ * If true, the source was produced by a factory `(abortSignal) => AsyncIterable<T>`
855
+ * and may honor the AbortSignal to exit gracefully. `disconnect()` first aborts
856
+ * the signal and gives the source up to `RpcStream.disconnectGracePeriodMs` ms
857
+ * before force-closing via `iterator.return()`.
858
+ *
859
+ * If false, the source is a plain AsyncIterable that cannot observe the signal,
860
+ * so `disconnect()` force-closes immediately.
861
+ */
862
+ sourceUsesAbortSignal;
863
+ // -- Pump state --
864
+ /** Next stream index that will be sent (matches .NET's `index`). */
667
865
  _nextIndex = 0;
668
866
  _ended = false;
669
867
  _started = new PromiseSource3();
868
+ /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
670
869
  _lastAckedIndex = 0;
671
- _ackWaiting = null;
672
- _resetRequested = false;
673
- constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true) {
870
+ _abortController = new AbortController();
871
+ _iterator = null;
872
+ // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
873
+ _acks = [];
874
+ _whenAckReady = null;
875
+ constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true, sourceUsesAbortSignal = false) {
674
876
  const localId = peer.sharedObjects.nextId();
675
877
  this.id = { hostId: peer.hub.hubId, localId };
676
878
  this.allowReconnect = allowReconnect;
@@ -679,62 +881,71 @@ var RpcStreamSender = class _RpcStreamSender {
679
881
  this.peer = peer;
680
882
  this.ackPeriod = ackPeriod;
681
883
  this.ackAdvance = ackAdvance;
884
+ this.sourceUsesAbortSignal = sourceUsesAbortSignal;
885
+ }
886
+ /** AbortSignal that is aborted when the sender is disconnected. */
887
+ get abortSignal() {
888
+ return this._abortController.signal;
682
889
  }
683
890
  /** Returns the stream reference string for the $sys.Ok response. */
684
891
  toRef() {
685
892
  return `${this.id.hostId},${this.id.localId},${this.ackPeriod},${this.ackAdvance},${this.allowReconnect ? "1" : "0"},${this.isRealTime ? "1" : "0"}`;
686
893
  }
687
- /** Called by system call handler when $sys.Ack is received from the client. */
894
+ /**
895
+ * Called by system call handler when $sys.Ack is received from the client.
896
+ *
897
+ * Mirrors .NET `RpcSharedStream<T>.OnAck` at
898
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:68-101.
899
+ */
688
900
  onAck(nextIndex, hostId) {
689
901
  const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
690
- if (!this._started.isCompleted) {
691
- this._started.resolve();
692
- }
693
- if (mustReset && this.isRealTime) {
694
- this._nextIndex = nextIndex;
695
- this._lastAckedIndex = nextIndex;
696
- this._resetRequested = true;
697
- } else {
698
- if (mustReset || nextIndex > this._lastAckedIndex) {
699
- this._lastAckedIndex = nextIndex;
700
- }
701
- }
702
- if (this._ackWaiting) {
703
- this._ackWaiting.resolve();
704
- this._ackWaiting = null;
902
+ if (!this._started.isCompleted) this._started.resolve();
903
+ this._acks.push({ nextIndex, mustReset });
904
+ if (this._whenAckReady) {
905
+ this._whenAckReady.resolve();
906
+ this._whenAckReady = null;
705
907
  }
706
908
  }
707
909
  /** Called by system call handler when $sys.AckEnd is received from the client. */
708
910
  onAckEnd(_hostId) {
709
- this._ended = true;
710
- this.peer.sharedObjects.unregister(this);
911
+ this.disconnect();
711
912
  }
712
- /** Send a single item to the client. */
913
+ /**
914
+ * Send a single item to the client at the current `_nextIndex`.
915
+ *
916
+ * Advances `_nextIndex` unconditionally — even if the peer is disconnected
917
+ * the item is considered "sent" from the pump's perspective so the replay
918
+ * buffer indexing stays consistent. If a connection is absent, the wire
919
+ * message is dropped; the item will be resent from the replay buffer
920
+ * after a reconnect (client sends `Ack(N, mustReset=true)`).
921
+ */
713
922
  sendItem(item) {
714
923
  if (this._ended) return;
715
924
  const conn = this.peer.connection;
716
- if (!conn) return;
717
- this.peer.hub.systemCallSender.item(
718
- conn,
719
- this.peer.format,
720
- this.id.localId,
721
- this._nextIndex,
722
- item
723
- );
925
+ if (conn) {
926
+ this.peer.hub.systemCallSender.item(
927
+ conn,
928
+ this.peer.format,
929
+ this.id.localId,
930
+ this._nextIndex,
931
+ item
932
+ );
933
+ }
724
934
  this._nextIndex++;
725
935
  }
726
- /** Send a batch of items to the client. */
936
+ /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
727
937
  sendBatch(items) {
728
938
  if (this._ended || items.length === 0) return;
729
939
  const conn = this.peer.connection;
730
- if (!conn) return;
731
- this.peer.hub.systemCallSender.batch(
732
- conn,
733
- this.peer.format,
734
- this.id.localId,
735
- this._nextIndex,
736
- items
737
- );
940
+ if (conn) {
941
+ this.peer.hub.systemCallSender.batch(
942
+ conn,
943
+ this.peer.format,
944
+ this.id.localId,
945
+ this._nextIndex,
946
+ items
947
+ );
948
+ }
738
949
  this._nextIndex += items.length;
739
950
  }
740
951
  /** Signal stream completion to the client. */
@@ -754,87 +965,313 @@ var RpcStreamSender = class _RpcStreamSender {
754
965
  }
755
966
  /**
756
967
  * Consume an AsyncIterable and send all items to the client.
757
- * Waits for the client's initial Ack before starting to pump items.
758
968
  *
759
- * When `isRealTime` is true, applies flow control based on `ackAdvance`:
760
- * if the sender is `ackAdvance` items ahead of the last ACK, it enters
761
- * skip mode — draining the source and discarding items until it finds
762
- * one where `canSkipTo` returns true.
969
+ * Dispatches to the non-real-time or real-time pump, matching .NET
970
+ * `RpcSharedStream<T>.OnRun` at
971
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:105-108.
763
972
  */
764
973
  async writeFrom(source) {
765
974
  await this._started.promise;
975
+ if (this._ended) return;
766
976
  const iterator = source[Symbol.asyncIterator]();
977
+ this._iterator = iterator;
978
+ const state = { iteratorDone: false };
767
979
  try {
768
- for (; ; ) {
769
- const next = await iterator.next();
770
- if (next.done || this._ended) break;
771
- const item = next.value;
772
- if (this._resetRequested) {
773
- if (!this.canSkipTo(item)) continue;
774
- this._resetRequested = false;
775
- this.sendItem(item);
776
- continue;
980
+ if (this.isRealTime) {
981
+ await this._runRealTime(iterator, state);
982
+ } else {
983
+ await this._runNonRealTime(iterator, state);
984
+ }
985
+ } finally {
986
+ if (state.iteratorDone && this._iterator === iterator) {
987
+ this._iterator = null;
988
+ }
989
+ }
990
+ }
991
+ /**
992
+ * Non-real-time pump — back-pressure through buffered ACKs.
993
+ *
994
+ * Direct port of .NET `OnRunNonRealTime` at
995
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:110-217.
996
+ */
997
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
998
+ async _runNonRealTime(iterator, state) {
999
+ const buffer = new RingBuffer(this.ackAdvance + 1);
1000
+ let bufferStart = 0;
1001
+ let isFullyBuffered = false;
1002
+ while (true) {
1003
+ let ack = this._tryProcessAcks();
1004
+ if (!ack) {
1005
+ await this._waitAckReady();
1006
+ if (this._ended) return;
1007
+ ack = this._tryProcessAcks();
1008
+ if (!ack) {
1009
+ return;
777
1010
  }
778
- if (this._nextIndex < this._lastAckedIndex + this.ackAdvance) {
779
- this.sendItem(item);
1011
+ }
1012
+ this._lastAckedIndex = ack.nextIndex;
1013
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1014
+ if (shift > 0) {
1015
+ buffer.moveHead(shift);
1016
+ bufferStart += shift;
1017
+ }
1018
+ if (this._nextIndex < bufferStart) {
1019
+ if (!this._ended)
1020
+ this.sendEnd(new Error("Stream position unavailable."));
1021
+ return;
1022
+ }
1023
+ let bufferIndex = this._nextIndex - bufferStart;
1024
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1025
+ while (this._nextIndex < maxIndex) {
1026
+ if (this._ended) return;
1027
+ if (bufferIndex < buffer.count) {
1028
+ const item = buffer.get(bufferIndex++);
1029
+ if (item.kind === "value") {
1030
+ this.sendItem(item.value);
1031
+ } else if (item.kind === "end") {
1032
+ if (!this._ended) this.sendEnd();
1033
+ return;
1034
+ } else {
1035
+ if (!this._ended) this.sendEnd(item.error);
1036
+ return;
1037
+ }
780
1038
  continue;
781
1039
  }
782
- if (!this.isRealTime) {
783
- await this._waitForAckBudget();
1040
+ if (isFullyBuffered) break;
1041
+ if (!buffer.hasRemainingCapacity) break;
1042
+ let pulled;
1043
+ try {
1044
+ const r = await iterator.next();
784
1045
  if (this._ended) return;
785
- this.sendItem(item);
786
- continue;
1046
+ if (r.done) {
1047
+ pulled = _endItem;
1048
+ isFullyBuffered = true;
1049
+ state.iteratorDone = true;
1050
+ } else {
1051
+ pulled = { kind: "value", value: r.value };
1052
+ }
1053
+ } catch (e) {
1054
+ state.iteratorDone = true;
1055
+ isFullyBuffered = true;
1056
+ pulled = {
1057
+ kind: "error",
1058
+ error: e instanceof Error ? e : new Error(String(e))
1059
+ };
787
1060
  }
788
- let latestSkipTarget = this.canSkipTo(item) ? item : void 0;
789
- let sourceExhausted = false;
790
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
1061
+ buffer.pushTail(pulled);
1062
+ }
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Real-time pump — drops stale items to stay close to the source's head.
1067
+ *
1068
+ * Direct port of .NET `OnRunRealTime` at
1069
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:219-420.
1070
+ */
1071
+ async _runRealTime(iterator, state) {
1072
+ const buffer = new RingBuffer(this.ackAdvance + 1);
1073
+ let bufferStart = 0;
1074
+ let isFullyBuffered = false;
1075
+ while (true) {
1076
+ let ack = this._tryProcessAcks();
1077
+ if (!ack) {
1078
+ await this._waitAckReady();
1079
+ if (this._ended) return;
1080
+ ack = this._tryProcessAcks();
1081
+ if (!ack) return;
1082
+ }
1083
+ this._lastAckedIndex = ack.nextIndex;
1084
+ if (ack.mustReset && !isFullyBuffered) {
1085
+ buffer.clear();
1086
+ bufferStart = this._nextIndex;
1087
+ while (true) {
791
1088
  if (this._ended) return;
792
- const n = await iterator.next();
793
- if (n.done) {
794
- sourceExhausted = true;
795
- break;
796
- }
797
- if (this.canSkipTo(n.value)) {
798
- latestSkipTarget = n.value;
1089
+ let item;
1090
+ let accepted = false;
1091
+ try {
1092
+ const r = await iterator.next();
1093
+ if (this._ended) return;
1094
+ if (r.done) {
1095
+ item = _endItem;
1096
+ isFullyBuffered = true;
1097
+ state.iteratorDone = true;
1098
+ } else if (this.canSkipTo(r.value)) {
1099
+ item = { kind: "value", value: r.value };
1100
+ accepted = true;
1101
+ } else {
1102
+ continue;
1103
+ }
1104
+ } catch (e) {
1105
+ state.iteratorDone = true;
1106
+ isFullyBuffered = true;
1107
+ item = {
1108
+ kind: "error",
1109
+ error: e instanceof Error ? e : new Error(String(e))
1110
+ };
799
1111
  }
1112
+ buffer.pushTail(item);
1113
+ if (accepted || isFullyBuffered) break;
800
1114
  }
1115
+ }
1116
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1117
+ if (shift > 0) {
1118
+ buffer.moveHead(shift);
1119
+ bufferStart += shift;
1120
+ }
1121
+ if (this._nextIndex < bufferStart) {
1122
+ if (!this._ended)
1123
+ this.sendEnd(new Error("Stream position unavailable."));
1124
+ return;
1125
+ }
1126
+ let bufferIndex = this._nextIndex - bufferStart;
1127
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1128
+ while (this._nextIndex < maxIndex) {
801
1129
  if (this._ended) return;
802
- if (latestSkipTarget !== void 0) {
803
- this.sendItem(latestSkipTarget);
1130
+ if (this._acks.length > 0) break;
1131
+ if (bufferIndex < buffer.count) {
1132
+ const item = buffer.get(bufferIndex++);
1133
+ if (item.kind === "value") {
1134
+ this.sendItem(item.value);
1135
+ } else if (item.kind === "end") {
1136
+ if (!this._ended) this.sendEnd();
1137
+ return;
1138
+ } else {
1139
+ if (!this._ended) this.sendEnd(item.error);
1140
+ return;
1141
+ }
1142
+ continue;
804
1143
  }
805
- if (sourceExhausted) {
806
- if (!this._ended) this.sendEnd();
807
- return;
1144
+ if (isFullyBuffered) break;
1145
+ if (!buffer.hasRemainingCapacity) break;
1146
+ let pulled;
1147
+ try {
1148
+ const r = await iterator.next();
1149
+ if (this._ended) return;
1150
+ if (r.done) {
1151
+ pulled = _endItem;
1152
+ isFullyBuffered = true;
1153
+ state.iteratorDone = true;
1154
+ } else {
1155
+ pulled = { kind: "value", value: r.value };
1156
+ }
1157
+ } catch (e) {
1158
+ state.iteratorDone = true;
1159
+ isFullyBuffered = true;
1160
+ pulled = {
1161
+ kind: "error",
1162
+ error: e instanceof Error ? e : new Error(String(e))
1163
+ };
808
1164
  }
1165
+ buffer.pushTail(pulled);
809
1166
  }
810
- if (!this._ended) {
811
- this.sendEnd();
812
- }
813
- } catch (e) {
814
- if (!this._ended) {
815
- this.sendEnd(e instanceof Error ? e : new Error(String(e)));
1167
+ if (this._nextIndex >= maxIndex && !isFullyBuffered && !this._ended) {
1168
+ let latestValue;
1169
+ let haveLatest = false;
1170
+ let exitReason = "ack";
1171
+ let exitError = null;
1172
+ drain: while (!this._ended) {
1173
+ if (this._acks.length > 0) {
1174
+ exitReason = "ack";
1175
+ break drain;
1176
+ }
1177
+ let r;
1178
+ try {
1179
+ r = await iterator.next();
1180
+ } catch (e) {
1181
+ state.iteratorDone = true;
1182
+ isFullyBuffered = true;
1183
+ exitReason = "error";
1184
+ exitError = e instanceof Error ? e : new Error(String(e));
1185
+ break drain;
1186
+ }
1187
+ if (this._ended) return;
1188
+ if (r.done) {
1189
+ state.iteratorDone = true;
1190
+ isFullyBuffered = true;
1191
+ exitReason = "end";
1192
+ break drain;
1193
+ }
1194
+ if (this.canSkipTo(r.value)) {
1195
+ latestValue = r.value;
1196
+ haveLatest = true;
1197
+ }
1198
+ }
1199
+ if (exitReason === "end" || exitReason === "error") {
1200
+ if (haveLatest && !this._ended) this.sendItem(latestValue);
1201
+ if (!this._ended) this.sendEnd(exitError);
1202
+ return;
1203
+ }
1204
+ if (haveLatest) {
1205
+ const wasFull = buffer.isFull;
1206
+ buffer.pushTailAndMoveHeadIfFull({ kind: "value", value: latestValue });
1207
+ if (wasFull) bufferStart++;
1208
+ }
816
1209
  }
817
1210
  }
818
1211
  }
819
- /** Wait until _nextIndex < _lastAckedIndex + ackAdvance. */
820
- async _waitForAckBudget() {
821
- while (this._nextIndex >= this._lastAckedIndex + this.ackAdvance) {
822
- if (this._ended) return;
823
- this._ackWaiting = new PromiseSource3();
824
- await this._ackWaiting.promise;
1212
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1213
+ /** Drain all queued ACKs, returning the most recent one (or null if none). */
1214
+ _tryProcessAcks() {
1215
+ if (this._acks.length === 0) return null;
1216
+ let last = null;
1217
+ while (this._acks.length > 0) {
1218
+ const a = this._acks.shift();
1219
+ last = a;
1220
+ if (a.mustReset || this._nextIndex < a.nextIndex) {
1221
+ this._nextIndex = a.nextIndex;
1222
+ }
825
1223
  }
1224
+ return last;
1225
+ }
1226
+ /** Wait for the ACK queue to have at least one entry (or `_ended`). */
1227
+ async _waitAckReady() {
1228
+ if (this._acks.length > 0 || this._ended) return;
1229
+ this._whenAckReady = new PromiseSource3();
1230
+ await this._whenAckReady.promise;
1231
+ this._whenAckReady = null;
826
1232
  }
827
1233
  // -- IRpcObject --
828
1234
  reconnect() {
829
1235
  }
830
1236
  disconnect() {
1237
+ if (this._ended) return;
831
1238
  this._ended = true;
1239
+ this._abortController.abort();
832
1240
  if (!this._started.isCompleted) {
833
1241
  this._started.resolve();
834
1242
  }
1243
+ if (this._whenAckReady) {
1244
+ this._whenAckReady.resolve();
1245
+ this._whenAckReady = null;
1246
+ }
835
1247
  this.peer.sharedObjects.unregister(this);
1248
+ const iterator = this._iterator;
1249
+ if (!iterator) return;
1250
+ if (!this.sourceUsesAbortSignal) {
1251
+ this._iterator = null;
1252
+ this._forceCloseIterator(iterator);
1253
+ return;
1254
+ }
1255
+ setTimeout(() => {
1256
+ if (this._iterator === iterator) {
1257
+ this._iterator = null;
1258
+ this._forceCloseIterator(iterator);
1259
+ }
1260
+ }, RpcStream.disconnectGracePeriodMs);
1261
+ }
1262
+ _forceCloseIterator(iterator) {
1263
+ try {
1264
+ const ret = iterator.return?.(void 0);
1265
+ if (ret) void ret.catch(() => {
1266
+ });
1267
+ } catch {
1268
+ }
836
1269
  }
837
1270
  };
1271
+ var _endItem = { kind: "end" };
1272
+ function _clamp(value, min, max) {
1273
+ return value < min ? min : value > max ? max : value;
1274
+ }
838
1275
 
839
1276
  // src/rpc-stream.ts
840
1277
  var DEFAULT_ACK_PERIOD2 = 30;
@@ -870,6 +1307,15 @@ function parseStreamRef(value) {
870
1307
  return null;
871
1308
  }
872
1309
  var RpcStream = class _RpcStream {
1310
+ /**
1311
+ * Grace period (ms) given to a factory-form local source to honor its
1312
+ * AbortSignal and exit naturally before the sender force-closes the
1313
+ * iterator via `iterator.return()`.
1314
+ *
1315
+ * Only applies to sources provided as `(abortSignal) => AsyncIterable<T>`.
1316
+ * Plain `AsyncIterable<T>` sources are force-closed immediately on disconnect.
1317
+ */
1318
+ static disconnectGracePeriodMs = 100;
873
1319
  id;
874
1320
  kind;
875
1321
  allowReconnect;
@@ -896,7 +1342,7 @@ var RpcStream = class _RpcStream {
896
1342
  _iterating = false;
897
1343
  _ackSentUpTo = 0;
898
1344
  constructor(sourceOrRef, optionsOrPeer) {
899
- if (_isAsyncIterable(sourceOrRef)) {
1345
+ if (_isStreamSource(sourceOrRef)) {
900
1346
  const options = optionsOrPeer ?? {};
901
1347
  this.kind = RpcObjectKind.Local;
902
1348
  this.localSource = sourceOrRef;
@@ -947,17 +1393,20 @@ var RpcStream = class _RpcStream {
947
1393
  throw new Error("toRef() was already called with a different peer.");
948
1394
  return this._toRefValue;
949
1395
  }
1396
+ const isFactory = typeof this.localSource === "function";
950
1397
  const sender = new RpcStreamSender(
951
1398
  peer,
952
1399
  this.ackPeriod,
953
1400
  this.ackAdvance,
954
1401
  this.allowReconnect,
955
1402
  this.isRealTime,
956
- this.canSkipTo
1403
+ this.canSkipTo,
1404
+ isFactory
957
1405
  );
958
1406
  peer.sharedObjects.register(sender);
959
1407
  this._sender = sender;
960
- this._whenSent = sender.writeFrom(this.localSource);
1408
+ const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
1409
+ this._whenSent = sender.writeFrom(source);
961
1410
  this._toRefValue = peer.format.isBinary ? {
962
1411
  SerializedId: [sender.id.hostId, sender.id.localId],
963
1412
  AckPeriod: sender.ackPeriod,
@@ -1037,7 +1486,10 @@ var RpcStream = class _RpcStream {
1037
1486
  }
1038
1487
  }
1039
1488
  disconnect() {
1040
- if (this.kind === RpcObjectKind.Local) return;
1489
+ if (this.kind === RpcObjectKind.Local) {
1490
+ this._sender?.disconnect();
1491
+ return;
1492
+ }
1041
1493
  if (this._completed) return;
1042
1494
  this._completed = true;
1043
1495
  this._completionError = new Error("Peer disconnected.");
@@ -1046,7 +1498,8 @@ var RpcStream = class _RpcStream {
1046
1498
  // -- AsyncIterable --
1047
1499
  [Symbol.asyncIterator]() {
1048
1500
  if (this.kind === RpcObjectKind.Local) {
1049
- return this.localSource[Symbol.asyncIterator]();
1501
+ const source = typeof this.localSource === "function" ? this.localSource(new AbortController().signal) : this.localSource;
1502
+ return source[Symbol.asyncIterator]();
1050
1503
  }
1051
1504
  if (this._iterating)
1052
1505
  throw new Error("RpcStream can only be iterated once.");
@@ -1127,7 +1580,8 @@ var RpcStream = class _RpcStream {
1127
1580
  }
1128
1581
  }
1129
1582
  };
1130
- function _isAsyncIterable(value) {
1583
+ function _isStreamSource(value) {
1584
+ if (typeof value === "function") return true;
1131
1585
  return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
1132
1586
  }
1133
1587
  function resolveStreamRefs(value, peer) {
@@ -1161,6 +1615,25 @@ function resolveStreamRefs(value, peer) {
1161
1615
  return value;
1162
1616
  }
1163
1617
 
1618
+ // src/base64.ts
1619
+ function base64Encode(bytes) {
1620
+ const chunkSize = 32768;
1621
+ let binary = "";
1622
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1623
+ binary += String.fromCharCode.apply(
1624
+ null,
1625
+ bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1626
+ );
1627
+ }
1628
+ return btoa(binary);
1629
+ }
1630
+ function base64Decode(encoded) {
1631
+ const binary = atob(encoded);
1632
+ const out = new Uint8Array(binary.length);
1633
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1634
+ return out;
1635
+ }
1636
+
1164
1637
  // src/rpc-system-call-handler.ts
1165
1638
  var RpcSystemCallHandler = class {
1166
1639
  handle(message, args, peer) {
@@ -1170,6 +1643,7 @@ var RpcSystemCallHandler = class {
1170
1643
  case RpcSystemCalls.ok: {
1171
1644
  const call = peer.outbound.get(relatedId);
1172
1645
  if (call !== void 0) {
1646
+ call.completedStage |= RpcCallStage.ResultReady;
1173
1647
  if (call.removeOnOk) {
1174
1648
  peer.outbound.remove(relatedId);
1175
1649
  }
@@ -1177,6 +1651,10 @@ var RpcSystemCallHandler = class {
1177
1651
  }
1178
1652
  break;
1179
1653
  }
1654
+ case RpcSystemCalls.reconnect: {
1655
+ this._handleReconnect(relatedId, args, peer);
1656
+ break;
1657
+ }
1180
1658
  case RpcSystemCalls.error: {
1181
1659
  const call = peer.outbound.remove(relatedId);
1182
1660
  if (call !== void 0) {
@@ -1265,7 +1743,54 @@ var RpcSystemCallHandler = class {
1265
1743
  }
1266
1744
  }
1267
1745
  }
1746
+ /**
1747
+ * Server-side handler for the `$sys.Reconnect` call. Parses the peer's
1748
+ * report of in-flight call IDs (grouped by stage, IncreasingSeqCompressor-
1749
+ * encoded, base64-wrapped for JSON transport), computes the subset this
1750
+ * peer does not recognize, and replies with a compressed byte[] of the
1751
+ * unknown IDs wrapped in `$sys.Ok`.
1752
+ *
1753
+ * @param relatedId The outbound call ID the client used for this
1754
+ * `$sys.Reconnect` invocation — we send `$sys.Ok` back
1755
+ * with the same `RelatedId`.
1756
+ */
1757
+ _handleReconnect(relatedId, args, peer) {
1758
+ if (peer.connection === void 0) return;
1759
+ let unknownIds = [];
1760
+ try {
1761
+ const stagesRaw = args[1];
1762
+ if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1763
+ const unknownSet = /* @__PURE__ */ new Set();
1764
+ for (const [, rawValue] of Object.entries(stagesRaw)) {
1765
+ const bytes = _toBytes(rawValue);
1766
+ if (bytes === null) continue;
1767
+ const callIds = IncreasingSeqCompressor.deserialize(bytes);
1768
+ for (const callId of callIds) {
1769
+ if (peer.inbound.get(callId) === void 0) {
1770
+ unknownSet.add(callId);
1771
+ }
1772
+ }
1773
+ }
1774
+ unknownIds = [...unknownSet].sort((a, b) => a - b);
1775
+ }
1776
+ } catch {
1777
+ }
1778
+ const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1779
+ const responseValue = peer.format.isBinary ? responseBytes : base64Encode(responseBytes);
1780
+ peer.hub.systemCallSender.ok(peer.connection, peer.format, relatedId, responseValue);
1781
+ }
1268
1782
  };
1783
+ function _toBytes(value) {
1784
+ if (value instanceof Uint8Array) return value;
1785
+ if (typeof value === "string") {
1786
+ try {
1787
+ return base64Decode(value);
1788
+ } catch {
1789
+ return null;
1790
+ }
1791
+ }
1792
+ return null;
1793
+ }
1269
1794
 
1270
1795
  // src/rpc-system-call-sender.ts
1271
1796
  var RpcSystemCallSender = class _RpcSystemCallSender {
@@ -1656,13 +2181,21 @@ var RpcPeer = class {
1656
2181
  this._startKeepAlive();
1657
2182
  });
1658
2183
  }
1659
- /** The serialization format used by this peer. Override in subclasses. */
2184
+ /**
2185
+ * The serialization format used by this peer for outgoing messages.
2186
+ * If not set explicitly, defaults to the resolver's default (JSON /
2187
+ * json5np). Subclasses can pre-assign in their constructor.
2188
+ */
2189
+ _format;
1660
2190
  get format() {
1661
- return RpcSerializationFormatResolver.Default.defaultFormat;
2191
+ return this._format ?? RpcSerializationFormatResolver.Default.defaultFormat;
2192
+ }
2193
+ set format(value) {
2194
+ this._format = value;
1662
2195
  }
1663
2196
  call(method, args, options) {
1664
2197
  const callId = this.outbound.nextId();
1665
- const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method);
2198
+ const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method, options?.remoteExecutionMode ?? 7);
1666
2199
  const envelope = {
1667
2200
  Method: method,
1668
2201
  RelatedId: callId,
@@ -1767,8 +2300,16 @@ var RpcPeer = class {
1767
2300
  }
1768
2301
  const method = message.Method ?? "";
1769
2302
  if (method === RpcSystemCalls.handshake) {
1770
- const handshake = args[0];
1771
- if (handshake !== void 0) this._onHandshakeReceived(handshake);
2303
+ const raw = args[0];
2304
+ if (raw !== void 0) {
2305
+ const handshake = Array.isArray(raw) ? {
2306
+ RemotePeerId: raw[0],
2307
+ RemoteHubId: raw[2],
2308
+ ProtocolVersion: raw[3],
2309
+ Index: raw[4]
2310
+ } : raw;
2311
+ this._onHandshakeReceived(handshake);
2312
+ }
1772
2313
  return;
1773
2314
  }
1774
2315
  if (method.startsWith("$sys")) {
@@ -1858,6 +2399,12 @@ var RpcClientPeer = class extends RpcPeer {
1858
2399
  _connectionKind = 0 /* Disconnected */;
1859
2400
  _tryIndex = 0;
1860
2401
  _handshakeIndex = 0;
2402
+ /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
2403
+ * to the server in `$sys.Reconnect:3` so the server can verify we're
2404
+ * talking about the current connection. Mirrors .NET's
2405
+ * `connectionState.Handshake.Index` path in
2406
+ * `RpcOutboundCallTracker.TryReconnect`. */
2407
+ _remoteHandshakeIndex = 0;
1861
2408
  _lastRemoteHubId;
1862
2409
  _pendingHandshake;
1863
2410
  _reconnectsAt = 0;
@@ -1884,24 +2431,24 @@ var RpcClientPeer = class extends RpcPeer {
1884
2431
  this._reconnectsAt = value;
1885
2432
  this.reconnectsAtChanged.trigger();
1886
2433
  }
1887
- _format;
1888
2434
  constructor(hub, url, serializationFormat) {
1889
2435
  super(url, hub);
1890
2436
  this.clientId = guidToBase64Url(this.id);
1891
2437
  const resolver = RpcSerializationFormatResolver.Default;
1892
2438
  this.serializationFormat = serializationFormat ?? resolver.defaultFormatKey;
1893
- this._format = resolver.get(this.serializationFormat);
1894
- }
1895
- get format() {
1896
- return this._format;
2439
+ this.format = resolver.get(this.serializationFormat);
1897
2440
  }
1898
2441
  get connectionKind() {
1899
2442
  return this._connectionKind;
1900
2443
  }
1901
- /** One-shot connection for tests — no reconnection loop, no handshake. */
1902
- connectWith(conn) {
2444
+ /** One-shot connection for tests — no reconnection loop, no handshake.
2445
+ * @param isPeerChanged Whether to treat this as a peer-identity change
2446
+ * (default `true` — no handshake was exchanged, so stage-3 compute calls
2447
+ * self-invalidate and remote/shared objects are disposed). Pass `false`
2448
+ * to simulate a same-peer reconnect in tests. */
2449
+ connectWith(conn, isPeerChanged = true) {
1903
2450
  this.setupConnection(conn);
1904
- this._reconnect(true);
2451
+ void this._reconnect(isPeerChanged);
1905
2452
  }
1906
2453
  /** Start the reconnection loop — runs until disposed. */
1907
2454
  async run(wsFactory) {
@@ -1945,6 +2492,7 @@ var RpcClientPeer = class extends RpcPeer {
1945
2492
  closedRejection
1946
2493
  ]);
1947
2494
  this._pendingHandshake = void 0;
2495
+ this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
1948
2496
  const remoteHubId = remoteHandshake.RemoteHubId;
1949
2497
  let isPeerChanged = false;
1950
2498
  if (remoteHubId !== void 0) {
@@ -1958,7 +2506,7 @@ var RpcClientPeer = class extends RpcPeer {
1958
2506
  this._connection = conn;
1959
2507
  this._connectionKind = 2 /* Connected */;
1960
2508
  this._tryIndex = 0;
1961
- this._reconnect(isPeerChanged);
2509
+ await this._reconnect(isPeerChanged);
1962
2510
  await new Promise((r) => conn.closed.add(() => r()));
1963
2511
  } catch {
1964
2512
  }
@@ -1983,31 +2531,129 @@ var RpcClientPeer = class extends RpcPeer {
1983
2531
  }
1984
2532
  }
1985
2533
  }
1986
- /** Re-send outbound calls after reconnection + flush pending sends.
1987
- * Stage-3 compute calls are always self-invalidated: without $sys.Reconnect
1988
- * protocol, the server's invalidation tracking is lost on disconnect, and
1989
- * re-sending would get a duplicate $sys.Ok that is ignored (PromiseSource
1990
- * already resolved). Self-invalidation forces a fresh recompute that
1991
- * establishes new invalidation tracking on the new connection.
1992
- * Regular in-flight calls are re-sent transparently. */
1993
- _reconnect(_isPeerChanged) {
2534
+ /**
2535
+ * Reconcile outbound calls after reconnection, then flush pending sends.
2536
+ *
2537
+ * On peer change (different server hubId) or with binary wire format —
2538
+ * we blind-resend every eligible call.
2539
+ *
2540
+ * On a same-peer reconnect with JSON transport, first call
2541
+ * `$sys.Reconnect` to ask the server which call IDs it no longer
2542
+ * recognizes (e.g. inbound tracker was cleared, or the call never
2543
+ * reached the server). Only those get resent; the rest stay in the
2544
+ * tracker awaiting their $sys.Ok. This prevents duplicate server-side
2545
+ * invocation of in-flight streaming calls on every transient
2546
+ * reconnect — mirrors .NET `RpcOutboundCallTracker.Reconnect` at
2547
+ * src/ActualLab.Rpc/Infrastructure/RpcCallTrackers.cs:209-279.
2548
+ *
2549
+ * Stage-3 compute calls (result already resolved, awaiting invalidation)
2550
+ * are always self-invalidated — TS doesn't track compute-call stages
2551
+ * across reconnect, so the safest behavior is to force a fresh compute.
2552
+ */
2553
+ async _reconnect(isPeerChanged) {
1994
2554
  if (this._connection === void 0) return;
1995
- if (_isPeerChanged) {
2555
+ if (isPeerChanged) {
1996
2556
  this.remoteObjects.disconnectAll();
2557
+ this.sharedObjects.disconnectAll();
1997
2558
  } else {
1998
2559
  this.remoteObjects.reconnectAll();
1999
2560
  }
2000
- const trackerCalls = [...this.outbound.values()];
2001
- for (const call of trackerCalls) {
2561
+ const eligible = [];
2562
+ for (const call of [...this.outbound.values()]) {
2563
+ const mode = call.remoteExecutionMode;
2564
+ if (!(mode & 2)) {
2565
+ if (!call.result.isCompleted)
2566
+ call.result.reject(new Error(
2567
+ "Outbound call failed: disconnected and AllowReconnect is not set."
2568
+ ));
2569
+ call.onDisconnect();
2570
+ this.outbound.remove(call.callId);
2571
+ continue;
2572
+ }
2573
+ if (isPeerChanged && !(mode & 4)) {
2574
+ if (!call.result.isCompleted)
2575
+ call.result.reject(new Error(
2576
+ "Outbound call failed: reconnected to a different peer and AllowResend is not set."
2577
+ ));
2578
+ call.onDisconnect();
2579
+ this.outbound.remove(call.callId);
2580
+ continue;
2581
+ }
2582
+ eligible.push(call);
2583
+ }
2584
+ let unknownIds = null;
2585
+ if (!isPeerChanged && eligible.length > 0) {
2586
+ unknownIds = await this._reconcileReconnect(eligible);
2587
+ }
2588
+ for (const call of eligible) {
2002
2589
  if (!call.removeOnOk && call.result.isCompleted) {
2003
2590
  call.onDisconnect();
2004
2591
  this.outbound.remove(call.callId);
2005
- } else {
2006
- this._sendWireData(call.serializedWireData);
2592
+ continue;
2593
+ }
2594
+ if (unknownIds !== null && !unknownIds.has(call.callId)) {
2595
+ continue;
2007
2596
  }
2597
+ this._sendWireData(call.serializedWireData);
2008
2598
  }
2009
2599
  this._flushPendingSends();
2010
2600
  }
2601
+ /**
2602
+ * Ask the server which call IDs in `eligible` it no longer recognizes
2603
+ * via `$sys.Reconnect:3`. Returns the set of call IDs that need to be
2604
+ * resent. On any failure, falls back to "resend everything".
2605
+ *
2606
+ * Wire format of the `completedStages` argument is chosen per wire format:
2607
+ * - JSON (json5np): plain object `{"<stage>": "<base64-of-bytes>"}` —
2608
+ * matches .NET `System.Text.Json`'s default for `Dictionary<int, byte[]>`.
2609
+ * - MessagePack (msgpack6/msgpack6c): `Map<number, Uint8Array>` —
2610
+ * the patched encoder (see `./msgpack-map-patch.ts`) writes this as a
2611
+ * native msgpack `map<int, bin>`, matching .NET MessagePack-CSharp's
2612
+ * default for `Dictionary<int, byte[]>`.
2613
+ */
2614
+ async _reconcileReconnect(eligible) {
2615
+ try {
2616
+ const byStage = /* @__PURE__ */ new Map();
2617
+ for (const call of eligible) {
2618
+ const stage = call.getReconnectStage(false);
2619
+ if (stage === null) continue;
2620
+ let arr = byStage.get(stage);
2621
+ if (!arr) {
2622
+ arr = [];
2623
+ byStage.set(stage, arr);
2624
+ }
2625
+ arr.push(call.callId);
2626
+ }
2627
+ if (byStage.size === 0) {
2628
+ return new Set(eligible.map((c) => c.callId));
2629
+ }
2630
+ const isBinary = this.format.isBinary;
2631
+ const stagesArg = isBinary ? /* @__PURE__ */ new Map() : {};
2632
+ for (const [stage, ids] of byStage) {
2633
+ ids.sort((a, b) => a - b);
2634
+ const bytes2 = IncreasingSeqCompressor.serialize(ids);
2635
+ if (isBinary) {
2636
+ stagesArg.set(stage, bytes2);
2637
+ } else {
2638
+ stagesArg[String(stage)] = base64Encode(bytes2);
2639
+ }
2640
+ }
2641
+ const reconnectCall = this.call(
2642
+ RpcSystemCalls.reconnect,
2643
+ [this._remoteHandshakeIndex, stagesArg],
2644
+ {
2645
+ remoteExecutionMode: 1
2646
+ /* AwaitForConnection only; must not itself reconnect */
2647
+ }
2648
+ );
2649
+ const result = await reconnectCall.result.promise;
2650
+ const bytes = _toBytes2(result);
2651
+ if (bytes === null) return new Set(eligible.map((c) => c.callId));
2652
+ return new Set(IncreasingSeqCompressor.deserialize(bytes));
2653
+ } catch {
2654
+ return new Set(eligible.map((c) => c.callId));
2655
+ }
2656
+ }
2011
2657
  _onHandshakeReceived(handshake) {
2012
2658
  this._pendingHandshake?.resolve(handshake);
2013
2659
  }
@@ -2023,6 +2669,9 @@ var RpcServerPeer = class extends RpcPeer {
2023
2669
  /** Accept an incoming connection — sets up message handling and handshake response. */
2024
2670
  accept(conn) {
2025
2671
  this.setupConnection(conn);
2672
+ conn.closed.add(() => {
2673
+ this.sharedObjects.disconnectAll();
2674
+ });
2026
2675
  }
2027
2676
  _onHandshakeReceived(_handshake) {
2028
2677
  if (this._connection !== void 0) {
@@ -2052,6 +2701,17 @@ function guidToBase64Url(uuid) {
2052
2701
  const binary = String.fromCharCode(...bytes);
2053
2702
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2054
2703
  }
2704
+ function _toBytes2(value) {
2705
+ if (value instanceof Uint8Array) return value;
2706
+ if (typeof value === "string") {
2707
+ try {
2708
+ return base64Decode(value);
2709
+ } catch {
2710
+ return null;
2711
+ }
2712
+ }
2713
+ return null;
2714
+ }
2055
2715
 
2056
2716
  // src/rpc-service-host.ts
2057
2717
  var RpcServiceHost = class {
@@ -2102,7 +2762,8 @@ function rpcMethod(options) {
2102
2762
  methods[methodName] = {
2103
2763
  ...methods[methodName],
2104
2764
  argCount: target.length,
2105
- returns: options?.returns
2765
+ returns: options?.returns,
2766
+ remoteExecutionMode: options?.remoteExecutionMode
2106
2767
  };
2107
2768
  return target;
2108
2769
  };
@@ -2717,7 +3378,8 @@ var RpcHub = class {
2717
3378
  const methods = /* @__PURE__ */ new Map();
2718
3379
  for (const [name, byArgCount] of overloads) {
2719
3380
  if (byArgCount.size === 1) {
2720
- methods.set(name, byArgCount.values().next().value);
3381
+ const [[, singleFn]] = byArgCount;
3382
+ methods.set(name, singleFn);
2721
3383
  } else {
2722
3384
  methods.set(name, (...args) => {
2723
3385
  const fn = byArgCount.get(args.length);
@@ -2747,6 +3409,7 @@ var RpcHub = class {
2747
3409
  for (const [name, meta] of Object.entries(methodsMeta)) {
2748
3410
  const wireArgCount = meta.argCount + 1;
2749
3411
  const mapKey = `${name}:${wireArgCount}`;
3412
+ const isNoWait = meta.returns === RpcType.noWait;
2750
3413
  methods.set(mapKey, {
2751
3414
  name,
2752
3415
  serviceName: svcMeta.name,
@@ -2754,7 +3417,8 @@ var RpcHub = class {
2754
3417
  wireArgCount,
2755
3418
  callTypeId: 0,
2756
3419
  stream: meta.returns === RpcType.stream,
2757
- noWait: meta.returns === RpcType.noWait
3420
+ noWait: isNoWait,
3421
+ remoteExecutionMode: isNoWait ? 0 : meta.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
2758
3422
  });
2759
3423
  }
2760
3424
  return { name: svcMeta.name, methods };
@@ -2772,6 +3436,8 @@ var RpcHub = class {
2772
3436
  /** Create a client method for the given methodDef. Override in FusionHub for custom call types. */
2773
3437
  _createClientMethod(peer, methodDef) {
2774
3438
  const wireName = wireMethodName(methodDef);
3439
+ const mode = methodDef.remoteExecutionMode;
3440
+ const mustCheckConnection = !(mode & RpcRemoteExecutionMode.AwaitForConnection);
2775
3441
  if (methodDef.noWait) {
2776
3442
  return (...args) => {
2777
3443
  const callArgs = args.slice(0, methodDef.argCount);
@@ -2780,6 +3446,8 @@ var RpcHub = class {
2780
3446
  }
2781
3447
  if (methodDef.stream) {
2782
3448
  return async (...args) => {
3449
+ if (mustCheckConnection && !peer.isConnected)
3450
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2783
3451
  const callArgs = args.slice(0, methodDef.argCount);
2784
3452
  const outboundCall = peer.call(wireName, callArgs);
2785
3453
  const result = await outboundCall.result.promise;
@@ -2793,8 +3461,10 @@ var RpcHub = class {
2793
3461
  return stream;
2794
3462
  };
2795
3463
  }
2796
- const callOptions = methodDef.callTypeId !== 0 ? { callTypeId: methodDef.callTypeId } : void 0;
3464
+ const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
2797
3465
  return async (...args) => {
3466
+ if (mustCheckConnection && !peer.isConnected)
3467
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2798
3468
  const callArgs = args.slice(0, methodDef.argCount);
2799
3469
  const outboundCall = peer.call(wireName, callArgs, callOptions);
2800
3470
  const result = await outboundCall.result.promise;
@@ -2817,7 +3487,7 @@ function createRpcClient(peer, serviceDef) {
2817
3487
  const methods = /* @__PURE__ */ new Map();
2818
3488
  for (const [name, byArgCount] of overloads) {
2819
3489
  if (byArgCount.size === 1) {
2820
- const methodDef = byArgCount.values().next().value;
3490
+ const [[, methodDef]] = byArgCount;
2821
3491
  methods.set(name, createMethod(peer, methodDef));
2822
3492
  } else {
2823
3493
  const fns = /* @__PURE__ */ new Map();
@@ -2842,10 +3512,14 @@ function createRpcClient(peer, serviceDef) {
2842
3512
  });
2843
3513
  }
2844
3514
  function createMethod(peer, methodDef) {
3515
+ const mode = methodDef.remoteExecutionMode;
3516
+ const mustCheckConnection = !(mode & RpcRemoteExecutionMode.AwaitForConnection);
2845
3517
  return async (...args) => {
3518
+ if (mustCheckConnection && !peer.isConnected)
3519
+ throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
2846
3520
  const callArgs = args.slice(0, methodDef.argCount);
2847
3521
  const wireName = wireMethodName(methodDef);
2848
- const options = methodDef.callTypeId !== 0 ? { callTypeId: methodDef.callTypeId } : void 0;
3522
+ const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
2849
3523
  const outboundCall = peer.call(wireName, callArgs, options);
2850
3524
  return outboundCall.result.promise;
2851
3525
  };
@@ -3040,7 +3714,9 @@ export {
3040
3714
  ARG_DELIMITER,
3041
3715
  ENVELOPE_DELIMITER,
3042
3716
  FRAME_DELIMITER,
3717
+ IncreasingSeqCompressor,
3043
3718
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
3719
+ RpcCallStage,
3044
3720
  RpcCallTypeId,
3045
3721
  RpcClientPeer,
3046
3722
  RpcClientPeerReconnectDelayer,
@@ -3059,6 +3735,7 @@ export {
3059
3735
  RpcPeerConnectionKind,
3060
3736
  RpcPeerStateKind,
3061
3737
  RpcPeerStateMonitor,
3738
+ RpcRemoteExecutionMode,
3062
3739
  RpcRemoteObjectTracker,
3063
3740
  RpcSerializationFormat,
3064
3741
  RpcSerializationFormatResolver,