@actuallab/rpc 13.0.25 → 13.0.167

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
@@ -165,6 +165,24 @@ function readVarUint(data, offset) {
165
165
  } while (shift < 35);
166
166
  return { value: value >>> 0, bytesRead };
167
167
  }
168
+ function skipHeaders(data, pos, headerCount) {
169
+ for (let h = 0; h < headerCount; h++) {
170
+ const keyLen = data[pos++];
171
+ pos += keyLen;
172
+ const valLen = readVarUint(data, pos);
173
+ pos += valLen.bytesRead + valLen.value;
174
+ }
175
+ return pos;
176
+ }
177
+ function readPolymorphismMarker(data, offset) {
178
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
179
+ const length = view.getUint16(offset, true);
180
+ if (length === 0)
181
+ return { bytesRead: 2, isNull: false };
182
+ if (length === 1)
183
+ return { bytesRead: 2, isNull: true };
184
+ throw new Error("polymorphic payloads are not supported by the TS client");
185
+ }
168
186
  function concatUint8Arrays(arrays) {
169
187
  let totalLength = 0;
170
188
  for (const arr of arrays) totalLength += arr.length;
@@ -228,14 +246,6 @@ function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder)
228
246
  pos += methodLen.value;
229
247
  const argDataLen = view.getInt32(pos, true);
230
248
  pos += 4;
231
- if (headerCount > 0) {
232
- for (let h = 0; h < headerCount; h++) {
233
- const keyLen = data[pos++];
234
- pos += keyLen;
235
- const valLen = readVarUint(data, pos);
236
- pos += valLen.bytesRead + valLen.value;
237
- }
238
- }
239
249
  const args = [];
240
250
  const argEnd = pos + argDataLen;
241
251
  if (argDataLen > 0) {
@@ -243,8 +253,10 @@ function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder)
243
253
  for (const decoded of decoder.decodeMulti(argSlice)) {
244
254
  args.push(decoded);
245
255
  }
246
- pos = argEnd;
247
256
  }
