@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.cjs CHANGED
@@ -34,8 +34,10 @@ __export(index_exports, {
34
34
  ENVELOPE_DELIMITER: () => ENVELOPE_DELIMITER,
35
35
  FRAME_DELIMITER: () => FRAME_DELIMITER,
36
36
  IncreasingSeqCompressor: () => IncreasingSeqCompressor,
37
+ REMOTE_EXCEPTION_TYPE_REF: () => REMOTE_EXCEPTION_TYPE_REF,
37
38
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT: () => RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
38
39
  RpcCallStage: () => RpcCallStage,
40
+ RpcCallTimeouts: () => RpcCallTimeouts,
39
41
  RpcCallTypeId: () => RpcCallTypeId,
40
42
  RpcClientPeer: () => RpcClientPeer,
41
43
  RpcClientPeerReconnectDelayer: () => RpcClientPeerReconnectDelayer,
@@ -90,6 +92,7 @@ __export(index_exports, {
90
92
  isConnected: () => isConnected,
91
93
  likelyConnected: () => likelyConnected,
92
94
  parseStreamRef: () => parseStreamRef,
95
+ readPolymorphismMarker: () => readPolymorphismMarker,
93
96
  resolveStreamRefs: () => resolveStreamRefs,
94
97
  rpcMethod: () => rpcMethod,
95
98
  rpcService: () => rpcService,
@@ -101,6 +104,7 @@ __export(index_exports, {
101
104
  splitBinaryFrame: () => splitBinaryFrame,
102
105
  splitCompactBinaryFrame: () => splitCompactBinaryFrame,
103
106
  splitFrame: () => splitFrame,
107
+ toExceptionInfo: () => toExceptionInfo,
104
108
  wireMethodName: () => wireMethodName,
105
109
  xxh3_64: () => xxh3_64,
106
110
  xxh3_64str: () => xxh3_64str
@@ -271,6 +275,24 @@ function readVarUint(data, offset) {
271
275
  } while (shift < 35);
272
276
  return { value: value >>> 0, bytesRead };
273
277
  }
278
+ function skipHeaders(data, pos, headerCount) {
279
+ for (let h = 0; h < headerCount; h++) {
280
+ const keyLen = data[pos++];
281
+ pos += keyLen;
282
+ const valLen = readVarUint(data, pos);
283
+ pos += valLen.bytesRead + valLen.value;
284
+ }
285
+ return pos;
286
+ }
287
+ function readPolymorphismMarker(data, offset) {
288
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
289
+ const length = view.getUint16(offset, true);
290
+ if (length === 0)
291
+ return { bytesRead: 2, isNull: false };
292
+ if (length === 1)
293
+ return { bytesRead: 2, isNull: true };
294
+ throw new Error("polymorphic payloads are not supported by the TS client");
295
+ }
274
296
  function concatUint8Arrays(arrays) {
275
297
  let totalLength = 0;
276
298
  for (const arr of arrays) totalLength += arr.length;
@@ -334,14 +356,6 @@ function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder)
334
356
  pos += methodLen.value;
335
357
  const argDataLen = view.getInt32(pos, true);
336
358
  pos += 4;
337
- if (headerCount > 0) {
338
- for (let h = 0; h < headerCount; h++) {
339
- const keyLen = data[pos++];
340
- pos += keyLen;
341
- const valLen = readVarUint(data, pos);
342
- pos += valLen.bytesRead + valLen.value;
343
- }
344
- }
345
359
  const args = [];
346
360
  const argEnd = pos + argDataLen;
347
361
  if (argDataLen > 0) {
@@ -349,8 +363,10 @@ function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder)
349
363
  for (const decoded of decoder.decodeMulti(argSlice)) {
350
364
  args.push(decoded);
351
365
  }
352
- pos = argEnd;
353
366
  }
367
+ pos = argEnd;
368
+ if (headerCount > 0)
369
+ pos = skipHeaders(data, pos, headerCount);
354
370
  const message = {
355
371
  Method: method,
356
372
  RelatedId: relId.value,
@@ -428,14 +444,6 @@ function deserializeCompactBinaryMessage(data, offset, registry, decoder = defau
428
444
  const method = registry?.getName(methodHash | 0) ?? `<hash:0x${methodHash.toString(16).padStart(8, "0")}>`;
429
445
  const argDataLen = view.getInt32(pos, true);
430
446
  pos += 4;
431
- if (headerCount > 0) {
432
- for (let h = 0; h < headerCount; h++) {
433
- const keyLen = data[pos++];
434
- pos += keyLen;
435
- const valLen = readVarUint(data, pos);
436
- pos += valLen.bytesRead + valLen.value;
437
- }
438
- }
439
447
  const args = [];
440
448
  const argEnd = pos + argDataLen;
441
449
  if (argDataLen > 0) {
@@ -443,8 +451,10 @@ function deserializeCompactBinaryMessage(data, offset, registry, decoder = defau
443
451
  for (const decoded of decoder.decodeMulti(argSlice)) {
444
452
  args.push(decoded);
445
453
  }
446
- pos = argEnd;
447
454
  }
455
+ pos = argEnd;
456
+ if (headerCount > 0)
457
+ pos = skipHeaders(data, pos, headerCount);
448
458
  const message = {
449
459
  Method: method,
450
460
  RelatedId: relId.value,
@@ -714,7 +724,8 @@ function defineRpcService(name, methods) {
714
724
  callTypeId: input.callTypeId ?? 0,
715
725
  stream: input.returns === RpcType.stream,
716
726
  noWait: isNoWait,
717
- remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default
727
+ remoteExecutionMode: isNoWait ? 0 : input.remoteExecutionMode ?? RpcRemoteExecutionMode.Default,
728
+ timeouts: input.timeouts
718
729
  });
719
730
  }
720
731
  return { name, methods: map };
@@ -748,6 +759,14 @@ var RpcOutboundCall = class {
748
759
  removeOnOk = true;
749
760
  /** The serialized wire data — stored for re-sending on reconnect. */
750
761
  serializedWireData = "";
762
+ /** Per-call connect/run timeouts enforced by the peer's maintenance loop
763
+ * (R12). `undefined` means unbounded (the query default). */
764
+ timeouts;
765
+ /** `Date.now()` when this call was created (used for the connect timeout). */
766
+ startedAt = Date.now();
767
+ /** `Date.now()` when this call was last put on the wire, or 0 if it hasn't
768
+ * been sent yet (used for the run timeout). */
769
+ sentAt = 0;
751
770
  /**
752
771
  * Bitfield of {@link RpcCallStage} flags indicating how far this call
753
772
  * has progressed. Used by the `$sys.Reconnect` protocol to tell the
@@ -837,20 +856,77 @@ var RpcInboundCall = class {
837
856
  callId;
838
857
  method;
839
858
  args;
859
+ /** 0 = still processing, 1 = result computed and sent at least once. */
860
+ completedStage = 0;
861
+ _resend;
862
+ _abortController = new AbortController();
840
863
  constructor(callId, method, args) {
841
864
  this.callId = callId;
842
865
  this.method = method;
843
866
  this.args = args;
844
867
  }
868
+ /** Aborted when the remote peer cancels this call via `$sys.Cancel` (R17).
869
+ * Threaded into the service handler so it can abort in-flight work. */
870
+ get signal() {
871
+ return this._abortController.signal;
872
+ }
873
+ /** True once the remote peer cancelled this call — the response is then
874
+ * suppressed even though the call may still be registered (R9 dedup). */
875
+ get isCancelled() {
876
+ return this._abortController.signal.aborted;
877
+ }
878
+ cancel() {
879
+ this._abortController.abort();
880
+ }
881
+ // Records the closure that re-sends this call's already-computed result;
882
+ // marks the call as completed. Mirrors .NET's post-`ProcessStage1Plus` state.
883
+ setResult(resend) {
884
+ this._resend = resend;
885
+ this.completedStage = 1;
886
+ }
887
+ // Re-sends the result to the remote peer if it was already computed.
888
+ // A resent frame for an in-flight call is a no-op — the original dispatch
889
+ // will send the result when it completes. Mirrors .NET
890
+ // `RpcInboundCall.TryReprocess`.
891
+ resendResult() {
892
+ if (this.completedStage >= 1)
893
+ this._resend?.();
894
+ }
845
895
  };
846
896
  var RpcInboundCallTracker = class {
847
897
  _calls = /* @__PURE__ */ new Map();
898
+ _completedIds = [];
899
+ /** Cap on retained completed calls — see `RpcLimits.completedInboundCallsLimit`. */
900
+ completedCallsLimit = 1e3;
848
901
  get size() {
849
902
  return this._calls.size;
850
903
  }
851
904
  register(call) {
852
905
  this._calls.set(call.callId, call);
853
906
  }
907
+ // Registers `call` unless its id is already tracked, in which case the
908
+ // existing call is returned. Mirrors .NET `RpcInboundCallTracker.GetOrRegister`.
909
+ getOrRegister(call) {
910
+ const existing = this._calls.get(call.callId);
911
+ if (existing !== void 0)
912
+ return existing;
913
+ this._calls.set(call.callId, call);
914
+ return call;
915
+ }
916
+ // Keeps the completed `call` registered for duplicate-frame dedup, evicting
917
+ // the oldest completed calls beyond `completedCallsLimit`. Deviation from
918
+ // .NET (which unregisters a call once its result is sent): TS peers
919
+ // blind-resend after reconnect, so a completed call must stay resolvable
920
+ // to avoid re-executing the handler — but only within a bounded window.
921
+ markCompleted(call) {
922
+ if (this._calls.get(call.callId) !== call)
923
+ return;
924
+ this._completedIds.push(call.callId);
925
+ while (this._completedIds.length > this.completedCallsLimit) {
926
+ const id = this._completedIds.shift();
927
+ this._calls.delete(id);
928
+ }
929
+ }
854
930
  get(callId) {
855
931
  return this._calls.get(callId);
856
932
  }
@@ -861,6 +937,23 @@ var RpcInboundCallTracker = class {
861
937
  }
862
938
  clear() {
863
939
  this._calls.clear();
940
+ this._completedIds.length = 0;
941
+ }
942
+ };
943
+
944
+ // src/rpc-call-timeouts.ts
945
+ var RpcCallTimeouts = class _RpcCallTimeouts {
946
+ /** Both timeouts unbounded — the query default (.NET `RpcCallTimeouts.None`). */
947
+ static None = new _RpcCallTimeouts(Infinity, Infinity);
948
+ /** Command default: 1.5 s connect / 10 s run (.NET `RpcCallTimeouts.Default.Command`). */
949
+ static Command = new _RpcCallTimeouts(1500, 1e4);
950
+ /** Max time a call may wait for the connection before it is sent. */
951
+ connectTimeoutMs;
952
+ /** Max time a sent call may stay unanswered before it is failed. */
953
+ runTimeoutMs;
954
+ constructor(connectTimeoutMs = Infinity, runTimeoutMs = Infinity) {
955
+ this.connectTimeoutMs = connectTimeoutMs;
956
+ this.runTimeoutMs = runTimeoutMs;
864
957
  }
865
958
  };
866
959
 
@@ -874,6 +967,12 @@ var RpcError = class extends Error {
874
967
  this.typeName = typeName;
875
968
  }
876
969
  };
970
+ var REMOTE_EXCEPTION_TYPE_REF = "ActualLab.Serialization.RemoteException, ActualLab.Core";
971
+ function toExceptionInfo(error) {
972
+ const name = error instanceof Error ? error.name : "Error";
973
+ const message = error instanceof Error ? error.message : String(error);
974
+ return { TypeRef: REMOTE_EXCEPTION_TYPE_REF, Message: `${name}: ${message}` };
975
+ }
877
976
 
878
977
  // src/increasing-seq-compressor.ts
879
978
  var IncreasingSeqCompressor = {
@@ -978,6 +1077,7 @@ var import_core5 = require("@actuallab/core");
978
1077
  var { warnLog: warnLog3 } = getLogs("RpcSharedStream");
979
1078
  var DEFAULT_ACK_PERIOD = 256;
980
1079
  var DEFAULT_ACK_ADVANCE = 128;
1080
+ var RTT_WINDOW_MS = 2e4;
981
1081
  var RpcStreamSender = class _RpcStreamSender {
982
1082
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
983
1083
  id;
@@ -1019,6 +1119,9 @@ var RpcStreamSender = class _RpcStreamSender {
1019
1119
  _lastAckedIndex = 0;
1020
1120
  _abortController = new AbortController();
1021
1121
  _iterator = null;
1122
+ // -- RTT sampling (send → matching ack round trips, windowed minimum) --
1123
+ _pendingSendTimes = [];
1124
+ _rttSamples = [];
1022
1125
  // -- Recorder-controller metrics (Step 9.1) --
1023
1126
  /** Total items skipped via real-time `canSkipTo` compaction. */
1024
1127
  _skipCount = 0;
@@ -1043,6 +1146,21 @@ var RpcStreamSender = class _RpcStreamSender {
1043
1146
  get skipCount() {
1044
1147
  return this._skipCount;
1045
1148
  }
1149
+ // Windowed-MIN ack round trip (ms, -1 until sampled): converges on propagation
1150
+ // RTT — unlike a mean/EMA it is not inflated by self-induced queuing.
1151
+ get minRttMs() {
1152
+ const cutoffMs = Date.now() - RTT_WINDOW_MS;
1153
+ while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1154
+ this._rttSamples.shift();
1155
+ if (this._rttSamples.length === 0)
1156
+ return -1;
1157
+ let min = this._rttSamples[0].rttMs;
1158
+ for (const s of this._rttSamples) {
1159
+ if (s.rttMs < min)
1160
+ min = s.rttMs;
1161
+ }
1162
+ return min;
1163
+ }
1046
1164
  // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
1047
1165
  _acks = [];
1048
1166
  _whenAckReady = null;
@@ -1078,13 +1196,39 @@ var RpcStreamSender = class _RpcStreamSender {
1078
1196
  */
1079
1197
  onAck(nextIndex, hostId) {
1080
1198
  const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
1081
- if (!this._started.isCompleted) this._started.resolve();
1199
+ if (mustReset && hostId !== this.id.hostId) {
1200
+ this._sendDisconnect();
1201
+ return;
1202
+ }
1203
+ if (!this._started.isCompleted) {
1204
+ if (mustReset && nextIndex === 0)
1205
+ this._started.resolve();
1206
+ else {
1207
+ this._sendDisconnect();
1208
+ return;
1209
+ }
1210
+ } else if (this._ended) {
1211
+ this._sendDisconnect();
1212
+ return;
1213
+ } else if (mustReset && !this.allowReconnect) {
1214
+ this._sendDisconnect();
1215
+ return;
1216
+ }
1217
+ this._sampleRtt(nextIndex, mustReset);
1082
1218
  this._acks.push({ nextIndex, mustReset });
1083
1219
  if (this._whenAckReady) {
1084
1220
  this._whenAckReady.resolve();
1085
1221
  this._whenAckReady = null;
1086
1222
  }
1087
1223
  }
1224
+ // Tells the consumer this shared stream is gone, then disposes it —
1225
+ // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1226
+ _sendDisconnect() {
1227
+ const conn = this.peer.connection;
1228
+ if (conn)
1229
+ this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1230
+ this.disconnect();
1231
+ }
1088
1232
  /** Called by system call handler when $sys.AckEnd is received from the client. */
1089
1233
  onAckEnd(_hostId) {
1090
1234
  this.disconnect();
@@ -1109,6 +1253,7 @@ var RpcStreamSender = class _RpcStreamSender {
1109
1253
  this._nextIndex,
1110
1254
  item
1111
1255
  );
1256
+ this._pendingSendTimes.push({ index: this._nextIndex, sentAtMs: Date.now() });
1112
1257
  }
1113
1258
  this._nextIndex++;
1114
1259
  }
@@ -1133,7 +1278,7 @@ var RpcStreamSender = class _RpcStreamSender {
1133
1278
  this._ended = true;
1134
1279
  const conn = this.peer.connection;
1135
1280
  if (!conn) return;
1136
- const errorInfo = error ? { TypeRef: "System.Exception", Message: error.message } : { TypeRef: "", Message: "" };
1281
+ const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1137
1282
  this.peer.hub.systemCallSender.end(
1138
1283
  conn,
1139
1284
  this.peer.serializationFormat,
@@ -1351,6 +1496,26 @@ var RpcStreamSender = class _RpcStreamSender {
1351
1496
  await this._whenAckReady;
1352
1497
  this._whenAckReady = null;
1353
1498
  }
1499
+ // One RTT sample per ack: time from sending the newest acked item to the
1500
+ // ack's arrival (a slight upper bound — includes the server's ack cadence).
1501
+ _sampleRtt(ackNextIndex, mustReset) {
1502
+ if (mustReset) {
1503
+ this._pendingSendTimes.length = 0;
1504
+ return;
1505
+ }
1506
+ let sentAtMs = -1;
1507
+ while (this._pendingSendTimes.length > 0 && this._pendingSendTimes[0].index < ackNextIndex) {
1508
+ sentAtMs = this._pendingSendTimes[0].sentAtMs;
1509
+ this._pendingSendTimes.shift();
1510
+ }
1511
+ if (sentAtMs < 0)
1512
+ return;
1513
+ const nowMs = Date.now();
1514
+ const cutoffMs = nowMs - RTT_WINDOW_MS;
1515
+ while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1516
+ this._rttSamples.shift();
1517
+ this._rttSamples.push({ atMs: nowMs, rttMs: nowMs - sentAtMs });
1518
+ }
1354
1519
  // -- IRpcObject --
1355
1520
  reconnect() {
1356
1521
  }
@@ -1575,11 +1740,7 @@ var RpcStream = class _RpcStream {
1575
1740
  if (index > this._nextExpectedIndex) {
1576
1741
  warnLog4?.log(`item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1577
1742
  if (!this.allowReconnect) {
1578
- this._completed = true;
1579
- this._completionError = new Error(
1580
- `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1581
- );
1582
- this._notifyConsumer();
1743
+ this._complete(this._gapError(index));
1583
1744
  return;
1584
1745
  }
1585
1746
  this._sendAck(this._nextExpectedIndex, true);
@@ -1599,11 +1760,7 @@ var RpcStream = class _RpcStream {
1599
1760
  if (index > this._nextExpectedIndex) {
1600
1761
  warnLog4?.log(`batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1601
1762
  if (!this.allowReconnect) {
1602
- this._completed = true;
1603
- this._completionError = new Error(
1604
- `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1605
- );
1606
- this._notifyConsumer();
1763
+ this._complete(this._gapError(index));
1607
1764
  return;
1608
1765
  }
1609
1766
  this._sendAck(this._nextExpectedIndex, true);
@@ -1620,9 +1777,20 @@ var RpcStream = class _RpcStream {
1620
1777
  /** Called by system call handler when the stream ends ($sys.End). */
1621
1778
  onEnd(index, error) {
1622
1779
  if (this._disposed || this._completed) return;
1623
- this._completed = true;
1624
- this._completionError = error;
1625
- this._notifyConsumer();
1780
+ if (index > this._nextExpectedIndex) {
1781
+ warnLog4?.log(`end index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1782
+ if (!this.allowReconnect) {
1783
+ this._complete(this._gapError(index));
1784
+ return;
1785
+ }
1786
+ this._sendAck(this._nextExpectedIndex, true);
1787
+ return;
1788
+ }
1789
+ if (index < this._nextExpectedIndex) {
1790
+ this._maybeSendAck(Math.min(index, this._nextConsumedIndex));
1791
+ return;
1792
+ }
1793
+ this._complete(error);
1626
1794
  }
1627
1795
  // -- IRpcObject --
1628
1796
  reconnect() {
@@ -1656,6 +1824,7 @@ var RpcStream = class _RpcStream {
1656
1824
  next: async () => {
1657
1825
  if (!this._started) {
1658
1826
  this._started = true;
1827
+ this.peer.remoteObjects.register(this);
1659
1828
  this._sendAck(0, true);
1660
1829
  }
1661
1830
  this._maybeSendAck(this._nextConsumedIndex);
@@ -1666,6 +1835,7 @@ var RpcStream = class _RpcStream {
1666
1835
  return { value, done: false };
1667
1836
  }
1668
1837
  if (this._completed) {
1838
+ this.dispose();
1669
1839
  if (this._completionError) throw this._completionError;
1670
1840
  return { value: void 0, done: true };
1671
1841
  }
@@ -1674,7 +1844,8 @@ var RpcStream = class _RpcStream {
1674
1844
  }
1675
1845
  },
1676
1846
  return: () => {
1677
- this._sendAckEnd();
1847
+ if (this._started && !this._completed)
1848
+ this._sendAckEnd();
1678
1849
  this.dispose();
1679
1850
  return Promise.resolve({ value: void 0, done: true });
1680
1851
  }
@@ -1690,6 +1861,22 @@ var RpcStream = class _RpcStream {
1690
1861
  this._notifyConsumer();
1691
1862
  }
1692
1863
  // -- Private --
1864
+ // Mirrors .NET RpcStream.CloseFromLock: acknowledge the end to the sender
1865
+ // (so its RpcSharedStream is released) and unregister immediately.
1866
+ _complete(error) {
1867
+ if (this._completed) return;
1868
+ this._completed = true;
1869
+ this._completionError = error;
1870
+ this._sendAckEnd();
1871
+ if (this.kind === RpcObjectKind.Remote)
1872
+ this.peer.remoteObjects.unregister(this);
1873
+ this._notifyConsumer();
1874
+ }
1875
+ _gapError(index) {
1876
+ return new Error(
1877
+ `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1878
+ );
1879
+ }
1693
1880
  _notifyConsumer() {
1694
1881
  if (this._consumerWaiting !== null) {
1695
1882
  this._consumerWaiting.resolve();
@@ -1706,7 +1893,7 @@ var RpcStream = class _RpcStream {
1706
1893
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
1707
1894
  _sendAck(nextIndex, mustReset) {
1708
1895
  this._ackSentUpTo = nextIndex;
1709
- const conn = this.peer.connection;
1896
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
1710
1897
  if (conn) {
1711
1898
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
1712
1899
  this.peer.hub.systemCallSender.ack(
@@ -1719,7 +1906,7 @@ var RpcStream = class _RpcStream {
1719
1906
  }
1720
1907
  }
1721
1908
  _sendAckEnd() {
1722
- const conn = this.peer.connection;
1909
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
1723
1910
  if (conn) {
1724
1911
  this.peer.hub.systemCallSender.ackEnd(
1725
1912
  conn,
@@ -1739,9 +1926,7 @@ function resolveStreamRefs(value, peer) {
1739
1926
  if (typeof value === "string") {
1740
1927
  const ref = parseStreamRef(value);
1741
1928
  if (ref !== null) {
1742
- const stream = new RpcStream(ref, peer);
1743
- peer.remoteObjects.register(stream);
1744
- return stream;
1929
+ return new RpcStream(ref, peer);
1745
1930
  }
1746
1931
  return value;
1747
1932
  }
@@ -1794,6 +1979,11 @@ var RpcSystemCallHandler = class {
1794
1979
  case RpcSystemCalls.ok: {
1795
1980
  const call = peer.outboundCalls.get(relatedId);
1796
1981
  if (call !== void 0) {
1982
+ if (args.length > 1) {
1983
+ peer.outboundCalls.remove(relatedId);
1984
+ call.result.reject(polymorphicPayloadError(method, args.length, 1));
1985
+ break;
1986
+ }
1797
1987
  call.completedStage |= RpcCallStage.ResultReady;
1798
1988
  if (call.removeOnOk) {
1799
1989
  peer.outboundCalls.remove(relatedId);
@@ -1807,19 +1997,15 @@ var RpcSystemCallHandler = class {
1807
1997
  break;
1808
1998
  }
1809
1999
  case RpcSystemCalls.error: {
1810
- const call = peer.outboundCalls.remove(relatedId);
2000
+ const call = peer.outboundCalls.get(relatedId);
1811
2001
  if (call !== void 0) {
2002
+ call.completedStage |= RpcCallStage.ResultReady;
2003
+ if (call.removeOnOk) {
2004
+ peer.outboundCalls.remove(relatedId);
2005
+ }
1812
2006
  const errorInfo = args[0];
1813
2007
  const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
1814
- const errorType = errorInfo?.TypeRef ?? errorInfo?.typeRef;
1815
- let typeName;
1816
- if (typeof errorType === "string") {
1817
- const commaIdx = errorType.indexOf(",");
1818
- typeName = commaIdx >= 0 ? errorType.slice(0, commaIdx) : errorType;
1819
- } else if (errorType !== null && typeof errorType === "object") {
1820
- const t = errorType;
1821
- typeName = t.TypeName ?? t.typeName;
1822
- }
2008
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1823
2009
  if (typeName === "ActualLab.Rpc.RpcRerouteException")
1824
2010
  warnLog5?.log("Got RpcRerouteException from remote peer:", msg);
1825
2011
  call.result.reject(new RpcError(msg, typeName));
@@ -1827,17 +2013,29 @@ var RpcSystemCallHandler = class {
1827
2013
  break;
1828
2014
  }
1829
2015
  case RpcSystemCalls.cancel: {
1830
- peer.inboundCalls.remove(relatedId);
2016
+ const call = peer.inboundCalls.remove(relatedId);
2017
+ call?.cancel();
1831
2018
  break;
1832
2019
  }
1833
2020
  case RpcSystemCalls.keepAlive: {
1834
2021
  peer.notifyKeepAliveReceived();
2022
+ const keepAliveIds = args[0];
2023
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
2024
+ const unknownIds = [];
2025
+ for (const id of keepAliveIds)
2026
+ if (peer.sharedObjects.get(id) === void 0)
2027
+ unknownIds.push(id);
2028
+ if (unknownIds.length > 0)
2029
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
2030
+ }
1835
2031
  break;
1836
2032
  }
1837
2033
  case RpcSystemCalls.item: {
1838
2034
  const stream = peer.remoteObjects.get(relatedId);
1839
2035
  if (!stream)
1840
2036
  debugLog?.log(`$sys.I: no stream for relatedId=${relatedId}`);
2037
+ else if (args.length > 2)
2038
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1841
2039
  else
1842
2040
  stream.onItem(
1843
2041
  args[0],
@@ -1849,6 +2047,8 @@ var RpcSystemCallHandler = class {
1849
2047
  const stream = peer.remoteObjects.get(relatedId);
1850
2048
  if (!stream)
1851
2049
  debugLog?.log(`$sys.B: no stream for relatedId=${relatedId}`);
2050
+ else if (args.length > 2)
2051
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1852
2052
  else if (Array.isArray(args[1])) {
1853
2053
  const items = args[1];
1854
2054
  for (let i = 0; i < items.length; i++)
@@ -1863,20 +2063,32 @@ var RpcSystemCallHandler = class {
1863
2063
  debugLog?.log(`$sys.End: no stream for relatedId=${relatedId}`);
1864
2064
  } else {
1865
2065
  const errorInfo = args[1];
2066
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1866
2067
  const msg = errorInfo?.Message ?? errorInfo?.message;
1867
- const error = msg ? new Error(msg) : null;
2068
+ let error = null;
2069
+ if (typeName || msg) {
2070
+ error = new Error(msg ?? typeName ?? "RPC stream error");
2071
+ if (typeName)
2072
+ error.name = typeName;
2073
+ }
1868
2074
  stream.onEnd(args[0], error);
1869
2075
  }
1870
2076
  break;
1871
2077
  }
1872
2078
  case RpcSystemCalls.ack: {
1873
2079
  const sender = peer.sharedObjects.get(relatedId);
1874
- sender?.onAck(args[0], args[1]);
2080
+ if (sender)
2081
+ sender.onAck(args[0], args[1]);
2082
+ else if (peer.connection !== void 0)
2083
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1875
2084
  break;
1876
2085
  }
1877
2086
  case RpcSystemCalls.ackEnd: {
1878
2087
  const sender = peer.sharedObjects.get(relatedId);
1879
- sender?.onAckEnd(args[0]);
2088
+ if (sender)
2089
+ sender.onAckEnd(args[0]);
2090
+ else if (peer.connection !== void 0)
2091
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1880
2092
  break;
1881
2093
  }
1882
2094
  case RpcSystemCalls.disconnect: {
@@ -1910,19 +2122,34 @@ var RpcSystemCallHandler = class {
1910
2122
  */
1911
2123
  _handleReconnect(relatedId, args, peer) {
1912
2124
  if (peer.connection === void 0) return;
2125
+ const handshakeIndex = args[0];
2126
+ if (typeof handshakeIndex === "number" && handshakeIndex !== peer.ownHandshakeIndex) {
2127
+ peer.hub.systemCallSender.error(
2128
+ peer.connection,
2129
+ peer.serializationFormat,
2130
+ relatedId,
2131
+ new Error(
2132
+ `TooLateToReconnect: own handshake index ${peer.ownHandshakeIndex} != ${handshakeIndex}`
2133
+ )
2134
+ );
2135
+ return;
2136
+ }
1913
2137
  let unknownIds = [];
1914
2138
  try {
1915
2139
  const stagesRaw = args[1];
1916
2140
  if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1917
2141
  const unknownSet = /* @__PURE__ */ new Set();
1918
- for (const [, rawValue] of Object.entries(stagesRaw)) {
2142
+ for (const [stageKey, rawValue] of Object.entries(stagesRaw)) {
1919
2143
  const bytes = _toBytes(rawValue);
1920
2144
  if (bytes === null) continue;
2145
+ const completedStage = parseInt(stageKey, 10) || 0;
1921
2146
  const callIds = IncreasingSeqCompressor.deserialize(bytes);
1922
2147
  for (const callId of callIds) {
1923
- if (peer.inboundCalls.get(callId) === void 0) {
2148
+ const call = peer.inboundCalls.get(callId);
2149
+ if (call === void 0)
1924
2150
  unknownSet.add(callId);
1925
- }
2151
+ else if (completedStage < 1)
2152
+ call.resendResult();
1926
2153
  }
1927
2154
  }
1928
2155
  unknownIds = [...unknownSet].sort((a, b) => a - b);
@@ -1934,6 +2161,29 @@ var RpcSystemCallHandler = class {
1934
2161
  peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1935
2162
  }
1936
2163
  };
2164
+ function polymorphicPayloadError(method, got, expected) {
2165
+ return new Error(
2166
+ `${method}: expected ${expected} value(s), got ${got} \u2014 polymorphic payloads are not supported by the TS client`
2167
+ );
2168
+ }
2169
+ function extractTypeName(typeRef) {
2170
+ if (typeof typeRef === "string") {
2171
+ if (typeRef.length === 0)
2172
+ return void 0;
2173
+ const commaIdx = typeRef.indexOf(",");
2174
+ return commaIdx >= 0 ? typeRef.slice(0, commaIdx) : typeRef;
2175
+ }
2176
+ if (typeRef !== null && typeof typeRef === "object") {
2177
+ const t = typeRef;
2178
+ const aqn = t.AssemblyQualifiedName ?? t.assemblyQualifiedName;
2179
+ if (typeof aqn === "string" && aqn.length > 0) {
2180
+ const commaIdx = aqn.indexOf(",");
2181
+ return commaIdx >= 0 ? aqn.slice(0, commaIdx) : aqn;
2182
+ }
2183
+ return t.TypeName ?? t.typeName;
2184
+ }
2185
+ return void 0;
2186
+ }
1937
2187
  function _toBytes(value) {
1938
2188
  if (value instanceof Uint8Array) return value;
1939
2189
  if (typeof value === "string") {
@@ -2017,12 +2267,11 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
2017
2267
  }
2018
2268
  }
2019
2269
  error(conn, format, relatedId, error) {
2020
- const message = error instanceof Error ? error.message : String(error);
2021
2270
  this._send(
2022
2271
  conn,
2023
2272
  format,
2024
2273
  { Method: RpcSystemCalls.error, RelatedId: relatedId },
2025
- [{ Message: message }]
2274
+ [toExceptionInfo(error)]
2026
2275
  );
2027
2276
  }
2028
2277
  cancel(conn, format, relatedId) {
@@ -2036,6 +2285,14 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
2036
2285
  activeCallIds
2037
2286
  ]);
2038
2287
  }
2288
+ // Tells the remote peer the listed shared-object ids are gone on our side,
2289
+ // so its matching remote objects (e.g. RpcStream consumers) fail fast
2290
+ // instead of hanging. Mirrors .NET RpcSystemCallSender.Disconnect.
2291
+ disconnect(conn, format, localIds) {
2292
+ this._send(conn, format, { Method: RpcSystemCalls.disconnect }, [
2293
+ localIds
2294
+ ]);
2295
+ }
2039
2296
  item(conn, format, localId, index, item) {
2040
2297
  this._itemEnv.RelatedId = localId;
2041
2298
  this._itemArgs[0] = index;
@@ -2073,7 +2330,13 @@ var RpcSystemCallSender = class _RpcSystemCallSender {
2073
2330
  var RpcRemoteObjectTracker = class {
2074
2331
  _objects = /* @__PURE__ */ new Map();
2075
2332
  register(obj) {
2076
- this._objects.set(obj.id.localId, obj);
2333
+ const localId = obj.id.localId;
2334
+ const existing = this._objects.get(localId);
2335
+ if (existing === obj)
2336
+ return;
2337
+ if (existing !== void 0)
2338
+ existing.disconnect();
2339
+ this._objects.set(localId, obj);
2077
2340
  }
2078
2341
  get(localId) {
2079
2342
  return this._objects.get(localId);
@@ -2082,7 +2345,9 @@ var RpcRemoteObjectTracker = class {
2082
2345
  return this._objects.keys();
2083
2346
  }
2084
2347
  unregister(obj) {
2085
- this._objects.delete(obj.id.localId);
2348
+ const localId = obj.id.localId;
2349
+ if (this._objects.get(localId) === obj)
2350
+ this._objects.delete(localId);
2086
2351
  }
2087
2352
  disconnectAll() {
2088
2353
  for (const obj of this._objects.values()) {
@@ -2106,7 +2371,10 @@ var RpcSharedObjectTracker = class {
2106
2371
  return this._nextId++;
2107
2372
  }
2108
2373
  register(obj) {
2109
- this._objects.set(obj.id.localId, obj);
2374
+ const localId = obj.id.localId;
2375
+ if (this._objects.has(localId))
2376
+ throw new Error(`RPC shared object with localId ${localId} is already registered.`);
2377
+ this._objects.set(localId, obj);
2110
2378
  }
2111
2379
  get(localId) {
2112
2380
  return this._objects.get(localId);
@@ -2115,7 +2383,9 @@ var RpcSharedObjectTracker = class {
2115
2383
  return this._objects.keys();
2116
2384
  }
2117
2385
  unregister(obj) {
2118
- this._objects.delete(obj.id.localId);
2386
+ const localId = obj.id.localId;
2387
+ if (this._objects.get(localId) === obj)
2388
+ this._objects.delete(localId);
2119
2389
  }
2120
2390
  disconnectAll() {
2121
2391
  for (const obj of this._objects.values()) {
@@ -2145,9 +2415,7 @@ var RpcSerializationFormat = class {
2145
2415
  static NewtonsoftJsonV5;
2146
2416
  static NewtonsoftJsonV5NP;
2147
2417
  static MessagePackV6;
2148
- static MemoryPackV6;
2149
2418
  static MessagePackV6C;
2150
- static MemoryPackV6C;
2151
2419
  static All;
2152
2420
  /** Convenience shortcut for RpcSerializationFormatResolver.Default.get(key). */
2153
2421
  static get(key) {
@@ -2243,18 +2511,14 @@ RpcSerializationFormat.SystemJsonV5NP = new RpcJsonSerializationFormat("json5np"
2243
2511
  RpcSerializationFormat.NewtonsoftJsonV5 = new RpcJsonSerializationFormat("njson5");
2244
2512
  RpcSerializationFormat.NewtonsoftJsonV5NP = new RpcJsonSerializationFormat("njson5np");
2245
2513
  RpcSerializationFormat.MessagePackV6 = new RpcMessagePackSerializationFormat("msgpack6");
2246
- RpcSerializationFormat.MemoryPackV6 = new RpcMessagePackSerializationFormat("mempack6");
2247
2514
  RpcSerializationFormat.MessagePackV6C = new RpcMessagePackCompactSerializationFormat("msgpack6c");
2248
- RpcSerializationFormat.MemoryPackV6C = new RpcMessagePackCompactSerializationFormat("mempack6c");
2249
2515
  RpcSerializationFormat.All = [
2250
2516
  RpcSerializationFormat.SystemJsonV5,
2251
2517
  RpcSerializationFormat.SystemJsonV5NP,
2252
2518
  RpcSerializationFormat.NewtonsoftJsonV5,
2253
2519
  RpcSerializationFormat.NewtonsoftJsonV5NP,
2254
2520
  RpcSerializationFormat.MessagePackV6,
2255
- RpcSerializationFormat.MemoryPackV6,
2256
- RpcSerializationFormat.MessagePackV6C,
2257
- RpcSerializationFormat.MemoryPackV6C
2521
+ RpcSerializationFormat.MessagePackV6C
2258
2522
  ];
2259
2523
  var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2260
2524
  static DefaultFormats = RpcSerializationFormat.All;
@@ -2274,6 +2538,8 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2274
2538
  this.formats = map;
2275
2539
  }
2276
2540
  get(key) {
2541
+ if (isMemoryPackKey(key))
2542
+ throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
2277
2543
  const format = this.tryGet(key);
2278
2544
  if (!format)
2279
2545
  throw new Error(`No format with key '${key}'.`);
@@ -2281,9 +2547,14 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2281
2547
  }
2282
2548
  tryGet(key) {
2283
2549
  if (!key) key = this.defaultFormatKey;
2550
+ if (isMemoryPackKey(key))
2551
+ throw new Error(`Serialization format '${key}': MemoryPack formats are not supported by the TS client.`);
2284
2552
  return this.formats.get(key);
2285
2553
  }
2286
2554
  };
2555
+ function isMemoryPackKey(key) {
2556
+ return key.startsWith("mempack");
2557
+ }
2287
2558
 
2288
2559
  // src/rpc-peer.ts
2289
2560
  function yieldToEventLoop() {
@@ -2337,6 +2608,10 @@ var RpcPeer = class {
2337
2608
  * attempt (the reconnect delay is part of this phase); `Connected` is
2338
2609
  * entered only after the handshake has succeeded. */
2339
2610
  connectionStateChanged = new import_core7.EventHandlerSet();
2611
+ /** Index of the handshake THIS peer most recently sent. Used to reject a
2612
+ * stale `$sys.Reconnect` that references an older connection generation
2613
+ * (C# `RpcSystemCalls.Reconnect`'s `ownHandshake.Index` check). */
2614
+ _ownHandshakeIndex = 0;
2340
2615
  _connection;
2341
2616
  /** Mirror of `_connectionState === Connected`. True only after the
2342
2617
  * handshake round-trip completes; outbound `call()` checks this to
@@ -2348,6 +2623,7 @@ var RpcPeer = class {
2348
2623
  _connectionState = 0 /* Disconnected */;
2349
2624
  _keepAliveTimer;
2350
2625
  _keepAliveWatchdog;
2626
+ _maintainTimer;
2351
2627
  /** Wall-clock time of the last inbound `$sys.KeepAlive`, or 0 if none yet
2352
2628
  * for the current connection. Useful for diagnostics. */
2353
2629
  _lastKeepAliveAt = 0;
@@ -2362,6 +2638,37 @@ var RpcPeer = class {
2362
2638
  this.ref = ref;
2363
2639
  this.keepAlivePeriodMs = hub.limits.keepAlivePeriodMs;
2364
2640
  this.keepAliveTimeoutMs = hub.limits.keepAliveTimeoutMs;
2641
+ this.inboundCalls.completedCallsLimit = hub.limits.completedInboundCallsLimit;
2642
+ const checkPeriod = hub.limits.callTimeoutCheckPeriodMs;
2643
+ if (checkPeriod > 0 && Number.isFinite(checkPeriod)) {
2644
+ this._maintainTimer = setInterval(() => this._maintainOutboundCalls(), checkPeriod);
2645
+ this._maintainTimer.unref?.();
2646
+ }
2647
+ }
2648
+ /** Fail outbound calls whose per-call {@link RpcCallTimeouts} elapsed —
2649
+ * the connect timeout while a call still awaits the wire, the run timeout
2650
+ * once it has been sent. Mirrors .NET `RpcOutboundCallTracker.Maintain`
2651
+ * (RpcCallTrackers.cs:108-207). Calls without timeouts (queries) never
2652
+ * time out here. */
2653
+ _maintainOutboundCalls() {
2654
+ const now = Date.now();
2655
+ const canRunTimeout = this._isConnected && (this._lastKeepAliveAt === 0 || now - this._lastKeepAliveAt <= this.keepAliveTimeoutMs);
2656
+ for (const call of [...this.outboundCalls.values()]) {
2657
+ const timeouts = call.timeouts;
2658
+ if (timeouts === void 0 || call.result.isCompleted)
2659
+ continue;
2660
+ let timedOut;
2661
+ if (call.sentAt === 0) {
2662
+ if (now - call.startedAt >= timeouts.connectTimeoutMs)
2663
+ timedOut = `connect timeout (${timeouts.connectTimeoutMs}ms)`;
2664
+ } else if (canRunTimeout && now - call.sentAt >= timeouts.runTimeoutMs)
2665
+ timedOut = `run timeout (${timeouts.runTimeoutMs}ms)`;
2666
+ if (timedOut === void 0)
2667
+ continue;
2668
+ this.outboundCalls.remove(call.callId);
2669
+ call.result.reject(new import_core7.TimeoutError(`'${this.ref}': call ${call.method}#${call.callId} timed out \u2014 ${timedOut}`));
2670
+ call.onDisconnect();
2671
+ }
2365
2672
  }
2366
2673
  get connection() {
2367
2674
  return this._connection;
@@ -2372,6 +2679,9 @@ var RpcPeer = class {
2372
2679
  get isConnected() {
2373
2680
  return this._isConnected;
2374
2681
  }
2682
+ get ownHandshakeIndex() {
2683
+ return this._ownHandshakeIndex;
2684
+ }
2375
2685
  _setConnectionState(value) {
2376
2686
  if (this._connectionState === value) return;
2377
2687
  const previousState = this._connectionState;
@@ -2443,14 +2753,17 @@ var RpcPeer = class {
2443
2753
  this._connection?.encoder,
2444
2754
  this.hub.registry
2445
2755
  );
2756
+ outboundCall.timeouts = options?.timeouts;
2446
2757
  this.outboundCalls.register(outboundCall);
2447
2758
  if (this._isConnected) {
2759
+ outboundCall.sentAt = Date.now();
2448
2760
  this._sendWireData(outboundCall.serializedWireData);
2449
2761
  } else {
2450
2762
  const stateHandler = (state) => {
2451
2763
  if (state !== 3 /* Connected */) return;
2452
2764
  this.connectionStateChanged.remove(stateHandler);
2453
2765
  if (outboundCall.result.isCompleted) return;
2766
+ outboundCall.sentAt = Date.now();
2454
2767
  this._sendWireData(outboundCall.serializedWireData);
2455
2768
  };
2456
2769
  this.connectionStateChanged.add(stateHandler);
@@ -2460,9 +2773,9 @@ var RpcPeer = class {
2460
2773
  if (signal !== void 0) {
2461
2774
  const onAbort = () => {
2462
2775
  if (this.outboundCalls.remove(callId) !== void 0) {
2463
- outboundCall.result.reject(new Error("Call cancelled."));
2776
+ outboundCall.result.reject((0, import_core7.cancellationError)("Call cancelled."));
2464
2777
  outboundCall.onDisconnect();
2465
- if (this._connection !== void 0)
2778
+ if (this._isConnected && this._connection !== void 0)
2466
2779
  this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
2467
2780
  }
2468
2781
  };
@@ -2492,10 +2805,15 @@ var RpcPeer = class {
2492
2805
  }
2493
2806
  close() {
2494
2807
  infoLog?.log(`'${this.ref}': Stopping`);
2808
+ if (this._maintainTimer !== void 0) {
2809
+ clearInterval(this._maintainTimer);
2810
+ this._maintainTimer = void 0;
2811
+ }
2495
2812
  this.remoteObjects.disconnectAll();
2496
2813
  this.sharedObjects.disconnectAll();
2497
- this.outboundCalls.rejectAll(new Error("Peer closed."));
2814
+ this.outboundCalls.rejectAll((0, import_core7.cancellationError)("Peer closed."));
2498
2815
  this.outboundCalls.invalidateAll();
2816
+ this.inboundCalls.clear();
2499
2817
  this._connection?.close();
2500
2818
  this.hub.peers.delete(this.ref);
2501
2819
  this._setConnectionState(0 /* Disconnected */);
@@ -2559,57 +2877,85 @@ var RpcPeer = class {
2559
2877
  _handleInbound(message, args) {
2560
2878
  const method = message.Method ?? "";
2561
2879
  const relatedId = message.RelatedId ?? 0;
2562
- const call = new RpcInboundCall(relatedId, method, args);
2563
2880
  const methodDef = this.hub.serviceHost.getMethodDef(method);
2564
2881
  const isNoWait = methodDef?.noWait === true;
2565
- if (!isNoWait) {
2566
- this.inboundCalls.register(call);
2567
- }
2568
2882
  const serviceHost = this.hub.serviceHost;
2569
- void (async () => {
2883
+ const dispatch = async (call2) => {
2570
2884
  try {
2571
2885
  const context = this._connection !== void 0 ? {
2572
2886
  __rpcDispatch: true,
2573
2887
  callId: relatedId,
2574
- connection: this._connection
2888
+ callType: message.CallType ?? 0,
2889
+ connection: this._connection,
2890
+ signal: call2?.signal,
2891
+ peer: this
2575
2892
  } : void 0;
2576
- const result = await serviceHost.dispatch(
2577
- method,
2578
- args,
2579
- context
2580
- );
2581
- if (methodDef?.stream === true && this._connection !== void 0) {
2582
- const stream = result instanceof RpcStream ? result : new RpcStream(result);
2583
- this.hub.systemCallSender.ok(
2584
- this._connection,
2585
- this.serializationFormat,
2586
- relatedId,
2587
- stream.toRef(this)
2588
- );
2589
- } else if (!isNoWait && this._connection !== void 0) {
2590
- this.hub.systemCallSender.ok(
2591
- this._connection,
2592
- this.serializationFormat,
2593
- relatedId,
2594
- result
2595
- );
2893
+ const result = await serviceHost.dispatch(method, args, context);
2894
+ if (methodDef?.stream === true) {
2895
+ if (this._connection !== void 0) {
2896
+ const stream = result instanceof RpcStream ? result : new RpcStream(result);
2897
+ const ref = stream.toRef(this);
2898
+ const send = () => {
2899
+ if (call2?.isCancelled === true) return;
2900
+ if (this._connection !== void 0)
2901
+ this.hub.systemCallSender.ok(
2902
+ this._connection,
2903
+ this.serializationFormat,
2904
+ relatedId,
2905
+ ref
2906
+ );
2907
+ };
2908
+ if (call2 !== void 0) {
2909
+ call2.setResult(send);
2910
+ this.inboundCalls.markCompleted(call2);
2911
+ }
2912
+ send();
2913
+ }
2914
+ } else if (call2 !== void 0) {
2915
+ const send = () => {
2916
+ if (call2.isCancelled) return;
2917
+ if (this._connection !== void 0)
2918
+ this.hub.systemCallSender.ok(
2919
+ this._connection,
2920
+ this.serializationFormat,
2921
+ relatedId,
2922
+ result
2923
+ );
2924
+ };
2925
+ call2.setResult(send);
2926
+ this.inboundCalls.markCompleted(call2);
2927
+ send();
2596
2928
  }
2597
2929
  } catch (e) {
2598
- if (!isNoWait && this._connection !== void 0) {
2599
- this.hub.systemCallSender.error(
2600
- this._connection,
2601
- this.serializationFormat,
2602
- relatedId,
2603
- e
2604
- );
2930
+ if (call2 !== void 0) {
2931
+ const send = () => {
2932
+ if (call2.isCancelled) return;
2933
+ if (this._connection !== void 0)
2934
+ this.hub.systemCallSender.error(
2935
+ this._connection,
2936
+ this.serializationFormat,
2937
+ relatedId,
2938
+ e
2939
+ );
2940
+ };
2941
+ call2.setResult(send);
2942
+ this.inboundCalls.markCompleted(call2);
2943
+ send();
2605
2944
  }
2606
2945
  debugLog2?.log(`'${this.ref}': Inbound call failed: ${method}#${relatedId}`, e);
2607
- } finally {
2608
- if (!isNoWait) {
2609
- this.inboundCalls.remove(relatedId);
2610
- }
2611
2946
  }
2612
- })();
2947
+ };
2948
+ if (isNoWait) {
2949
+ void dispatch(void 0);
2950
+ return;
2951
+ }
2952
+ const call = new RpcInboundCall(relatedId, method, args);
2953
+ const existing = this.inboundCalls.getOrRegister(call);
2954
+ if (existing !== call) {
2955
+ existing.resendResult();
2956
+ return;
2957
+ }
2958
+ void dispatch(call);
2613
2959
  }
2614
2960
  /** Start sending `$sys.KeepAlive` pings and arm the silence watchdog.
2615
2961
  * Called when the peer transitions into `Connected` (after handshake).
@@ -2665,15 +3011,17 @@ var RpcConnectionState = /* @__PURE__ */ ((RpcConnectionState2) => {
2665
3011
  })(RpcConnectionState || {});
2666
3012
  var RpcClientPeer = class extends RpcPeer {
2667
3013
  _disposed = false;
3014
+ /** Aborted when the peer is stopped/disposed — threaded into the reconnect
3015
+ * delay so a pending delay rejects promptly instead of parking. */
3016
+ _stopController = new AbortController();
2668
3017
  _tryIndex = 0;
2669
- _handshakeIndex = 0;
2670
3018
  /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
2671
3019
  * to the server in `$sys.Reconnect:3` so the server can verify we're
2672
3020
  * talking about the current connection. Mirrors .NET's
2673
3021
  * `connectionState.Handshake.Index` path in
2674
3022
  * `RpcOutboundCallTracker.TryReconnect`. */
2675
3023
  _remoteHandshakeIndex = 0;
2676
- _lastRemoteHubId;
3024
+ _lastRemotePeerId;
2677
3025
  _pendingHandshake;
2678
3026
  _reconnectsAt = 0;
2679
3027
  /** Base64url-encoded peer ID — matches .NET's RpcClientPeer.ClientId (Guid.ToBase64Url). */
@@ -2694,6 +3042,10 @@ var RpcClientPeer = class extends RpcPeer {
2694
3042
  * the connection attempt. Initialized from `hub.limits` at construction;
2695
3043
  * set directly to override on a single peer. */
2696
3044
  handshakeTimeoutMs;
3045
+ /** A connection that lived less than this before dropping does NOT reset
3046
+ * `_tryIndex`, so a crash-looping server sees growing reconnect delays.
3047
+ * Initialized from `hub.limits` at construction; override per peer. */
3048
+ prematureDisconnectTimeoutMs;
2697
3049
  peerChanged = new import_core7.EventHandlerSet();
2698
3050
  /** Fired when the server rejects the connection due to an unsupported serialization format (close code 4010). */
2699
3051
  unsupportedFormat = new import_core7.EventHandlerSet();
@@ -2726,6 +3078,7 @@ var RpcClientPeer = class extends RpcPeer {
2726
3078
  super(hub, url);
2727
3079
  this.connectTimeoutMs = hub.limits.connectTimeoutMs;
2728
3080
  this.handshakeTimeoutMs = hub.limits.handshakeTimeoutMs;
3081
+ this.prematureDisconnectTimeoutMs = hub.limits.prematureDisconnectTimeoutMs;
2729
3082
  this.clientId = guidToBase64Url(this.id);
2730
3083
  const resolver = RpcSerializationFormatResolver.Default;
2731
3084
  this.serializationFormat = resolver.get(parseFormatFromUrl(url) ?? resolver.defaultFormatKey);
@@ -2762,7 +3115,7 @@ var RpcClientPeer = class extends RpcPeer {
2762
3115
  try {
2763
3116
  while (!this._disposed) {
2764
3117
  this._setConnectionState(1 /* Connecting */);
2765
- const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex);
3118
+ const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex, this._stopController.signal);
2766
3119
  if (retryDelay.isLimitExceeded) {
2767
3120
  warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2768
3121
  this._disposed = true;
@@ -2771,12 +3124,14 @@ var RpcClientPeer = class extends RpcPeer {
2771
3124
  this._setReconnectsAt(retryDelay.endsAt);
2772
3125
  try {
2773
3126
  await retryDelay.promise;
3127
+ } catch {
2774
3128
  } finally {
2775
3129
  this._setReconnectsAt(0);
2776
3130
  }
2777
3131
  if (this._disposed) break;
2778
3132
  let lastCloseCode = 0;
2779
3133
  let lastCloseReason = "";
3134
+ let connectedAt = 0;
2780
3135
  try {
2781
3136
  const connUrl = await this.connectionUrlResolver(this);
2782
3137
  infoLog?.log(`'${this.ref}': Connecting to ${sanitizeUrl(connUrl)}`);
@@ -2823,7 +3178,7 @@ var RpcClientPeer = class extends RpcPeer {
2823
3178
  this.serializationFormat,
2824
3179
  this.id,
2825
3180
  this.hub.hubId,
2826
- ++this._handshakeIndex
3181
+ ++this._ownHandshakeIndex
2827
3182
  );
2828
3183
  } catch (e) {
2829
3184
  warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
@@ -2847,20 +3202,10 @@ var RpcClientPeer = class extends RpcPeer {
2847
3202
  this._pendingHandshake = void 0;
2848
3203
  }
2849
3204
  this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2850
- const remoteHubId = remoteHandshake.RemoteHubId;
2851
- let isPeerChanged = false;
2852
- if (remoteHubId !== void 0) {
2853
- isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2854
- if (isPeerChanged) {
2855
- this.inboundCalls.clear();
2856
- infoLog?.log(`'${this.ref}': Peer changed`);
2857
- this.peerChanged.trigger();
2858
- }
2859
- this._lastRemoteHubId = remoteHubId;
2860
- }
3205
+ const isPeerChanged = this._detectPeerChange(remoteHandshake);
2861
3206
  infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2862
3207
  this._setConnectionState(3 /* Connected */);
2863
- this._tryIndex = 0;
3208
+ connectedAt = Date.now();
2864
3209
  await this._reconnect(isPeerChanged, closedController.signal);
2865
3210
  await whenClosed;
2866
3211
  } catch (e) {
@@ -2874,6 +3219,8 @@ var RpcClientPeer = class extends RpcPeer {
2874
3219
  this._disposed = true;
2875
3220
  break;
2876
3221
  }
3222
+ if (connectedAt !== 0 && Date.now() - connectedAt >= this.prematureDisconnectTimeoutMs)
3223
+ this._tryIndex = 0;
2877
3224
  this._tryIndex++;
2878
3225
  }
2879
3226
  } finally {
@@ -2929,6 +3276,8 @@ var RpcClientPeer = class extends RpcPeer {
2929
3276
  continue;
2930
3277
  }
2931
3278
  eligible.push(call);
3279
+ if (call.sentAt !== 0)
3280
+ call.sentAt = Date.now();
2932
3281
  }
2933
3282
  let unknownIds = null;
2934
3283
  if (!isPeerChanged && eligible.length > 0) {
@@ -2945,6 +3294,7 @@ var RpcClientPeer = class extends RpcPeer {
2945
3294
  if (unknownIds !== null && !unknownIds.has(call.callId)) {
2946
3295
  continue;
2947
3296
  }
3297
+ call.sentAt = Date.now();
2948
3298
  this._sendWireData(call.serializedWireData);
2949
3299
  resentSinceYield++;
2950
3300
  if (resentSinceYield >= YIELD_BATCH) {
@@ -3010,30 +3360,80 @@ var RpcClientPeer = class extends RpcPeer {
3010
3360
  return new Set(eligible.map((c) => c.callId));
3011
3361
  }
3012
3362
  }
3363
+ /** Detects a server-peer identity change by comparing the incoming
3364
+ * handshake's `RemotePeerId` with the last one — C#
3365
+ * `RpcHandshake.GetPeerChangeKind` keys off `RemotePeerId`, not
3366
+ * `RemoteHubId` (an idle server peer can be replaced while keeping the
3367
+ * same hub id). On a change it clears the inbound tracker and fires
3368
+ * `peerChanged`; the very first handshake is never a change. */
3369
+ _detectPeerChange(handshake) {
3370
+ const remotePeerId = handshake.RemotePeerId;
3371
+ if (remotePeerId === void 0)
3372
+ return false;
3373
+ const isPeerChanged = this._lastRemotePeerId !== void 0 && this._lastRemotePeerId !== remotePeerId;
3374
+ this._lastRemotePeerId = remotePeerId;
3375
+ if (isPeerChanged) {
3376
+ this.inboundCalls.clear();
3377
+ infoLog?.log(`'${this.ref}': Peer changed`);
3378
+ this.peerChanged.trigger();
3379
+ }
3380
+ return isPeerChanged;
3381
+ }
3013
3382
  _onHandshakeReceived(handshake) {
3014
3383
  this._pendingHandshake?.resolve(handshake);
3015
3384
  }
3016
3385
  close() {
3017
3386
  this._disposed = true;
3387
+ this._stopController.abort();
3018
3388
  super.close();
3019
3389
  }
3020
3390
  };
3021
3391
  var RpcServerPeer = class extends RpcPeer {
3022
- // Needed to widen the base `protected constructor` to public.
3023
- // eslint-disable-next-line @typescript-eslint/no-useless-constructor
3392
+ /** Grace period before an idle (disconnected) server peer is stopped and
3393
+ * removed from `hub.peers`; a re-`accept()` within it cancels removal.
3394
+ * Snapshotted from `hub.limits` at construction (F11). */
3395
+ serverPeerCloseTimeoutMs;
3396
+ _closeTimer;
3397
+ _isClosed = false;
3024
3398
  constructor(hub, ref) {
3025
3399
  super(hub, ref);
3400
+ this.serverPeerCloseTimeoutMs = hub.limits.serverPeerCloseTimeoutMs;
3026
3401
  }
3027
3402
  /** Accept an incoming connection — sets up message handling and handshake response. */
3028
3403
  accept(conn) {
3404
+ this._clearCloseTimer();
3029
3405
  this._setConnectionState(1 /* Connecting */);
3030
3406
  this.setupConnection(conn);
3031
3407
  this._setConnectionState(2 /* Handshaking */);
3032
3408
  conn.closed.add(() => {
3033
3409
  if (this._connection !== conn && this._connection !== void 0) return;
3034
3410
  this.sharedObjects.disconnectAll();
3411
+ this._armCloseTimer();
3035
3412
  });
3036
3413
  }
3414
+ close() {
3415
+ this._isClosed = true;
3416
+ this._clearCloseTimer();
3417
+ super.close();
3418
+ }
3419
+ _armCloseTimer() {
3420
+ this._clearCloseTimer();
3421
+ if (this._isClosed) return;
3422
+ const timeout = this.serverPeerCloseTimeoutMs;
3423
+ if (!(timeout > 0) || !Number.isFinite(timeout)) return;
3424
+ this._closeTimer = setTimeout(() => {
3425
+ this._closeTimer = void 0;
3426
+ if (this._connection === void 0)
3427
+ this.close();
3428
+ }, timeout);
3429
+ this._closeTimer.unref?.();
3430
+ }
3431
+ _clearCloseTimer() {
3432
+ if (this._closeTimer !== void 0) {
3433
+ clearTimeout(this._closeTimer);
3434
+ this._closeTimer = void 0;
3435
+ }
3436
+ }
3037
3437
  _onHandshakeReceived(_handshake) {
3038
3438
  if (this._connection !== void 0) {
3039
3439
  this.hub.systemCallSender.handshake(
@@ -3041,7 +3441,7 @@ var RpcServerPeer = class extends RpcPeer {
3041
3441
  this.serializationFormat,
3042
3442
  this.id,
3043
3443
  this.hub.hubId,
3044
- 0
3444
+ ++this._ownHandshakeIndex
3045
3445
  );
3046
3446
  this._setConnectionState(3 /* Connected */);
3047
3447
  }
@@ -3080,6 +3480,12 @@ var RpcLimits = class _RpcLimits {
3080
3480
  /** Process-wide default. Hubs read from this when they're constructed
3081
3481
  * unless caller assigns a custom instance to `hub.limits`. */
3082
3482
  static Default = new _RpcLimits();
3483
+ /** Dev preset with a relaxed keep-alive timeout (5 min), matching .NET's
3484
+ * debugger-attached `KeepAlivePeriod` (RpcLimits.cs:46-54). Activate
3485
+ * explicitly — e.g. `RpcLimits.Default = RpcLimits.Debug` or per-hub
3486
+ * `hub.limits = RpcLimits.Debug`. Never switched on automatically: the
3487
+ * TS side can't detect a debugger on the server (decision D4). */
3488
+ static Debug = new _RpcLimits({ keepAliveTimeoutMs: 3e5 });
3083
3489
  /** Max time to wait for the WebSocket to enter the OPEN state. On a
3084
3490
  * hung connect (mobile after network change, half-open after sleep)
3085
3491
  * the browser can take ~2 min to emit `onerror`/`onclose`; without
@@ -3087,6 +3493,22 @@ var RpcLimits = class _RpcLimits {
3087
3493
  connectTimeoutMs = 1e4;
3088
3494
  /** Max time to wait for the server's handshake response after WS opens. */
3089
3495
  handshakeTimeoutMs = 1e4;
3496
+ /** If a connection lived less than this before dropping, a graceful close
3497
+ * still counts as a failed attempt — the client keeps its growing
3498
+ * `_tryIndex` instead of resetting it, so a crash-looping server sees
3499
+ * increasing reconnect delays via `RetryDelaySeq`. Mirrors .NET
3500
+ * `RpcLimits.PrematureDisconnectTimeout` (RpcLimits.cs:18). */
3501
+ prematureDisconnectTimeoutMs = 15e3;
3502
+ /** Max completed inbound calls retained for duplicate-frame dedup
3503
+ * (a resent call id re-sends the computed result instead of
3504
+ * re-executing the handler). Deviation from .NET, which unregisters a
3505
+ * call once its result is sent: TS clients blind-resend on reconnect,
3506
+ * so completed calls are retained — bounded by this limit (oldest
3507
+ * evicted first) to cap memory on long-lived connections. */
3508
+ completedInboundCallsLimit = 1e3;
3509
+ /** How often each peer's maintenance loop scans outbound calls for
3510
+ * timeouts (R12). Mirrors .NET `RpcLimits.CallTimeoutCheckPeriod`. */
3511
+ callTimeoutCheckPeriodMs = 1e3;
3090
3512
  /** Outbound `$sys.KeepAlive` send period. */
3091
3513
  keepAlivePeriodMs = 1e4;
3092
3514
  /** Reaper threshold — connection is force-closed (and the reconnect
@@ -3096,6 +3518,13 @@ var RpcLimits = class _RpcLimits {
3096
3518
  * `keepAlivePeriodMs + stall_duration`, so with these defaults the
3097
3519
  * tolerated stall is ~15 s. */
3098
3520
  keepAliveTimeoutMs = 25e3;
3521
+ /** How long a server peer stays in `hub.peers` after its connection closes
3522
+ * before it's stopped and removed. A same-peer reconnect (`accept()`)
3523
+ * within this window cancels the pending removal, so brief drops don't
3524
+ * discard the peer's trackers. Mirrors .NET's server-peer shutdown timeout
3525
+ * (`RpcPeerOptions.ServerPeerShutdownTimeoutProvider`, 3-15 min); the TS
3526
+ * port uses a fixed floor-matching default. */
3527
+ serverPeerCloseTimeoutMs = 18e4;
3099
3528
  constructor(overrides) {
3100
3529
  if (overrides) Object.assign(this, overrides);
3101
3530
  }
@@ -3106,7 +3535,7 @@ var import_core8 = require("@actuallab/core");
3106
3535
  var RpcClientPeerReconnectDelayer = class extends import_core8.RetryDelayer {
3107
3536
  constructor() {
3108
3537
  super();
3109
- this.delays = import_core8.RetryDelaySeq.fixed(100);
3538
+ this.delays = import_core8.RetryDelaySeq.exp(100, 1e4);
3110
3539
  }
3111
3540
  };
3112
3541
 
@@ -3114,13 +3543,14 @@ var RpcClientPeerReconnectDelayer = class extends import_core8.RetryDelayer {
3114
3543
  var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
3115
3544
  var RpcServiceHost = class {
3116
3545
  _methods = /* @__PURE__ */ new Map();
3117
- register(def, impl) {
3546
+ register(def, impl, receiver = impl) {
3118
3547
  for (const methodDef of def.methods.values()) {
3119
3548
  const fn = impl[methodDef.name];
3120
3549
  if (!fn) continue;
3121
3550
  this._methods.set(wireMethodName(methodDef), {
3122
3551
  def: methodDef,
3123
- fn
3552
+ fn,
3553
+ receiver
3124
3554
  });
3125
3555
  }
3126
3556
  }
@@ -3131,9 +3561,9 @@ var RpcServiceHost = class {
3131
3561
  throw new Error(`Method not found: ${wireMethod}`);
3132
3562
  }
3133
3563
  if (context !== void 0 && entry.def.callTypeId !== 0) {
3134
- return await entry.fn(...args, context);
3564
+ return await entry.fn.call(entry.receiver, ...args, context);
3135
3565
  }
3136
- return await entry.fn(...args);
3566
+ return await entry.fn.call(entry.receiver, ...args);
3137
3567
  }
3138
3568
  getMethodDef(wireMethod) {
3139
3569
  return this._methods.get(wireMethod)?.def;
@@ -3141,6 +3571,7 @@ var RpcServiceHost = class {
3141
3571
  };
3142
3572
 
3143
3573
  // src/rpc-decorators.ts
3574
+ var import_core9 = require("@actuallab/core");
3144
3575
  var METHODS_META = /* @__PURE__ */ Symbol.for("actuallab.methods");
3145
3576
  var SERVICE_META = /* @__PURE__ */ Symbol.for("actuallab.service");
3146
3577
  function getMethodsMeta(cls) {
@@ -3151,17 +3582,17 @@ function getServiceMeta(cls) {
3151
3582
  }
3152
3583
  function rpcService(serviceName) {
3153
3584
  return function(_target, context) {
3154
- const meta = context.metadata[SERVICE_META] ??= {};
3585
+ const meta = (0, import_core9.ownMetadata)(context.metadata, SERVICE_META);
3155
3586
  meta.name = serviceName;
3156
3587
  };
3157
3588
  }
3158
3589
  function rpcMethod(options) {
3159
3590
  return function(target, context) {
3160
3591
  const methodName = String(context.name);
3161
- const methods = context.metadata[METHODS_META] ??= {};
3592
+ const methods = (0, import_core9.ownMetadata)(context.metadata, METHODS_META);
3162
3593
  methods[methodName] = {
3163
3594
  ...methods[methodName],
3164
- argCount: target.length,
3595
+ argCount: (0, import_core9.resolveArgCount)(target, options?.argCount, methodName),
3165
3596
  returns: options?.returns,
3166
3597
  remoteExecutionMode: options?.remoteExecutionMode
3167
3598
  };
@@ -3707,7 +4138,7 @@ var RpcHub = class {
3707
4138
  hubId;
3708
4139
  peers = /* @__PURE__ */ new Map();
3709
4140
  serviceHost;
3710
- systemCallSender = new RpcSystemCallSender();
4141
+ systemCallSender;
3711
4142
  systemCallHandler = new RpcSystemCallHandler();
3712
4143
  /** Method registry for compact format hash ↔ name resolution.
3713
4144
  * Created lazily, populated by addService/addClient. */
@@ -3715,6 +4146,14 @@ var RpcHub = class {
3715
4146
  /** Shared reconnect delayer used by every client peer in this hub. Swap in
3716
4147
  * a custom subclass (e.g. app-level signal-gated) before peers start. */
3717
4148
  reconnectDelayer = new RpcClientPeerReconnectDelayer();
4149
+ /** Cache of client proxies keyed by peer, then by service name — so
4150
+ * repeated {@link addClient} calls for the same service+peer return one
4151
+ * proxy. All consumers then share a single computed/call/invalidation
4152
+ * stream per logical value, matching .NET's singleton client proxies (F8).
4153
+ * A later def registered under the same name extends the existing proxy
4154
+ * with its extra methods (superset), so no consumer ever receives a proxy
4155
+ * missing methods the def declares. */
4156
+ _clientProxies = /* @__PURE__ */ new WeakMap();
3718
4157
  /** Connection-lifecycle timing limits. Peers constructed against this hub
3719
4158
  * read their initial `*Ms` field values from this instance; later
3720
4159
  * mutations affect only peers built afterward (existing peers can be
@@ -3732,6 +4171,7 @@ var RpcHub = class {
3732
4171
  constructor(hubId) {
3733
4172
  this.hubId = hubId ?? crypto.randomUUID();
3734
4173
  this.serviceHost = new RpcServiceHost();
4174
+ this.systemCallSender = this._createSystemCallSender();
3735
4175
  this.systemCallSender.registry = this.registry;
3736
4176
  for (const methodName of Object.values(RpcSystemCalls)) {
3737
4177
  this.registry.register(methodName);
@@ -3792,50 +4232,38 @@ var RpcHub = class {
3792
4232
  if (!fn) continue;
3793
4233
  wrappedImpl[methodDef.name] = methodDef.callTypeId !== 0 ? this._wrapServerMethod(methodDef, fn, impl) : fn;
3794
4234
  }
3795
- this.serviceHost.register(def, wrappedImpl);
4235
+ this.serviceHost.register(def, wrappedImpl, impl);
3796
4236
  this.registry.registerService(def.name, def.methods);
3797
4237
  }
3798
- /** Create a typed client proxy for a service on a remote peer. */
4238
+ /** Create a typed client proxy for a service on a remote peer. Repeated calls
4239
+ * for the same (peer, service name) return one shared proxy; a def carrying
4240
+ * extra methods extends it (F8). */
3799
4241
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used for caller-specified proxy type
3800
4242
  addClient(peer, defOrContract) {
3801
4243
  const def = this._resolveServiceDef(defOrContract);
3802
4244
  this.registry.registerService(def.name, def.methods);
3803
- const overloads = /* @__PURE__ */ new Map();
3804
- for (const methodDef of def.methods.values()) {
3805
- let byArgCount = overloads.get(methodDef.name);
3806
- if (!byArgCount) {
3807
- byArgCount = /* @__PURE__ */ new Map();
3808
- overloads.set(methodDef.name, byArgCount);
3809
- }
3810
- byArgCount.set(
3811
- methodDef.argCount,
3812
- this._createClientMethod(peer, methodDef)
3813
- );
4245
+ let byService = this._clientProxies.get(peer);
4246
+ if (byService === void 0) {
4247
+ byService = /* @__PURE__ */ new Map();
4248
+ this._clientProxies.set(peer, byService);
3814
4249
  }
3815
- const methods = /* @__PURE__ */ new Map();
3816
- for (const [name, byArgCount] of overloads) {
3817
- if (byArgCount.size === 1) {
3818
- const [[, singleFn]] = byArgCount;
3819
- methods.set(name, singleFn);
3820
- } else {
3821
- methods.set(name, (...args) => {
3822
- const fn = byArgCount.get(args.length);
3823
- if (!fn)
3824
- throw new Error(
3825
- `No overload of ${name} accepts ${args.length} arguments`
3826
- );
3827
- return fn(...args);
3828
- });
3829
- }
4250
+ let entry = byService.get(def.name);
4251
+ if (entry === void 0) {
4252
+ entry = this._createClientProxy();
4253
+ byService.set(def.name, entry);
3830
4254
  }
3831
- return new Proxy({}, {
3832
- get: (_target, prop) => typeof prop === "string" ? methods.get(prop) : void 0
3833
- });
4255
+ this._extendClientProxy(peer, entry, def);
4256
+ return entry.proxy;
3834
4257
  }
3835
4258
  close() {
3836
4259
  for (const peer of this.peers.values()) peer.close();
3837
4260
  this.peers.clear();
3838
4261
  }
4262
+ /** Create the hub's system-call sender. Override in FusionHub to add
4263
+ * Fusion-specific system calls (e.g. $sys-c.Invalidate). */
4264
+ _createSystemCallSender() {
4265
+ return new RpcSystemCallSender();
4266
+ }
3839
4267
  /** Build an RpcServiceDef from decorator metadata on a contract class. Override in FusionHub for compute support. */
3840
4268
  _buildServiceDef(cls) {
3841
4269
  const svcMeta = getServiceMeta(cls);
@@ -3893,12 +4321,10 @@ var RpcHub = class {
3893
4321
  throw new Error(
3894
4322
  `Expected stream reference, got: ${JSON.stringify(result)}`
3895
4323
  );
3896
- const stream = new RpcStream(ref, peer);
3897
- peer.remoteObjects.register(stream);
3898
- return stream;
4324
+ return new RpcStream(ref, peer);
3899
4325
  };
3900
4326
  }
3901
- const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
4327
+ const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default || methodDef.timeouts !== void 0 ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode, timeouts: methodDef.timeouts } : void 0;
3902
4328
  return async (...args) => {
3903
4329
  if (mustCheckConnection && !peer.isConnected)
3904
4330
  throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
@@ -3908,7 +4334,58 @@ var RpcHub = class {
3908
4334
  return resolveStreamRefs(result, peer);
3909
4335
  };
3910
4336
  }
4337
+ // Private methods
4338
+ _createClientProxy() {
4339
+ const methods = /* @__PURE__ */ new Map();
4340
+ const proxy = new Proxy({}, {
4341
+ get: (_target, prop) => typeof prop === "string" ? methods.get(prop) : void 0
4342
+ });
4343
+ return { proxy, methods, overloads: /* @__PURE__ */ new Map() };
4344
+ }
4345
+ _extendClientProxy(peer, entry, def) {
4346
+ for (const methodDef of def.methods.values()) {
4347
+ const existing = entry.overloads.get(methodDef.name)?.get(methodDef.argCount);
4348
+ if (existing !== void 0 && !isSameWireMethod(existing.def, methodDef))
4349
+ throw new Error(
4350
+ `RpcHub.addClient: conflicting definition of '${wireMethodName(methodDef)}' under service '${def.name}' \u2014 a different signature is already registered.`
4351
+ );
4352
+ }
4353
+ const changed = /* @__PURE__ */ new Set();
4354
+ for (const methodDef of def.methods.values()) {
4355
+ let byArgCount = entry.overloads.get(methodDef.name);
4356
+ if (byArgCount === void 0) {
4357
+ byArgCount = /* @__PURE__ */ new Map();
4358
+ entry.overloads.set(methodDef.name, byArgCount);
4359
+ }
4360
+ if (byArgCount.has(methodDef.argCount))
4361
+ continue;
4362
+ byArgCount.set(methodDef.argCount, {
4363
+ def: methodDef,
4364
+ fn: this._createClientMethod(peer, methodDef)
4365
+ });
4366
+ changed.add(methodDef.name);
4367
+ }
4368
+ for (const name of changed) {
4369
+ const byArgCount = entry.overloads.get(name);
4370
+ entry.methods.set(name, buildOverloadFn(name, byArgCount));
4371
+ }
4372
+ }
3911
4373
  };
4374
+ function buildOverloadFn(name, byArgCount) {
4375
+ if (byArgCount.size === 1) {
4376
+ const [[, only]] = byArgCount;
4377
+ return only.fn;
4378
+ }
4379
+ return (...args) => {
4380
+ const overload = byArgCount.get(args.length);
4381
+ if (overload === void 0)
4382
+ throw new Error(`No overload of ${name} accepts ${args.length} arguments`);
4383
+ return overload.fn(...args);
4384
+ };
4385
+ }
4386
+ function isSameWireMethod(a, b) {
4387
+ return a.wireArgCount === b.wireArgCount && a.callTypeId === b.callTypeId && a.stream === b.stream && a.noWait === b.noWait && a.remoteExecutionMode === b.remoteExecutionMode;
4388
+ }
3912
4389
 
3913
4390
  // src/rpc-peer-ref-builder.ts
3914
4391
  var RpcPeerRefBuilder = class {
@@ -3975,7 +4452,7 @@ function createMethod(peer, methodDef) {
3975
4452
  throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
3976
4453
  const callArgs = args.slice(0, methodDef.argCount);
3977
4454
  const wireName = wireMethodName(methodDef);
3978
- const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode } : void 0;
4455
+ const options = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default || methodDef.timeouts !== void 0 ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode, timeouts: methodDef.timeouts } : void 0;
3979
4456
  const outboundCall = peer.call(wireName, callArgs, options);
3980
4457
  return outboundCall.result.promise;
3981
4458
  };
@@ -4015,14 +4492,14 @@ function getStateDescription(state, useLastError = false) {
4015
4492
  }
4016
4493
 
4017
4494
  // src/rpc-peer-state-monitor.ts
4018
- var import_core9 = require("@actuallab/core");
4495
+ var import_core10 = require("@actuallab/core");
4019
4496
  var { infoLog: infoLog2 } = getLogs("RpcPeerStateMonitor");
4020
4497
  var JustConnectedPeriodMs = 1500;
4021
4498
  var JustDisconnectedPeriodMs = 3e3;
4022
4499
  var MinReconnectsInMs = 1e3;
4023
4500
  var RpcPeerStateMonitor = class {
4024
4501
  peer;
4025
- stateChanged = new import_core9.EventHandlerSet();
4502
+ stateChanged = new import_core10.EventHandlerSet();
4026
4503
  _rawConnected;
4027
4504
  _connectedAt = 0;
4028
4505
  _disconnectedAt = 0;
@@ -4171,8 +4648,10 @@ var RpcPeerStateMonitor = class {
4171
4648
  ENVELOPE_DELIMITER,
4172
4649
  FRAME_DELIMITER,
4173
4650
  IncreasingSeqCompressor,
4651
+ REMOTE_EXCEPTION_TYPE_REF,
4174
4652
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
4175
4653
  RpcCallStage,
4654
+ RpcCallTimeouts,
4176
4655
  RpcCallTypeId,
4177
4656
  RpcClientPeer,
4178
4657
  RpcClientPeerReconnectDelayer,
@@ -4227,6 +4706,7 @@ var RpcPeerStateMonitor = class {
4227
4706
  isConnected,
4228
4707
  likelyConnected,
4229
4708
  parseStreamRef,
4709
+ readPolymorphismMarker,
4230
4710
  resolveStreamRefs,
4231
4711
  rpcMethod,
4232
4712
  rpcService,
@@ -4238,6 +4718,7 @@ var RpcPeerStateMonitor = class {
4238
4718
  splitBinaryFrame,
4239
4719
  splitCompactBinaryFrame,
4240
4720
  splitFrame,
4721
+ toExceptionInfo,
4241
4722
  wireMethodName,
4242
4723
  xxh3_64,
4243
4724
  xxh3_64str