257
+ pos = argEnd;
258
+ if (headerCount > 0)
259
+ pos = skipHeaders(data, pos, headerCount);
248
260
  const message = {
249
261
  Method: method,
250
262
  RelatedId: relId.value,
@@ -322,14 +334,6 @@ function deserializeCompactBinaryMessage(data, offset, registry, decoder = defau
322
334
  const method = registry?.getName(methodHash | 0) ?? `<hash:0x${methodHash.toString(16).padStart(8, "0")}>`;
323
335
  const argDataLen = view.getInt32(pos, true);
324
336
  pos += 4;
325
- if (headerCount > 0) {
326
- for (let h = 0; h < headerCount; h++) {
327
- const keyLen = data[pos++];
328
- pos += keyLen;
329
- const valLen = readVarUint(data, pos);
330
- pos += valLen.bytesRead + valLen.value;
331
- }
332
- }
333
337
  const args = [];
334
338
  const argEnd = pos + argDataLen;
335
339
  if (argDataLen > 0) {
@@ -337,8 +341,10 @@ function deserializeCompactBinaryMessage(data, offset, registry, decoder = defau
337
341
  for (const decoded of decoder.decodeMulti(argSlice)) {
338
342
  args.push(decoded);
339
343
  }
340
- pos = argEnd;
341
344
  }
345
+ pos = argEnd;
346
+ if (headerCount > 0)
347
+ pos = skipHeaders(data, pos, headerCount);
342
348
  const message = {
343
349
  Method: method,
344
350
  RelatedId: relId.value,
@@ -608,7 +614,8 @@ function defineRpcService(name, methods) {
608
614
  callTypeId: input.callTypeId ?? 0,
609
615
  stream: input.returns === RpcType.stream,
610
616
  noWait: isNoWait,
611
- remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
617
+ remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default,
618
+ timeouts: input.timeouts
612
619
  });
613
620
  }
614
621
  return { name, methods: map };
@@ -642,6 +649,14 @@ var RpcOutboundCall = class {
642
649
  removeOnOk = true;
643
650
  /** The serialized wire data — stored for re-sending on reconnect. */
644
651
  serializedWireData = "";
652
+ /** Per-call connect/run timeouts enforced by the peer's maintenance loop
653
+ * (R12). `undefined` means unbounded (the query default). */
654
+ timeouts;
655
+ /** `Date.now()` when this call was created (used for the connect timeout). */
656
+ startedAt = Date.now();
657
+ /** `Date.now()` when this call was last put on the wire, or 0 if it hasn't
658
+ * been sent yet (used for the run timeout). */
659
+ sentAt = 0;
645
660
  /**
646
661
  * Bitfield of {@link RpcCallStage} flags indicating how far this call
647
662
  * has progressed. Used by the `$sys.Reconnect` protocol to tell the
@@ -731,20 +746,77 @@ var RpcInboundCall = class {
731
746
  callId;
732
747
  method;
733
748
  args;
749
+ /** 0 = still processing, 1 = result computed and sent at least once. */
750
+ completedStage = 0;
751
+ _resend;
752
+ _abortController = new AbortController();
734
753
  constructor(callId, method, args) {
735
754
  this.callId = callId;
736
755
  this.method = method;
737
756
  this.args = args;
738
757
  }
758
+ /** Aborted when the remote peer cancels this call via `$sys.Cancel` (R17).
759
+ * Threaded into the service handler so it can abort in-flight work. */
760
+ get signal() {
761
+ return this._abortController.signal;
762
+ }
763
+ /** True once the remote peer cancelled this call — the response is then
764
+ * suppressed even though the call may still be registered (R9 dedup). */
765
+ get isCancelled() {
766
+ return this._abortController.signal.aborted;
767
+ }
768
+ cancel() {
769
+ this._abortController.abort();
770
+ }
771
+ // Records the closure that re-sends this call's already-computed result;
772
+ // marks the call as completed. Mirrors .NET's post-`ProcessStage1Plus` state.
773
+ setResult(resend) {
774
+ this._resend = resend;
775
+ this.completedStage = 1;
776
+ }
777
+ // Re-sends the result to the remote peer if it was already computed.
778
+ // A resent frame for an in-flight call is a no-op — the original dispatch
779
+ // will send the result when it completes. Mirrors .NET
780
+ // `RpcInboundCall.TryReprocess`.
781
+ resendResult() {
782
+ if (this.completedStage >= 1)
783
+ this._resend?.();
784
+ }
739
785
  };
740
786
  var RpcInboundCallTracker = class {
741
787
  _calls = /* @__PURE__ */ new Map();
788
+ _completedIds = [];
789
+ /** Cap on retained completed calls — see `RpcLimits.completedInboundCallsLimit`. */
790
+ completedCallsLimit = 1e3;
742
791
  get size() {
743
792
  return this._calls.size;
744
793
  }
745
794
  register(call) {
746
795
  this._calls.set(call.callId, call);
747
796
  }
797
+ // Registers `call` unless its id is already tracked, in which case the
798
+ // existing call is returned. Mirrors .NET `RpcInboundCallTracker.GetOrRegister`.
799
+ getOrRegister(call) {
800
+ const existing = this._calls.get(call.callId);
801
+ if (existing !== void 0)
802
+ return existing;
803
+ this._calls.set(call.callId, call);
804
+ return call;
805
+ }
806
+ // Keeps the completed `call` registered for duplicate-frame dedup, evicting
807
+ // the oldest completed calls beyond `completedCallsLimit`. Deviation from
808
+ // .NET (which unregisters a call once its result is sent): TS peers
809
+ // blind-resend after reconnect, so a completed call must stay resolvable
810
+ // to avoid re-executing the handler — but only within a bounded window.
811
+ markCompleted(call) {
812
+ if (this._calls.get(call.callId) !== call)
813
+ return;
814
+ this._completedIds.push(call.callId);
815
+ while (this._completedIds.length > this.completedCallsLimit) {
816
+ const id = this._completedIds.shift();
817
+ this._calls.delete(id);
818
+ }
819
+ }
748
820
  get(callId) {
749
821
  return this._calls.get(callId);
750
822
  }
@@ -755,6 +827,23 @@ var RpcInboundCallTracker = class {
755
827
  }
756
828
  clear() {
757
829
  this._calls.clear();
830
+ this._completedIds.length = 0;
831
+ }
832
+ };
833
+
834
+ // src/rpc-call-timeouts.ts
835
+ var RpcCallTimeouts = class _RpcCallTimeouts {
836
+ /** Both timeouts unbounded — the query default (.NET `RpcCallTimeouts.None`). */
837
+ static None = new _RpcCallTimeouts(Infinity, Infinity);
838
+ /** Command default: 1.5 s connect / 10 s run (.NET `RpcCallTimeouts.Default.Command`). */
839
+ static Command = new _RpcCallTimeouts(1500, 1e4);
840
+ /** Max time a call may wait for the connection before it is sent. */
841
+ connectTimeoutMs;
842
+ /** Max time a sent call may stay unanswered before it is failed. */
843
+ runTimeoutMs;
844
+ constructor(connectTimeoutMs = Infinity, runTimeoutMs = Infinity) {
845
+ this.connectTimeoutMs = connectTimeoutMs;
846
+ this.runTimeoutMs = runTimeoutMs;
758
847
  }
759
848
  };
760
849
 
@@ -768,6 +857,12 @@ var RpcError = class extends Error {
768
857
  this.typeName = typeName;
769
858
  }
770
859
  };
860
+ var REMOTE_EXCEPTION_TYPE_REF = "ActualLab.Serialization.RemoteException, ActualLab.Core";
861
+ function toExceptionInfo(error) {
862
+ const name = error instanceof Error ? error.name : "Error";
863
+ const message = error instanceof Error ? error.message : String(error);
864
+ return { TypeRef: REMOTE_EXCEPTION_TYPE_REF, Message: `${name}: ${message}` };
865
+ }
771
866
 
772
867
  // src/increasing-seq-compressor.ts
773
868
  var IncreasingSeqCompressor = {
@@ -872,6 +967,7 @@ import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
872
967
  var { warnLog: warnLog3 } = getLogs("RpcSharedStream");
873
968
  var DEFAULT_ACK_PERIOD = 256;
874
969
  var DEFAULT_ACK_ADVANCE = 128;
970
+ var RTT_WINDOW_MS = 2e4;
875
971
  var RpcStreamSender = class _RpcStreamSender {
876
972
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
877
973
  id;
@@ -913,6 +1009,9 @@ var RpcStreamSender = class _RpcStreamSender {
913
1009
  _lastAckedIndex = 0;
914
1010
  _abortController = new AbortController();
915
1011
  _iterator = null;
1012
+ // -- RTT sampling (send → matching ack round trips, windowed minimum) --
1013
+ _pendingSendTimes = [];
1014
+ _rttSamples = [];
916
1015
  // -- Recorder-controller metrics (Step 9.1) --
917
1016
  /** Total items skipped via real-time `canSkipTo` compaction. */
918
1017
  _skipCount = 0;
@@ -937,6 +1036,21 @@ var RpcStreamSender = class _RpcStreamSender {
937
1036
  get skipCount() {
938
1037
  return this._skipCount;
939
1038
  }
1039
+ // Windowed-MIN ack round trip (ms, -1 until sampled): converges on propagation
1040
+ // RTT — unlike a mean/EMA it is not inflated by self-induced queuing.
1041
+ get minRttMs() {
1042
+ const cutoffMs = Date.now() - RTT_WINDOW_MS;
1043
+ while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1044
+ this._rttSamples.shift();
1045
+ if (this._rttSamples.length === 0)
1046
+ return -1;
1047
+ let min = this._rttSamples[0].rttMs;
1048
+ for (const s of this._rttSamples) {
1049
+ if (s.rttMs < min)
1050
+ min = s.rttMs;
1051
+ }
1052
+ return min;
1053
+ }
940
1054
  // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
941
1055
  _acks = [];
942
1056
  _whenAckReady = null;
@@ -972,13 +1086,39 @@ var RpcStreamSender = class _RpcStreamSender {
972
1086
  */
973
1087
  onAck(nextIndex, hostId) {
974
1088
  const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
975
- if (!this._started.isCompleted) this._started.resolve();
1089
+ if (mustReset && hostId !== this.id.hostId) {
1090
+ this._sendDisconnect();
1091
+ return;
1092
+ }
1093
+ if (!this._started.isCompleted) {
1094
+ if (mustReset && nextIndex === 0)
1095
+ this._started.resolve();
1096
+ else {
1097
+ this._sendDisconnect();
1098
+ return;
1099
+ }
1100
+ } else if (this._ended) {
1101
+ this._sendDisconnect();
1102
+ return;
1103
+ } else if (mustReset && !this.allowReconnect) {
1104
+ this._sendDisconnect();
1105
+ return;
1106
+ }
1107
+ this._sampleRtt(nextIndex, mustReset);
976
1108
  this._acks.push({ nextIndex, mustReset });
977
1109
  if (this._whenAckReady) {
978
1110
  this._whenAckReady.resolve();
979
1111
  this._whenAckReady = null;
980
1112
  }
981
1113
  }
1114
+ // Tells the consumer this shared stream is gone, then disposes it —
1115
+ // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1116
+ _sendDisconnect() {
1117
+ const conn = this.peer.connection;
1118
+ if (conn)
1119
+ this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1120
+ this.disconnect();
1121
+ }
982
1122
  /** Called by system call handler when $sys.AckEnd is received from the client. */
983
1123
  onAckEnd(_hostId) {
984
1124
  this.disconnect();
@@ -1003,6 +1143,7 @@ var RpcStreamSender = class _RpcStreamSender {
1003
1143
  this._nextIndex,
1004
1144
  item
1005
1145
  );
1146
+ this._pendingSendTimes.push({ index: this._nextIndex, sentAtMs: Date.now() });
1006
1147
  }
1007
1148
  this._nextIndex++;
1008
1149
  }
@@ -1027,7 +1168,7 @@ var RpcStreamSender = class _RpcStreamSender {
1027
1168
  this._ended = true;
1028
1169
  const conn = this.peer.connection;
1029
1170
  if (!conn) return;
1030
- const errorInfo = error ? { TypeRef: "System.Exception", Message: error.message } : { TypeRef: "", Message: "" };
1171
+ const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1031
1172
  this.peer.hub.systemCallSender.end(
1032
1173
  conn,
1033
1174
  this.peer.serializationFormat,
@@ -1245,6 +1386,26 @@ var RpcStreamSender = class _RpcStreamSender {
1245
1386
  await this._whenAckReady;
1246
1387
  this._whenAckReady = null;
1247
1388
  }
1389
+ // One RTT sample per ack: time from sending the newest acked item to the
1390
+ // ack's arrival (a slight upper bound — includes the server's ack cadence).
1391
+ _sampleRtt(ackNextIndex, mustReset) {
1392
+ if (mustReset) {
1393
+ this._pendingSendTimes.length = 0;
1394
+ return;
1395
+ }
1396
+ let sentAtMs = -1;
1397
+ while (this._pendingSendTimes.length > 0 && this._pendingSendTimes[0].index < ackNextIndex) {
1398
+ sentAtMs = this._pendingSendTimes[0].sentAtMs;
1399
+ this._pendingSendTimes.shift();
1400
+ }
1401
+ if (sentAtMs < 0)
1402
+ return;
1403
+ const nowMs = Date.now();
1404
+ const cutoffMs = nowMs - RTT_WINDOW_MS;
1405
+ while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1406
+ this._rttSamples.shift();
1407
+ this._rttSamples.push({ atMs: nowMs, rttMs: nowMs - sentAtMs });
1408
+ }
1248
1409
  // -- IRpcObject --
1249
1410
  reconnect() {
1250
1411
  }
@@ -1469,11 +1630,7 @@ var RpcStream = class _RpcStream {
1469
1630
  if (index > this._nextExpectedIndex) {
1470
1631
  warnLog4?.log(`item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1471
1632
  if (!this.allowReconnect) {
1472
- this._completed = true;
1473
- this._completionError = new Error(
1474
- `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1475
- );
1476
- this._notifyConsumer();
1633
+ this._complete(this._gapError(index));
1477
1634
  return;
1478
1635
  }
1479
1636
  this._sendAck(this._nextExpectedIndex, true);
@@ -1493,11 +1650,7 @@ var RpcStream = class _RpcStream {
1493
1650
  if (index > this._nextExpectedIndex) {
1494
1651
  warnLog4?.log(`batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1495
1652
  if (!this.allowReconnect) {
1496
- this._completed = true;
1497
- this._completionError = new Error(
1498
- `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1499
- );
1500
- this._notifyConsumer();
1653
+ this._complete(this._gapError(index));
1501
1654
  return;
1502
1655
  }
1503
1656
  this._sendAck(this._nextExpectedIndex, true);
@@ -1514,9 +1667,20 @@ var RpcStream = class _RpcStream {
1514
1667
  /** Called by system call handler when the stream ends ($sys.End). */
1515
1668
  onEnd(index, error) {
1516
1669
  if (this._disposed || this._completed) return;
1517
- this._completed = true;
1518
- this._completionError = error;
1519
- this._notifyConsumer();
1670
+ if (index > this._nextExpectedIndex) {
1671
+ warnLog4?.log(`end index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1672
+ if (!this.allowReconnect) {
1673
+ this._complete(this._gapError(index));
1674
+ return;
1675
+ }
1676
+ this._sendAck(this._nextExpectedIndex, true);
1677
+ return;
1678
+ }
1679
+ if (index < this._nextExpectedIndex) {
1680
+ this._maybeSendAck(Math.min(index, this._nextConsumedIndex));
1681
+ return;
1682
+ }
1683
+ this._complete(error);
1520
1684
  }
1521
1685
  // -- IRpcObject --
1522
1686
  reconnect() {
@@ -1550,6 +1714,7 @@ var RpcStream = class _RpcStream {
1550
1714
  next: async () => {
1551
1715
  if (!this._started) {
1552
1716
  this._started = true;
1717
+ this.peer.remoteObjects.register(this);
1553
1718
  this._sendAck(0, true);
1554
1719
  }
1555
1720
  this._maybeSendAck(this._nextConsumedIndex);
@@ -1560,6 +1725,7 @@ var RpcStream = class _RpcStream {
1560
1725
  return { value, done: false };
1561
1726
  }
1562
1727
  if (this._completed) {
1728
+ this.dispose();
1563
1729
  if (this._completionError) throw this._completionError;
1564
1730
  return { value: void 0, done: true };
1565
1731
  }
@@ -1568,7 +1734,8 @@ var RpcStream = class _RpcStream {
1568
1734
  }
1569
1735
  },
1570
1736
  return: () => {
1571
- this._sendAckEnd();
1737
+ if (this._started && !this._completed)
1738
+ this._sendAckEnd();
1572
1739
  this.dispose();
1573
1740
  return Promise.resolve({ value: void 0, done: true });
1574
1741
  }
@@ -1584,6 +1751,22 @@ var RpcStream = class _RpcStream {
1584
1751
  this._notifyConsumer();
1585
1752
  }
1586
1753
  // -- Private --
1754
+ // Mirrors .NET RpcStream.CloseFromLock: acknowledge the end to the sender
1755
+ // (so its RpcSharedStream is released) and unregister immediately.
1756
+ _complete(error) {
1757
+ if (this._completed) return;
1758
+ this._completed = true;
1759
+ this._completionError = error;
1760
+ this._sendAckEnd();
1761
+ if (this.kind === RpcObjectKind.Remote)
1762
+ this.peer.remoteObjects.unregister(this);
1763
+ this._notifyConsumer();
1764
+ }
1765
+ _gapError(index) {
1766
+ return new Error(
1767
+ `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1768
+ );
1769
+ }
1587
1770
  _notifyConsumer() {
1588
1771
  if (this._consumerWaiting !== null) {
1589
1772
  this._consumerWaiting.resolve();
@@ -1600,7 +1783,7 @@ var RpcStream = class _RpcStream {
1600
1783
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
1601
1784
  _sendAck(nextIndex, mustReset) {
1602
1785
  this._ackSentUpTo = nextIndex;
1603
- const conn = this.peer.connection;
1786
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
1604
1787
  if (conn) {
1605
1788
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
1606
1789
  this.peer.hub.systemCallSender.ack(
@@ -1613,7 +1796,7 @@ var RpcStream = class _RpcStream {
1613
1796
  }
1614
1797
  }
1615
1798
  _sendAckEnd() {
1616
- const conn = this.peer.connection;
1799
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
1617
1800
  if (conn) {
1618
1801
  this.peer.hub.systemCallSender.ackEnd(
1619
1802
  conn,
@@ -1633,9 +1816,7 @@ function resolveStreamRefs(value, peer) {
1633
1816
  if (typeof value === "string") {
1634
1817
  const ref = parseStreamRef(value);
1635
1818
  if (ref !== null) {
1636
- const stream = new RpcStream(ref, peer);
1637
- peer.remoteObjects.register(stream);
1638
- return stream;
1819
+ return new RpcStream(ref, peer);
1639
1820
  }
1640
1821
  return value;
1641
1822
  }
@@ -1688,6 +1869,11 @@ var RpcSystemCallHandler = class {
1688
1869
  case RpcSystemCalls.ok: {
1689
1870
  const call = peer.outboundCalls.get(relatedId);
1690
1871
  if (call !== void 0) {
1872
+ if (args.length > 1) {
1873
+ peer.outboundCalls.remove(relatedId);
1874
+ call.result.reject(polymorphicPayloadError(method, args.length, 1));
1875
+ break;
1876
+ }
1691
1877
  call.completedStage |= RpcCallStage.ResultReady;
1692
1878
  if (call.removeOnOk) {
1693
1879
  peer.outboundCalls.remove(relatedId);
@@ -1701,19 +1887,15 @@ var RpcSystemCallHandler = class {
1701
1887
  break;
1702
1888
  }
1703
1889
  case RpcSystemCalls.error: {
1704
- const call = peer.outboundCalls.remove(relatedId);
1890
+ const call = peer.outboundCalls.get(relatedId);
1705
1891
  if (call !== void 0) {
1892
+ call.completedStage |= RpcCallStage.ResultReady;
1893
+ if (call.removeOnOk) {
1894
+ peer.outboundCalls.remove(relatedId);
1895
+ }
1706
1896
  const errorInfo = args[0];
1707
1897
  const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
1708
- const errorType = errorInfo?.TypeRef ?? errorInfo?.typeRef;
1709
- let typeName;
1710
- if (typeof errorType === "string") {
1711
- const commaIdx = errorType.indexOf(",");
1712
- typeName = commaIdx >= 0 ? errorType.slice(0, commaIdx) : errorType;
1713
- } else if (errorType !== null && typeof errorType === "object") {
1714
- const t = errorType;
1715
- typeName = t.TypeName ?? t.typeName;
1716
- }
1898
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1717
1899
  if (typeName === "ActualLab.Rpc.RpcRerouteException")
1718
1900
  warnLog5?.log("Got RpcRerouteException from remote peer:", msg);
1719
1901
  call.result.reject(new RpcError(msg, typeName));
@@ -1721,17 +1903,29 @@ var RpcSystemCallHandler = class {
1721
1903
  break;
1722
1904
  }
1723
1905
  case RpcSystemCalls.cancel: {
1724
- peer.inboundCalls.remove(relatedId);
1906
+ const call = peer.inboundCalls.remove(relatedId);
1907
+ call?.cancel();
1725
1908
  break;
1726
1909
  }
1727
1910
  case RpcSystemCalls.keepAlive: {
1728
1911
  peer.notifyKeepAliveReceived();
1912
+ const keepAliveIds = args[0];
1913
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1914
+ const unknownIds = [];
1915
+ for (const id of keepAliveIds)
1916
+ if (peer.sharedObjects.get(id) === void 0)
1917
+ unknownIds.push(id);
1918
+ if (unknownIds.length > 0)
1919
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1920
+ }
1729
1921
  break;
1730
1922
  }
1731
1923
  case RpcSystemCalls.item: {
1732
1924
  const stream = peer.remoteObjects.get(relatedId);
1733
1925
  if (!stream)
1734
1926
  debugLog?.log(`$sys.I: no stream for relatedId=${relatedId}`);
1927
+ else if (args.length > 2)
1928
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1735
1929
  else
1736
1930
  stream.onItem(
1737
1931
  args[0],
@@ -1743,6 +1937,8 @@ var RpcSystemCallHandler = class {
1743
1937
  const stream = peer.remoteObjects.get(relatedId);
1744
1938
  if (!stream)
1745
1939
  debugLog?.log(`$sys.B: no stream for relatedId=${relatedId}`);
1940
+ else if (args.length > 2)
1941
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1746
1942
  else if (Array.isArray(args[1])) {
1747
1943
  const items = args[1];
1748
1944
  for (let i = 0; i < items.length; i++)
@@ -1757,20 +1953,32 @@ var RpcSystemCallHandler = class {
1757
1953
  debugLog?.log(`$sys.End: no stream for relatedId=${relatedId}`);
1758
1954
  } else {
1759
1955
  const errorInfo = args[1];
1956
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1760
1957
  const msg = errorInfo?.Message ?? errorInfo?.message;
1761
- const error = msg ? new Error(msg) : null;
1958
+ let error = null;
1959
+ if (typeName || msg) {
1960
+ error = new Error(msg ?? typeName ?? "RPC stream error");
1961
+ if (typeName)
1962
+ error.name = typeName;
1963
+ }
1762
1964
  stream.onEnd(args[0], error);
1763
1965
  }
1764
1966
  break;
1765
1967
  }
1766
1968
  case RpcSystemCalls.ack: {
1767
1969
  const sender = peer.sharedObjects.get(relatedId);
1768
- sender?.onAck(args[0], args[1]);
1970
+ if (sender)
1971
+ sender.onAck(args[0], args[1]);
1972
+ else if (peer.connection !== void 0)
1973
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1769
1974
  break;
1770
1975
  }
1771
1976
  case RpcSystemCalls.ackEnd: {
1772
1977
  const sender = peer.sharedObjects.get(relatedId);
1773
- sender?.onAckEnd(args[0]);
1978
+ if (sender)
1979
+ sender.onAckEnd(args[0]);
1980
+ else if (peer.connection !== void 0)
1981
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1774
1982
  break;
1775
1983
  }
1776
1984
  case RpcSystemCalls.disconnect: {
@@ -1804,19 +2012,34 @@ var RpcSystemCallHandler = class {
1804
2012
  */
1805
2013
  _handleReconnect(relatedId, args, peer) {
1806
2014
  if (peer.connection === void 0) return;
2015
+ const handshakeIndex = args[0];
2016
+ if (typeof handshakeIndex === "number" && handshakeIndex !== peer.ownHandshakeIndex) {
2017
+ peer.hub.systemCallSender.error(
2018
+ peer.connection,
2019
+ peer.serializationFormat,
2020
+ relatedId,
2021
+ new Error(
2022
+ `TooLateToReconnect: own handshake index ${peer.ownHandshakeIndex} != ${handshakeIndex}`
2023
+ )
2024
+ );
2025
+ return;
2026
+ }
1807
2027
  let unknownIds = [];
1808
2028
  try {
1809
2029
  const stagesRaw = args[1];
1810
2030
  if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1811
2031
  const unknownSet = /* @__PURE__ */ new Set();
1812
- for (const [, rawValue] of Object.entries(stagesRaw)) {
2032
+ for (const [stageKey, rawValue] of Object.entries(stagesRaw)) {
1813
2033
  const bytes = _toBytes(rawValue);
1814
2034
  if (bytes === null) continue;
2035
+ const completedStage = parseInt(stageKey, 10) || 0;
1815
2036
  const callIds = IncreasingSeqCompressor.deserialize(bytes);
1816
2037
  for (const callId of callIds) {
1817
- if (peer.inboundCalls.get(callId) === void 0) {
2038
+ const call = peer.inboundCalls.get(callId);
2039
+ if (call === void 0)
1818
2040
  unknownSet.add(callId);
1819
- }
2041
+ else if (completedStage < 1)
2042
+ call.resendResult();
1820
2043
  }
1821
2044
  }
1822
2045
  unknownIds = [...unknownSet].sort((a, b) => a - b);
@@ -1828,6 +2051,29 @@ var RpcSystemCallHandler = class {
1828
2051
  peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1829
2052
  }
1830
2053
  };
2054
+ function polymorphicPayloadError(method, got, expected) {
2055
+ return new Error(
2056
+ `${method}: expected ${expected} value(s), got ${got} \u2014 polymorphic payloads are not supported by the TS client`
2057
+ );
2058
+ }
2059
+ function extractTypeName(typeRef) {
2060
+ if (typeof typeRef === "string") {
2061
+ if (typeRef.length === 0)
2062
+ return void 0;
2063
+ const commaIdx = typeRef.indexOf(",");
2064
+ return commaIdx >= 0 ? typeRef.slice(0, commaIdx) : typeRef;
2065
+ }
2066
+ if (typeRef !== null && typeof typeRef === "object") {
2067
+ const t = typeRef;
2068
+ const aqn = t.AssemblyQualifiedName ?? t.assemblyQualifiedName;
2069
+ if (typeof aqn === "string" && aqn.length > 0) {
2070
+ const commaIdx = aqn.indexOf(",");
2071
+ return commaIdx >= 0 ? aqn.slice(0, commaIdx) : aqn;
2072
+ }
2073
+ return t.TypeName ?? t.typeName;
2074
+ }
2075
+ return void 0;
2076
+ }
1831
2077
  function _toBytes(value) {
1832
2078
  if (value instanceof Uint8Array) return value;
1833
2079
  if (typeof value === "string") {
@@ -1911,12 +2157,11 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
1911
2157
  }
1912
2158
  }
1913
2159
  error(conn, format, relatedId, error) {
1914
- const message = error instanceof Error ? error.message : String(error);
1915
2160
  this._send(
1916
2161
  conn,
1917
2162
  format,
1918
2163
  { Method: RpcSystemCalls.error, RelatedId: relatedId },
1919
- [{ Message: message }]
2164
+ [toExceptionInfo(error)]
1920
2165
  );
1921
2166
  }
1922
2167
  cancel(conn, format, relatedId) {
@@ -1930,6 +2175,14 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
1930
2175
  activeCallIds
1931
2176
  ]);
1932
2177
  }
2178
+ // Tells the remote peer the listed shared-object ids are gone on our side,
2179
+ // so its matching remote objects (e.g. RpcStream consumers) fail fast
2180
+ // instead of hanging. Mirrors .NET RpcSystemCallSender.Disconnect.
2181
+ disconnect(conn, format, localIds) {
2182
+ this._send(conn, format, { Method: RpcSystemCalls.disconnect }, [
2183
+ localIds
2184
+ ]);
2185
+ }
1933
2186
  item(conn, format, localId, index, item) {
1934
2187
  this._itemEnv.RelatedId = localId;
1935
2188
  this._itemArgs[0] = index;
@@ -1967,7 +2220,13 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
1967
2220
  var RpcRemoteObjectTracker = class {
1968
2221
  _objects = /* @__PURE__ */ new Map();
1969
2222
  register(obj) {
1970
- this._objects.set(obj.id.localId, obj);
2223
+ const localId = obj.id.localId;
2224
+ const existing = this._objects.get(localId);
2225
+ if (existing === obj)
2226
+ return;
2227
+ if (existing !== void 0)
2228
+ existing.disconnect();
2229
+ this._objects.set(localId, obj);
1971
2230
  }
1972
2231
  get(localId) {
1973
2232
  return this._objects.get(localId);
@@ -1976,7 +2235,9 @@ var RpcRemoteObjectTracker = class {
1976
2235
  return this._objects.keys();
1977
2236
  }
1978
2237
  unregister(obj) {
1979
- this._objects.delete(obj.id.localId);
2238
+ const localId = obj.id.localId;
2239
+ if (this._objects.get(localId) === obj)
2240
+ this._objects.delete(localId);
1980
2241
  }
1981
2242
  disconnectAll() {
1982
2243
  for (const obj of this._objects.values()) {
@@ -2000,7 +2261,10 @@ var RpcSharedObjectTracker = class {
2000
2261
  return this._nextId++;
2001
2262
  }
2002
2263
  register(obj) {
2003
- this._objects.set(obj.id.localId, obj);
2264
+ const localId = obj.id.localId;
2265
+ if (this._objects.has(localId))
2266
+ throw new Error(`RPC shared object with localId ${localId} is already registered.`);
2267
+ this._objects.set(localId, obj);
2004
2268
  }
2005
2269
  get(localId) {
2006
2270
  return this._objects.get(localId);
@@ -2009,7 +2273,9 @@ var RpcSharedObjectTracker = class {
2009
2273
  return this._objects.keys();
2010
2274
  }
2011
2275
  unregister(obj) {
2012
- this._objects.delete(obj.id.localId);
2276
+ const localId = obj.id.localId;
2277
+ if (this._objects.get(localId) === obj)
2278
+ this._objects.delete(localId);
2013
2279
  }
2014
2280
  disconnectAll() {
2015
2281
  for (const obj of this._objects.values()) {
@@ -2020,7 +2286,7 @@ var RpcSharedObjectTracker = class {
2020
2286
  };
2021
2287
 
2022
2288
  // src/rpc-peer.ts
2023
- import { EventHandlerSet as EventHandlerSet3, PromiseSource as PromiseSource5, TimeoutError, withTimeout } from "@actuallab/core";
2289
+ import { EventHandlerSet as EventHandlerSet3, PromiseSource as PromiseSource5, TimeoutError, cancellationError, withTimeout } from "@actuallab/core";
2024
2290
 
2025
2291
  // src/rpc-serialization-format.ts
2026
2292
  import { Decoder as Decoder3 } from "@msgpack/msgpack";
@@ -2039,9 +2305,7 @@ var RpcSerializationFormat = class {
2039
2305
  static NewtonsoftJsonV5;
2040
2306
  static NewtonsoftJsonV5NP;
2041
2307
  static MessagePackV6;
2042
- static MemoryPackV6;
2043
2308
  static MessagePackV6C;
2044
- static MemoryPackV6C;
2045
2309
  static All;
2046
2310
  /** Convenience shortcut for RpcSerializationFormatResolver.Default.get(key). */
2047
2311
  static get(key) {
@@ -2137,18 +2401,14 @@ RpcSerializationFormat.SystemJsonV5NP = new RpcJsonSerializationFormat("json5np"
2137
2401
  RpcSerializationFormat.NewtonsoftJsonV5 = new RpcJsonSerializationFormat("njson5");
2138
2402
  RpcSerializationFormat.NewtonsoftJsonV5NP = new RpcJsonSerializationFormat("njson5np");
2139
2403
  RpcSerializationFormat.MessagePackV6 = new RpcMessagePackSerializationFormat("msgpack6");
2140
- RpcSerializationFormat.MemoryPackV6 = new RpcMessagePackSerializationFormat("mempack6");
2141
2404
  RpcSerializationFormat.MessagePackV6C = new RpcMessagePackCompactSerializationFormat("msgpack6c");
2142
- RpcSerializationFormat.MemoryPackV6C = new RpcMessagePackCompactSerializationFormat("mempack6c");
2143
2405
  RpcSerializationFormat.All = [
2144
2406
  RpcSerializationFormat.SystemJsonV5,
2145
2407
  RpcSerializationFormat.SystemJsonV5NP,
2146
2408
  RpcSerializationFormat.NewtonsoftJsonV5,
2147
2409
  RpcSerializationFormat.NewtonsoftJsonV5NP,
2148
2410
  RpcSerializationFormat.MessagePackV6,
2149
- RpcSerializationFormat.MemoryPackV6,
2150
- RpcSerializationFormat.MessagePackV6C,
2151
- RpcSerializationFormat.MemoryPackV6C
2411
+ RpcSerializationFormat.MessagePackV6C
2152
2412
  ];
2153
2413
  var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2154
2414
  static DefaultFormats = RpcSerializationFormat.All;
@@ -2168,6 +2428,8 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2168
2428
  this.formats = map;
2169
2429
  }
2170
2430
  get(key) {
2431
+ if (isMemoryPackKey(key))
2432
+ throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
2171
2433
  const format = this.tryGet(key);
2172
2434
  if (!format)
2173
2435
  throw new Error(`No format with key '${key}'.`);
@@ -2175,9 +2437,14 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2175
2437
  }
2176
2438
  tryGet(key) {
2177
2439
  if (!key) key = this.defaultFormatKey;
2440
+ if (isMemoryPackKey(key))
2441
+ throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
2178
2442
  return this.formats.get(key);
2179
2443
  }
2180
2444
  };
2445
+ function isMemoryPackKey(key) {
2446
+ return key.startsWith("mempack");
2447
+ }
2181
2448
 
2182
2449
  // src/rpc-peer.ts
2183
2450
  function yieldToEventLoop() {
@@ -2231,6 +2498,10 @@ var RpcPeer = class {
2231
2498
  * attempt (the reconnect delay is part of this phase); `Connected` is
2232
2499
  * entered only after the handshake has succeeded. */
2233
2500
  connectionStateChanged = new EventHandlerSet3();
2501
+ /** Index of the handshake THIS peer most recently sent. Used to reject a
2502
+ * stale `$sys.Reconnect` that references an older connection generation
2503
+ * (C# `RpcSystemCalls.Reconnect`'s `ownHandshake.Index` check). */
2504
+ _ownHandshakeIndex = 0;
2234
2505
  _connection;
2235
2506
  /** Mirror of `_connectionState === Connected`. True only after the
2236
2507
  * handshake round-trip completes; outbound `call()` checks this to
@@ -2242,6 +2513,7 @@ var RpcPeer = class {
2242
2513
  _connectionState = 0 /* Disconnected */;
2243
2514
  _keepAliveTimer;
2244
2515
  _keepAliveWatchdog;
2516
+ _maintainTimer;
2245
2517
  /** Wall-clock time of the last inbound `$sys.KeepAlive`, or 0 if none yet
2246
2518
  * for the current connection. Useful for diagnostics. */
2247
2519
  _lastKeepAliveAt = 0;
@@ -2256,6 +2528,37 @@ var RpcPeer = class {
2256
2528
  this.ref = ref;
2257
2529
  this.keepAlivePeriodMs = hub.limits.keepAlivePeriodMs;
2258
2530
  this.keepAliveTimeoutMs = hub.limits.keepAliveTimeoutMs;
2531
+ this.inboundCalls.completedCallsLimit = hub.limits.completedInboundCallsLimit;
2532
+ const checkPeriod = hub.limits.callTimeoutCheckPeriodMs;
2533
+ if (checkPeriod > 0 && Number.isFinite(checkPeriod)) {
2534
+ this._maintainTimer = setInterval(() => this._maintainOutboundCalls(), checkPeriod);
2535
+ this._maintainTimer.unref?.();
2536
+ }
2537
+ }
2538
+ /** Fail outbound calls whose per-call {@link RpcCallTimeouts} elapsed —
2539
+ * the connect timeout while a call still awaits the wire, the run timeout
2540
+ * once it has been sent. Mirrors .NET `RpcOutboundCallTracker.Maintain`
2541
+ * (RpcCallTrackers.cs:108-207). Calls without timeouts (queries) never
2542
+ * time out here. */
2543
+ _maintainOutboundCalls() {
2544
+ const now = Date.now();
2545
+ const canRunTimeout = this._isConnected && (this._lastKeepAliveAt === 0 || now - this._lastKeepAliveAt <= this.keepAliveTimeoutMs);
2546
+ for (const call of [...this.outboundCalls.values()]) {
2547
+ const timeouts = call.timeouts;
2548
+ if (timeouts === void 0 || call.result.isCompleted)
2549
+ continue;
2550
+ let timedOut;
2551
+ if (call.sentAt === 0) {
2552
+ if (now - call.startedAt >= timeouts.connectTimeoutMs)
2553
+ timedOut = `connect timeout (${timeouts.connectTimeoutMs}ms)`;
2554
+ } else if (canRunTimeout && now - call.sentAt >= timeouts.runTimeoutMs)
2555
+ timedOut = `run timeout (${timeouts.runTimeoutMs}ms)`;
2556
+ if (timedOut === void 0)
2557
+ continue;
2558
+ this.outboundCalls.remove(call.callId);
2559
+ call.result.reject(new TimeoutError(`'${this.ref}': call ${call.method}#${call.callId} timed out \u2014 ${timedOut}`));
2560
+ call.onDisconnect();
2561
+ }
2259
2562
  }
2260
2563
  get connection() {
2261
2564
  return this._connection;
@@ -2266,6 +2569,9 @@ var RpcPeer = class {
2266
2569
  get isConnected() {
2267
2570
  return this._isConnected;
2268
2571
  }
2572
+ get ownHandshakeIndex() {
2573
+ return this._ownHandshakeIndex;
2574
+ }
2269
2575
  _setConnectionState(value) {
2270
2576
  if (this._connectionState === value) return;
2271
2577
  const previousState = this._connectionState;
@@ -2337,14 +2643,17 @@ var RpcPeer = class {
2337
2643
  this._connection?.encoder,
2338
2644
  this.hub.registry
2339
2645
  );
2646
+ outboundCall.timeouts = options?.timeouts;
2340
2647
  this.outboundCalls.register(outboundCall);
2341
2648
  if (this._isConnected) {
2649
+ outboundCall.sentAt = Date.now();
2342
2650
  this._sendWireData(outboundCall.serializedWireData);
2343
2651
  } else {
2344
2652
  const stateHandler = (state) => {
2345
2653
  if (state !== 3 /* Connected */) return;
2346
2654
  this.connectionStateChanged.remove(stateHandler);
2347
2655
  if (outboundCall.result.isCompleted) return;
2656
+ outboundCall.sentAt = Date.now();
2348
2657
  this._sendWireData(outboundCall.serializedWireData);
2349
2658
  };
2350
2659
  this.connectionStateChanged.add(stateHandler);
@@ -2354,9 +2663,9 @@ var RpcPeer = class {
2354
2663
  if (signal !== void 0) {
2355
2664
  const onAbort = () => {
2356
2665
  if (this.outboundCalls.remove(callId) !== void 0) {
2357
- outboundCall.result.reject(new Error("Call cancelled."));
2666
+ outboundCall.result.reject(cancellationError("Call cancelled."));
2358
2667
  outboundCall.onDisconnect();
2359
- if (this._connection !== void 0)
2668
+ if (this._isConnected && this._connection !== void 0)
2360
2669
  this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
2361
2670
  }
2362
2671
  };
@@ -2386,10 +2695,15 @@ var RpcPeer = class {
2386
2695
  }
2387
2696
  close() {
2388
2697
  infoLog?.log(`'${this.ref}': Stopping`);
2698
+ if (this._maintainTimer !== void 0) {
2699
+ clearInterval(this._maintainTimer);
2700
+ this._maintainTimer = void 0;
2701
+ }
2389
2702
  this.remoteObjects.disconnectAll();
2390
2703
  this.sharedObjects.disconnectAll();
2391
- this.outboundCalls.rejectAll(new Error("Peer closed."));
2704
+ this.outboundCalls.rejectAll(cancellationError("Peer closed."));
2392
2705
  this.outboundCalls.invalidateAll();
2706
+ this.inboundCalls.clear();
2393
2707
  this._connection?.close();
2394
2708
  this.hub.peers.delete(this.ref);
2395
2709
  this._setConnectionState(0 /* Disconnected */);
@@ -2453,57 +2767,85 @@ var RpcPeer = class {
2453
2767
  _handleInbound(message, args) {
2454
2768
  const method = message.Method ?? "";
2455
2769
  const relatedId = message.RelatedId ?? 0;
2456
- const call = new RpcInboundCall(relatedId, method, args);
2457
2770
  const methodDef = this.hub.serviceHost.getMethodDef(method);
2458
2771
  const isNoWait = methodDef?.noWait === true;
2459
- if (!isNoWait) {
2460
- this.inboundCalls.register(call);
2461
- }
2462
2772
  const serviceHost = this.hub.serviceHost;
2463
- void (async () => {
2773
+ const dispatch = async (call2) => {
2464
2774
  try {
2465
2775
  const context = this._connection !== void 0 ? {
2466
2776
  __rpcDispatch: true,
2467
2777
  callId: relatedId,
2468
- connection: this._connection
2778
+ callType: message.CallType ?? 0,
2779
+ connection: this._connection,
2780
+ signal: call2?.signal,
2781
+ peer: this
2469
2782
  } : void 0;
2470
- const result = await serviceHost.dispatch(
2471
- method,
2472
- args,
2473
- context
2474
- );
2475
- if (methodDef?.stream === true && this._connection !== void 0) {
2476
- const stream = result instanceof RpcStream ? result : new RpcStream(result);
2477
- this.hub.systemCallSender.ok(
2478
- this._connection,
2479
- this.serializationFormat,
2480
- relatedId,
2481
- stream.toRef(this)
2482
- );
2483
- } else if (!isNoWait && this._connection !== void 0) {
2484
- this.hub.systemCallSender.ok(
2485
- this._connection,
2486
- this.serializationFormat,
2487
- relatedId,
2488
- result
2489
- );
2783
+ const result = await serviceHost.dispatch(method, args, context);
2784
+ if (methodDef?.stream === true) {
2785
+ if (this._connection !== void 0) {
2786
+ const stream = result instanceof RpcStream ? result : new RpcStream(result);
2787
+ const ref = stream.toRef(this);
2788
+ const send = () => {
2789
+ if (call2?.isCancelled === true) return;
2790
+ if (this._connection !== void 0)
2791
+ this.hub.systemCallSender.ok(
2792
+ this._connection,
2793
+ this.serializationFormat,
2794
+ relatedId,
2795
+ ref
2796
+ );
2797
+ };
2798
+ if (call2 !== void 0) {
2799
+ call2.setResult(send);
2800
+ this.inboundCalls.markCompleted(call2);
2801
+ }
2802
+ send();
2803
+ }
2804
+ } else if (call2 !== void 0) {
2805
+ const send = () => {
2806
+ if (call2.isCancelled) return;
2807
+ if (this._connection !== void 0)
2808
+ this.hub.systemCallSender.ok(
2809
+ this._connection,
2810
+ this.serializationFormat,
2811
+ relatedId,
2812
+ result
2813
+ );
2814
+ };
2815
+ call2.setResult(send);
2816
+ this.inboundCalls.markCompleted(call2);
2817
+ send();
2490
2818
  }
2491
2819
  } catch (e) {
2492
- if (!isNoWait && this._connection !== void 0) {
2493
- this.hub.systemCallSender.error(
2494
- this._connection,
2495
- this.serializationFormat,
2496
- relatedId,
2497
- e
2498
- );
2820
+ if (call2 !== void 0) {
2821
+ const send = () => {
2822
+ if (call2.isCancelled) return;
2823
+ if (this._connection !== void 0)
2824
+ this.hub.systemCallSender.error(
2825
+ this._connection,
2826
+ this.serializationFormat,
2827
+ relatedId,
2828
+ e
2829
+ );
2830
+ };
2831
+ call2.setResult(send);
2832
+ this.inboundCalls.markCompleted(call2);
2833
+ send();
2499
2834
  }
2500
2835
  debugLog2?.log(`'${this.ref}': Inbound call failed: ${method}#${relatedId}`, e);
2501
- } finally {
2502
- if (!isNoWait) {
2503
- this.inboundCalls.remove(relatedId);
2504
- }
2505
2836
  }
2506
- })();
2837
+ };
2838
+ if (isNoWait) {
2839
+ void dispatch(void 0);
2840
+ return;
2841
+ }
2842
+ const call = new RpcInboundCall(relatedId, method, args);
2843
+ const existing = this.inboundCalls.getOrRegister(call);
2844
+ if (existing !== call) {
2845
+ existing.resendResult();
2846
+ return;
2847
+ }
2848
+ void dispatch(call);
2507
2849
  }
2508
2850
  /** Start sending `$sys.KeepAlive` pings and arm the silence watchdog.
2509
2851
  * Called when the peer transitions into `Connected` (after handshake).
@@ -2559,15 +2901,17 @@ var RpcConnectionState = /* @__PURE__ */ ((RpcConnectionState2) => {
2559
2901
  })(RpcConnectionState || {});
2560
2902
  var RpcClientPeer = class extends RpcPeer {
2561
2903
  _disposed = false;
2904
+ /** Aborted when the peer is stopped/disposed — threaded into the reconnect
2905
+ * delay so a pending delay rejects promptly instead of parking. */
2906
+ _stopController = new AbortController();
2562
2907
  _tryIndex = 0;
2563
- _handshakeIndex = 0;
2564
2908
  /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
2565
2909
  * to the server in `$sys.Reconnect:3` so the server can verify we're
2566
2910
  * talking about the current connection. Mirrors .NET's
2567
2911
  * `connectionState.Handshake.Index` path in
2568
2912
  * `RpcOutboundCallTracker.TryReconnect`. */
2569
2913
  _remoteHandshakeIndex = 0;
2570
- _lastRemoteHubId;
2914
+ _lastRemotePeerId;
2571
2915
  _pendingHandshake;
2572
2916
  _reconnectsAt = 0;
2573
2917
  /** Base64url-encoded peer ID — matches .NET's RpcClientPeer.ClientId (Guid.ToBase64Url). */
@@ -2588,6 +2932,10 @@ var RpcClientPeer = class extends RpcPeer {
2588
2932
  * the connection attempt. Initialized from `hub.limits` at construction;
2589
2933
  * set directly to override on a single peer. */
2590
2934
  handshakeTimeoutMs;
2935
+ /** A connection that lived less than this before dropping does NOT reset
2936
+ * `_tryIndex`, so a crash-looping server sees growing reconnect delays.
2937
+ * Initialized from `hub.limits` at construction; override per peer. */
2938
+ prematureDisconnectTimeoutMs;
2591
2939
  peerChanged = new EventHandlerSet3();
2592
2940
  /** Fired when the server rejects the connection due to an unsupported serialization format (close code 4010). */
2593
2941
  unsupportedFormat = new EventHandlerSet3();
@@ -2620,6 +2968,7 @@ var RpcClientPeer = class extends RpcPeer {
2620
2968
  super(hub, url);
2621
2969
  this.connectTimeoutMs = hub.limits.connectTimeoutMs;
2622
2970
  this.handshakeTimeoutMs = hub.limits.handshakeTimeoutMs;
2971
+ this.prematureDisconnectTimeoutMs = hub.limits.prematureDisconnectTimeoutMs;
2623
2972
  this.clientId = guidToBase64Url(this.id);
2624
2973
  const resolver = RpcSerializationFormatResolver.Default;
2625
2974
  this.serializationFormat = resolver.get(parseFormatFromUrl(url) ?? resolver.defaultFormatKey);
@@ -2656,7 +3005,7 @@ var RpcClientPeer = class extends RpcPeer {
2656
3005
  try {
2657
3006
  while (!this._disposed) {
2658
3007
  this._setConnectionState(1 /* Connecting */);
2659
- const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex);
3008
+ const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex, this._stopController.signal);
2660
3009
  if (retryDelay.isLimitExceeded) {
2661
3010
  warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2662
3011
  this._disposed = true;
@@ -2665,12 +3014,14 @@ var RpcClientPeer = class extends RpcPeer {
2665
3014
  this._setReconnectsAt(retryDelay.endsAt);
2666
3015
  try {
2667
3016
  await retryDelay.promise;
3017
+ } catch {
2668
3018
  } finally {
2669
3019
  this._setReconnectsAt(0);
2670
3020
  }
2671
3021
  if (this._disposed) break;
2672
3022
  let lastCloseCode = 0;
2673
3023
  let lastCloseReason = "";
3024
+ let connectedAt = 0;
2674
3025
  try {
2675
3026
  const connUrl = await this.connectionUrlResolver(this);
2676
3027
  infoLog?.log(`'${this.ref}': Connecting to ${sanitizeUrl(connUrl)}`);
@@ -2717,7 +3068,7 @@ var RpcClientPeer = class extends RpcPeer {
2717
3068
  this.serializationFormat,
2718
3069
  this.id,
2719
3070
  this.hub.hubId,
2720
- ++this._handshakeIndex
3071
+ ++this._ownHandshakeIndex
2721
3072
  );
2722
3073
  } catch (e) {
2723
3074
  warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
@@ -2741,20 +3092,10 @@ var RpcClientPeer = class extends RpcPeer {
2741
3092
  this._pendingHandshake = void 0;
2742
3093
  }
2743
3094
  this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2744
- const remoteHubId = remoteHandshake.RemoteHubId;
2745
- let isPeerChanged = false;
2746
- if (remoteHubId !== void 0) {
2747
- isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2748
- if (isPeerChanged) {
2749
- this.inboundCalls.clear();
2750
- infoLog?.log(`'${this.ref}': Peer changed`);
2751
- this.peerChanged.trigger();
2752
- }
2753
- this._lastRemoteHubId = remoteHubId;
2754
- }
3095
+ const isPeerChanged = this._detectPeerChange(remoteHandshake);
2755
3096
  infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2756
3097
  this._setConnectionState(3 /* Connected */);
2757
- this._tryIndex = 0;
3098
+ connectedAt = Date.now();
2758
3099
  await this._reconnect(isPeerChanged, closedController.signal);
2759
3100
  await whenClosed;
2760
3101
  } catch (e) {
@@ -2768,6 +3109,8 @@ var RpcClientPeer = class extends RpcPeer {
2768
3109
  this._disposed = true;
2769
3110
  break;
2770
3111
  }
3112
+ if (connectedAt !== 0 && Date.now() - connectedAt >= this.prematureDisconnectTimeoutMs)
3113
+ this._tryIndex = 0;
2771
3114
  this._tryIndex++;
2772
3115
  }
2773
3116
  } finally {
@@ -2823,6 +3166,8 @@ var RpcClientPeer = class extends RpcPeer {
2823
3166
  continue;
2824
3167
  }
2825
3168
  eligible.push(call);
3169
+ if (call.sentAt !== 0)
3170
+ call.sentAt = Date.now();
2826
3171
  }
2827
3172
  let unknownIds = null;
2828
3173
  if (!isPeerChanged && eligible.length > 0) {
@@ -2839,6 +3184,7 @@ var RpcClientPeer = class extends RpcPeer {
2839
3184
  if (unknownIds !== null && !unknownIds.has(call.callId)) {
2840
3185
  continue;
2841
3186
  }
3187
+ call.sentAt = Date.now();
2842
3188
  this._sendWireData(call.serializedWireData);
2843
3189
  resentSinceYield++;
2844
3190
  if (resentSinceYield >= YIELD_BATCH) {
@@ -2904,30 +3250,80 @@ var RpcClientPeer = class extends RpcPeer {
2904
3250
  return new Set(eligible.map((c) => c.callId));
2905
3251
  }
2906
3252
  }
3253
+ /** Detects a server-peer identity change by comparing the incoming
3254
+ * handshake's `RemotePeerId` with the last one — C#
3255
+ * `RpcHandshake.GetPeerChangeKind` keys off `RemotePeerId`, not
3256
+ * `RemoteHubId` (an idle server peer can be replaced while keeping the
3257
+ * same hub id). On a change it clears the inbound tracker and fires
3258
+ * `peerChanged`; the very first handshake is never a change. */
3259
+ _detectPeerChange(handshake) {
3260
+ const remotePeerId = handshake.RemotePeerId;
3261
+ if (remotePeerId === void 0)
3262
+ return false;
3263
+ const isPeerChanged = this._lastRemotePeerId !== void 0 && this._lastRemotePeerId !== remotePeerId;
3264
+ this._lastRemotePeerId = remotePeerId;
3265
+ if (isPeerChanged) {
3266
+ this.inboundCalls.clear();
3267
+ infoLog?.log(`'${this.ref}': Peer changed`);
3268
+ this.peerChanged.trigger();
3269
+ }
3270
+ return isPeerChanged;
3271
+ }
2907
3272
  _onHandshakeReceived(handshake) {
2908
3273
  this._pendingHandshake?.resolve(handshake);
2909
3274
  }
2910
3275
  close() {
2911
3276
  this._disposed = true;
3277
+ this._stopController.abort();
2912
3278
  super.close();
2913
3279
  }
2914
3280
  };
2915
3281
  var RpcServerPeer = class extends RpcPeer {
2916
- // Needed to widen the base `protected constructor` to public.
2917
- // eslint-disable-next-line @typescript-eslint/no-useless-constructor
3282
+ /** Grace period before an idle (disconnected) server peer is stopped and
3283
+ * removed from `hub.peers`; a re-`accept()` within it cancels removal.
3284
+ * Snapshotted from `hub.limits` at construction (F11). */
3285
+ serverPeerCloseTimeoutMs;
3286
+ _closeTimer;
3287
+ _isClosed = false;
2918
3288
  constructor(hub, ref) {
2919
3289
  super(hub, ref);
3290
+ this.serverPeerCloseTimeoutMs = hub.limits.serverPeerCloseTimeoutMs;
2920
3291
  }
2921
3292
  /** Accept an incoming connection — sets up message handling and handshake response. */
2922
3293
  accept(conn) {
3294
+ this._clearCloseTimer();
2923
3295
  this._setConnectionState(1 /* Connecting */);
2924
3296
  this.setupConnection(conn);
2925
3297
  this._setConnectionState(2 /* Handshaking */);
2926
3298
  conn.closed.add(() => {
2927
3299
  if (this._connection !== conn && this._connection !== void 0) return;
2928
3300
  this.sharedObjects.disconnectAll();
3301
+ this._armCloseTimer();
2929
3302
  });
2930
3303
  }
3304
+ close() {
3305
+ this._isClosed = true;
3306
+ this._clearCloseTimer();
3307
+ super.close();
3308
+ }
3309
+ _armCloseTimer() {
3310
+ this._clearCloseTimer();
3311
+ if (this._isClosed) return;
3312
+ const timeout = this.serverPeerCloseTimeoutMs;
3313
+ if (!(timeout > 0) || !Number.isFinite(timeout)) return;
3314
+ this._closeTimer = setTimeout(() => {
3315
+ this._closeTimer = void 0;
3316
+ if (this._connection === void 0)
3317
+ this.close();
3318
+ }, timeout);
3319
+ this._closeTimer.unref?.();
3320
+ }
3321
+ _clearCloseTimer() {
3322
+ if (this._closeTimer !== void 0) {
3323
+ clearTimeout(this._closeTimer);
3324
+ this._closeTimer = void 0;
3325
+ }
3326
+ }
2931
3327
  _onHandshakeReceived(_handshake) {
2932
3328
  if (this._connection !== void 0) {
2933
3329
  this.hub.systemCallSender.handshake(
@@ -2935,7 +3331,7 @@ var RpcServerPeer = class extends RpcPeer {
2935
3331
  this.serializationFormat,
2936
3332
  this.id,
2937
3333
  this.hub.hubId,
2938
- 0
3334
+ ++this._ownHandshakeIndex
2939
3335
  );
2940
3336
  this._setConnectionState(3 /* Connected */);
2941
3337
  }
@@ -2974,6 +3370,12 @@ var RpcLimits = class _RpcLimits {
2974
3370
  /** Process-wide default. Hubs read from this when they're constructed
2975
3371
  * unless caller assigns a custom instance to `hub.limits`. */
2976
3372
  static Default = new _RpcLimits();
3373
+ /** Dev preset with a relaxed keep-alive timeout (5 min), matching .NET's
3374
+ * debugger-attached `KeepAlivePeriod` (RpcLimits.cs:46-54). Activate
3375
+ * explicitly — e.g. `RpcLimits.Default = RpcLimits.Debug` or per-hub
3376
+ * `hub.limits = RpcLimits.Debug`. Never switched on automatically: the
3377
+ * TS side can't detect a debugger on the server (decision D4). */
3378
+ static Debug = new _RpcLimits({ keepAliveTimeoutMs: 3e5 });
2977
3379
  /** Max time to wait for the WebSocket to enter the OPEN state. On a
2978
3380
  * hung connect (mobile after network change, half-open after sleep)
2979
3381
  * the browser can take ~2 min to emit `onerror`/`onclose`; without
@@ -2981,6 +3383,22 @@ var RpcLimits = class _RpcLimits {
2981
3383
  connectTimeoutMs = 1e4;
2982
3384
  /** Max time to wait for the server's handshake response after WS opens. */
2983
3385
  handshakeTimeoutMs = 1e4;
3386
+ /** If a connection lived less than this before dropping, a graceful close
3387
+ * still counts as a failed attempt — the client keeps its growing
3388
+ * `_tryIndex` instead of resetting it, so a crash-looping server sees
3389
+ * increasing reconnect delays via `RetryDelaySeq`. Mirrors .NET
3390
+ * `RpcLimits.PrematureDisconnectTimeout` (RpcLimits.cs:18). */
3391
+ prematureDisconnectTimeoutMs = 15e3;
3392
+ /** Max completed inbound calls retained for duplicate-frame dedup
3393
+ * (a resent call id re-sends the computed result instead of
3394
+ * re-executing the handler). Deviation from .NET, which unregisters a
3395
+ * call once its result is sent: TS clients blind-resend on reconnect,
3396
+ * so completed calls are retained — bounded by this limit (oldest
3397
+ * evicted first) to cap memory on long-lived connections. */
3398
+ completedInboundCallsLimit = 1e3;
3399
+ /** How often each peer's maintenance loop scans outbound calls for
3400
+ * timeouts (R12). Mirrors .NET `RpcLimits.CallTimeoutCheckPeriod`. */
3401
+ callTimeoutCheckPeriodMs = 1e3;
2984
3402
  /** Outbound `$sys.KeepAlive` send period. */
2985
3403
  keepAlivePeriodMs = 1e4;
2986
3404
  /** Reaper threshold — connection is force-closed (and the reconnect
@@ -2990,6 +3408,13 @@ var RpcLimits = class _RpcLimits {
2990
3408
  * `keepAlivePeriodMs + stall_duration`, so with these defaults the
2991
3409
  * tolerated stall is ~15 s. */
2992
3410
  keepAliveTimeoutMs = 25e3;
3411
+ /** How long a server peer stays in `hub.peers` after its connection closes
3412
+ * before it's stopped and removed. A same-peer reconnect (`accept()`)
3413
+ * within this window cancels the pending removal, so brief drops don't
3414
+ * discard the peer's trackers. Mirrors .NET's server-peer shutdown timeout
3415
+ * (`RpcPeerOptions.ServerPeerShutdownTimeoutProvider`, 3-15 min); the TS
3416
+ * port uses a fixed floor-matching default. */
3417
+ serverPeerCloseTimeoutMs = 18e4;
2993
3418
  constructor(overrides) {
2994
3419
  if (overrides) Object.assign(this, overrides);
2995
3420
  }
@@ -3000,7 +3425,7 @@ import { RetryDelaySeq, RetryDelayer } from "@actuallab/core";
3000
3425
  var RpcClientPeerReconnectDelayer = class extends RetryDelayer {
3001
3426
  constructor() {
3002
3427
  super();
3003
- this.delays = RetryDelaySeq.fixed(100);
3428
+ this.delays = RetryDelaySeq.exp(100, 1e4);
3004
3429
  }
3005
3430
  };
3006
3431
 
@@ -3008,13 +3433,14 @@ var RpcClientPeerReconnectDelayer = class extends RetryDelayer {
3008
3433
  var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
3009
3434
  var RpcServiceHost = class {
3010
3435
  _methods = /* @__PURE__ */ new Map();
3011
- register(def, impl) {
3436
+ register(def, impl, receiver = impl) {
3012
3437
  for (const methodDef of def.methods.values()) {
3013
3438
  const fn = impl[methodDef.name];
3014
3439
  if (!fn) continue;
3015
3440
  this._methods.set(wireMethodName(methodDef), {
3016
3441
  def: methodDef,
3017
- fn
3442
+ fn,
3443
+ receiver
3018
3444
  });
3019
3445
  }
3020
3446
  }
@@ -3025,9 +3451,9 @@ var RpcServiceHost = class {
3025
3451
  throw new Error(`Method not found: ${wireMethod}`);
3026
3452
  }
3027
3453
  if (context !== void 0 && entry.def.callTypeId !== 0) {
3028
- return await entry.fn(...args, context);
3454
+ return await entry.fn.call(entry.receiver, ...args, context);
3029
3455
  }
3030
- return await entry.fn(...args);
3456
+ return await entry.fn.call(entry.receiver, ...args);
3031
3457
  }
3032
3458
  getMethodDef(wireMethod) {
3033
3459
  return this._methods.get(wireMethod)?.def;
@@ -3035,6 +3461,7 @@ var RpcServiceHost = class {
3035
3461
  };
3036
3462
 
3037
3463
  // src/rpc-decorators.ts
3464
+ import { ownMetadata, resolveArgCount } from "@actuallab/core";
3038
3465
  var METHODS_META = /* @__PURE__ */ Symbol.for("actuallab.methods");
3039
3466
  var SERVICE_META = /* @__PURE__ */ Symbol.for("actuallab.service");
3040
3467
  function getMethodsMeta(cls) {
@@ -3045,17 +3472,17 @@ function getServiceMeta(cls) {
3045
3472
  }
3046
3473
  function rpcService(serviceName) {
3047
3474
  return function(_target, context) {
3048
- const meta = context.metadata[SERVICE_META] ??= {};
3475
+ const meta = ownMetadata(context.metadata, SERVICE_META);
3049
3476
  meta.name = serviceName;
3050
3477
  };
3051
3478
  }
3052
3479
  function rpcMethod(options) {
3053
3480
  return function(target, context) {
3054
3481
  const methodName = String(context.name);
3055
- const methods = context.metadata[METHODS_META] ??= {};
3482
+ const methods = ownMetadata(context.metadata, METHODS_META);
3056
3483
  methods[methodName] = {
3057
3484
  ...methods[methodName],
3058
- argCount: target.length,
3485
+ argCount: resolveArgCount(target, options?.argCount, methodName),
3059
3486
  returns: options?.returns,
3060
3487
  remoteExecutionMode: options?.remoteExecutionMode
3061
3488
  };
@@ -3601,7 +4028,7 @@ var RpcHub = class {
3601
4028
  hubId;
3602
4029
  peers = /* @__PURE__ */ new Map();
3603
4030
  serviceHost;
3604
- systemCallSender = new RpcSystemCallSender();
4031
+ systemCallSender;
3605
4032
  systemCallHandler = new RpcSystemCallHandler();
3606
4033
  /** Method registry for compact format hash ↔ name resolution.
3607
4034
  * Created lazily, populated by addService/addClient. */
@@ -3609,6 +4036,14 @@ var RpcHub = class {
3609
4036
  /** Shared reconnect delayer used by every client peer in this hub. Swap in
3610
4037
  * a custom subclass (e.g. app-level signal-gated) before peers start. */
3611
4038
  reconnectDelayer = new RpcClientPeerReconnectDelayer();
4039
+ /** Cache of client proxies keyed by peer, then by service name — so
4040
+ * repeated {@link addClient} calls for the same service+peer return one
4041
+ * proxy. All consumers then share a single computed/call/invalidation
4042
+ * stream per logical value, matching .NET's singleton client proxies (F8).
4043
+ * A later def registered under the same name extends the existing proxy
4044
+ * with its extra methods (superset), so no consumer ever receives a proxy
4045
+ * missing methods the def declares. */
4046
+ _clientProxies = /* @__PURE__ */ new WeakMap();
3612
4047
  /** Connection-lifecycle timing limits. Peers constructed against this hub
3613
4048
  * read their initial `*Ms` field values from this instance; later
3614
4049
  * mutations affect only peers built afterward (existing peers can be
@@ -3626,6 +4061,7 @@ var RpcHub = class {
3626
4061
  constructor(hubId) {
3627
4062
  this.hubId = hubId ?? crypto.randomUUID();
3628
4063
  this.serviceHost = new RpcServiceHost();
4064
+ this.systemCallSender = this._createSystemCallSender();
3629
4065
  this.systemCallSender.registry = this.registry;
3630
4066
  for (const methodName of Object.values(RpcSystemCalls)) {
3631
4067
  this.registry.register(methodName);
@@ -3686,50 +4122,38 @@ var RpcHub = class {
3686
4122
  if (!fn) continue;
3687
4123
  wrappedImpl[methodDef.name] = methodDef.callTypeId !== 0 ? this._wrapServerMethod(methodDef, fn, impl) : fn;
3688
4124
  }
3689
- this.serviceHost.register(def, wrappedImpl);
4125
+ this.serviceHost.register(def, wrappedImpl, impl);
3690
4126
  this.registry.registerService(def.name, def.methods);
3691
4127
  }
3692
- /** Create a typed client proxy for a service on a remote peer. */
4128
+ /** Create a typed client proxy for a service on a remote peer. Repeated calls
4129
+ * for the same (peer, service name) return one shared proxy; a def carrying
4130
+ * extra methods extends it (F8). */
3693
4131
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used for caller-specified proxy type
3694
4132
  addClient(peer, defOrContract) {
3695
4133
  const def = this._resolveServiceDef(defOrContract);
3696
4134
  this.registry.registerService(def.name, def.methods);
3697
- const overloads = /* @__PURE__ */ new Map();
3698
- for (const methodDef of def.methods.values()) {
3699
- let byArgCount = overloads.get(methodDef.name);
3700
- if (!byArgCount) {
3701
- byArgCount = /* @__PURE__ */ new Map();
3702
- overloads.set(methodDef.name, byArgCount);
3703
- }
3704
- byArgCount.set(
3705
- methodDef.argCount,
3706
- this._createClientMethod(peer, methodDef)
3707
- );
4135
+ let byService = this._clientProxies.get(peer);
4136
+ if (byService === void 0) {
4137
+ byService = /* @__PURE__ */ new Map();
4138
+ this._clientProxies.set(peer, byService);
3708
4139
  }
3709
- const methods = /* @__PURE__ */ new Map();
3710
- for (const [name, byArgCount] of overloads) {
3711
- if (byArgCount.size === 1) {
3712
- const [[, singleFn]] = byArgCount;
3713
- methods.set(name, singleFn);
3714
- } else {
3715
- methods.set(name, (...args) => {
3716
- const fn = byArgCount.get(args.length);
3717
- if (!fn)
3718
- throw new Error(
3719
- `No overload of ${name} accepts ${args.length} arguments`
3720
- );
3721
- return fn(...args);
3722
- });
3723
- }
4140
+ let entry = byService.get(def.name);
4141
+ if (entry === void 0) {
4142
+ entry = this._createClientProxy();
4143
+ byService.set(def.name, entry);
3724
4144
  }
3725
- return new Proxy({}, {
3726
- get: (_target, prop) => typeof prop === "string" ? methods.get(prop) : void 0
3727
- });
4145
+ this._extendClientProxy(peer, entry, def);
4146
+ return entry.proxy;
3728
4147
  }
3729
4148
  close() {
3730
4149
  for (const peer of this.peers.values()) peer.close();
3731
4150
  this.peers.clear();
3732
4151
  }
4152
+ /** Create the hub's system-call sender. Override in FusionHub to add
4153
+ * Fusion-specific system calls (e.g. $sys-c.Invalidate). */
4154
+ _createSystemCallSender() {
4155
+ return new RpcSystemCallSender();
4156
+ }
3733
4157
  /** Build an RpcServiceDef from decorator metadata on a contract class. Override in FusionHub for compute support. */
3734
4158
  _buildServiceDef(cls) {
3735
4159
  const svcMeta = getServiceMeta(cls);
@@ -3787,12 +4211,10 @@ var RpcHub = class {
3787
4211
  throw new Error(
3788
4212
  `Expected stream reference, got: ${JSON.stringify(result)}`
3789
4213
  );
3790
- const stream = new RpcStream(ref, peer);
3791
- peer.remoteObjects.register(stream);
3792
- return stream;
4214
+ return new RpcStream(ref, peer);
3793
4215
  };
3794
4216
  }
3795
- const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
4217
+ const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default || methodDef.timeouts !== void 0 ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode, timeouts: methodDef.timeouts } : void 0;
3796
4218
  return async (...args) => {
3797
4219
  if (mustCheckConnection && !peer.isConnected)
3798
4220
  throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
@@ -3802,7 +4224,58 @@ var RpcHub = class {
3802
4224
  return resolveStreamRefs(result, peer);
3803
4225
  };
3804
4226
  }
4227
+ // Private methods
4228
+ _createClientProxy() {
4229
+ const methods = /* @__PURE__ */ new Map();
4230
+ const proxy = new Proxy({}, {
4231
+ get: (_target, prop) => typeof prop === "string" ? methods.get(prop) : void 0
4232
+ });
4233
+ return { proxy, methods, overloads: /* @__PURE__ */ new Map() };
4234
+ }
4235
+ _extendClientProxy(peer, entry, def) {
4236
+ for (const methodDef of def.methods.values()) {
4237
+ const existing = entry.overloads.get(methodDef.name)?.get(methodDef.argCount);
4238
+ if (existing !== void 0 && !isSameWireMethod(existing.def, methodDef))
4239
+ throw new Error(
4240
+ `RpcHub.addClient: conflicting definition of '${wireMethodName(methodDef)}' under service '${def.name}' \u2014 a different signature is already registered.`
4241
+ );
4242
+ }
4243
+ const changed = /* @__PURE__ */ new Set();
4244
+ for (const methodDef of def.methods.values()) {
4245
+ let byArgCount = entry.overloads.get(methodDef.name);
4246
+ if (byArgCount === void 0) {
4247
+ byArgCount = /* @__PURE__ */ new Map();
4248
+ entry.overloads.set(methodDef.name, byArgCount);
4249
+ }
4250
+ if (byArgCount.has(methodDef.argCount))
4251
+ continue;
4252
+ byArgCount.set(methodDef.argCount, {
4253
+ def: methodDef,
4254
+ fn: this._createClientMethod(peer, methodDef)
4255
+ });
4256
+ changed.add(methodDef.name);
4257
+ }
4258
+ for (const name of changed) {
4259
+ const byArgCount = entry.overloads.get(name);
4260
+ entry.methods.set(name, buildOverloadFn(name, byArgCount));
4261
+ }
4262
+ }
3805
4263
  };
4264
+ function buildOverloadFn(name, byArgCount) {
4265
+ if (byArgCount.size === 1) {
4266
+ const [[, only]] = byArgCount;
4267
+ return only.fn;
4268
+ }
4269
+ return (...args) => {
4270
+ const overload = byArgCount.get(args.length);
4271
+ if (overload === void 0)
4272
+ throw new Error(`No overload of ${name} accepts ${args.length} arguments`);
4273
+ return overload.fn(...args);
4274
+ };
4275
+ }
4276
+ function isSameWireMethod(a, b) {
4277
+ return a.wireArgCount === b.wireArgCount && a.callTypeId === b.callTypeId && a.stream === b.stream && a.noWait === b.noWait && a.remoteExecutionMode === b.remoteExecutionMode;
4278
+ }
3806
4279
 
3807
4280
  // src/rpc-peer-ref-builder.ts
3808
4281
  var RpcPeerRefBuilder = class {
@@ -3869,7 +4342,7 @@ function createMethod(peer, methodDef) {
3869
4342
  throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
3870
4343
  const callArgs = args.slice(0, methodDef.argCount);
3871
4344
  const wireName = wireMethodName(methodDef);
3872
- const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
4345
+ const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default || methodDef.timeouts !== void 0 ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode, timeouts: methodDef.timeouts } : void 0;
3873
4346
  const outboundCall = peer.call(wireName, callArgs, options);
3874
4347
  return outboundCall.result.promise;
3875
4348
  };
@@ -4064,8 +4537,10 @@ export {
4064
4537
  ENVELOPE_DELIMITER,
4065
4538
  FRAME_DELIMITER,
4066
4539
  IncreasingSeqCompressor,
4540
+ REMOTE_EXCEPTION_TYPE_REF,
4067
4541
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
4068
4542
  RpcCallStage,
4543
+ RpcCallTimeouts,
4069
4544
  RpcCallTypeId,
4070
4545
  RpcClientPeer,
4071
4546
  RpcClientPeerReconnectDelayer,
@@ -4120,6 +4595,7 @@ export {
4120
4595
  isConnected,
4121
4596
  likelyConnected,
4122
4597
  parseStreamRef,
4598
+ readPolymorphismMarker,
4123
4599
  resolveStreamRefs,
4124
4600
  rpcMethod,
4125
4601
  rpcService,
@@ -4131,6 +4607,7 @@ export {
4131
4607
  splitBinaryFrame,
4132
4608
  splitCompactBinaryFrame,
4133
4609
  splitFrame,
4610
+ toExceptionInfo,
4134
4611
  wireMethodName,
4135
4612
  xxh3_64,
4136
4613
  xxh3_64str