@actuallab/rpc 14.4.3 → 14.4.11

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
@@ -1079,13 +1079,17 @@ var RpcSystemCallHandler = class {
1079
1079
  case RpcSystemCalls.keepAlive: {
1080
1080
  peer.notifyKeepAliveReceived();
1081
1081
  const keepAliveIds = args[0];
1082
- if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1082
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.wireConnection !== void 0) {
1083
1083
  const unknownIds = [];
1084
1084
  for (const id of keepAliveIds)
1085
1085
  if (peer.sharedObjects.get(id) === void 0)
1086
1086
  unknownIds.push(id);
1087
1087
  if (unknownIds.length > 0)
1088
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1088
+ peer.hub.systemCallSender.disconnect(
1089
+ peer.wireConnection,
1090
+ peer.serializationFormat,
1091
+ unknownIds
1092
+ );
1089
1093
  }
1090
1094
  break;
1091
1095
  }
@@ -1131,16 +1135,24 @@ var RpcSystemCallHandler = class {
1131
1135
  const sender = peer.sharedObjects.get(relatedId);
1132
1136
  if (sender)
1133
1137
  sender.onAck(args[0], args[1]);
1134
- else if (peer.connection !== void 0)
1135
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1138
+ else if (peer.wireConnection !== void 0)
1139
+ peer.hub.systemCallSender.disconnect(
1140
+ peer.wireConnection,
1141
+ peer.serializationFormat,
1142
+ [relatedId]
1143
+ );
1136
1144
  break;
1137
1145
  }
1138
1146
  case RpcSystemCalls.ackEnd: {
1139
1147
  const sender = peer.sharedObjects.get(relatedId);
1140
1148
  if (sender)
1141
1149
  sender.onAckEnd(args[0]);
1142
- else if (peer.connection !== void 0)
1143
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1150
+ else if (peer.wireConnection !== void 0)
1151
+ peer.hub.systemCallSender.disconnect(
1152
+ peer.wireConnection,
1153
+ peer.serializationFormat,
1154
+ [relatedId]
1155
+ );
1144
1156
  break;
1145
1157
  }
1146
1158
  case RpcSystemCalls.disconnect: {
@@ -1169,12 +1181,13 @@ var RpcSystemCallHandler = class {
1169
1181
  * with the same `RelatedId`.
1170
1182
  */
1171
1183
  _handleReconnect(relatedId, args, peer) {
1172
- if (peer.connection === void 0) return;
1184
+ const conn = peer.wireConnection;
1185
+ if (conn === void 0) return;
1173
1186
  const handshakeIndex = args[0];
1174
1187
  if (typeof handshakeIndex !== "number" || handshakeIndex !== peer.ownHandshakeIndex) {
1175
1188
  const got = typeof handshakeIndex === "number" ? handshakeIndex : `<${typeof handshakeIndex}>`;
1176
1189
  peer.hub.systemCallSender.error(
1177
- peer.connection,
1190
+ conn,
1178
1191
  peer.serializationFormat,
1179
1192
  relatedId,
1180
1193
  new Error(
@@ -1185,7 +1198,7 @@ var RpcSystemCallHandler = class {
1185
1198
  }
1186
1199
  if (!peer.tryClaimReconnect()) {
1187
1200
  peer.hub.systemCallSender.error(
1188
- peer.connection,
1201
+ conn,
1189
1202
  peer.serializationFormat,
1190
1203
  relatedId,
1191
1204
  new Error("TooLateToReconnect: this connection was already reconnected")
@@ -1216,7 +1229,7 @@ var RpcSystemCallHandler = class {
1216
1229
  }
1217
1230
  const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1218
1231
  const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1219
- peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1232
+ peer.hub.systemCallSender.ok(conn, peer.serializationFormat, relatedId, responseValue);
1220
1233
  }
1221
1234
  };
1222
1235
  function polymorphicPayloadError(method, got, expected) {
@@ -1399,6 +1412,8 @@ import { PromiseSource as PromiseSource4 } from "@actuallab/core";
1399
1412
  // src/rpc-stream-sender.ts
1400
1413
  import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
1401
1414
  var { warnLog: warnLog5 } = getLogs("RpcSharedStream");
1415
+ function _ignore() {
1416
+ }
1402
1417
  var DEFAULT_ACK_PERIOD = 256;
1403
1418
  var DEFAULT_ACK_ADVANCE = 128;
1404
1419
  var RTT_WINDOW_MS = 2e4;
@@ -1438,7 +1453,11 @@ var RpcStreamSender = class _RpcStreamSender {
1438
1453
  // -- Pump state --
1439
1454
  /** Next stream index that will be sent (matches .NET's `index`). */
1440
1455
  _nextIndex = 0;
1441
- _ended = false;
1456
+ /** Torn down — `disconnect()` ran. .NET's counterpart is disposal; note it
1457
+ * does NOT mean "an End was sent", which .NET does not track at all. */
1458
+ _isDisposed = false;
1459
+ /** The pump has returned. .NET's `WhenRunning.IsCompleted`. */
1460
+ _isCompleted = false;
1442
1461
  _started = new PromiseSource3();
1443
1462
  /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
1444
1463
  _lastAckedIndex = 0;
@@ -1545,7 +1564,7 @@ var RpcStreamSender = class _RpcStreamSender {
1545
1564
  this._sendDisconnect();
1546
1565
  return;
1547
1566
  }
1548
- } else if (this._ended) {
1567
+ } else if (this._isCompleted) {
1549
1568
  this._sendDisconnect();
1550
1569
  return;
1551
1570
  } else if (mustReset && !this.allowReconnect) {
@@ -1566,7 +1585,7 @@ var RpcStreamSender = class _RpcStreamSender {
1566
1585
  // Tells the consumer this shared stream is gone, then disposes it —
1567
1586
  // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1568
1587
  _sendDisconnect() {
1569
- const conn = this.peer.connection;
1588
+ const conn = this.peer.wireConnection;
1570
1589
  if (conn)
1571
1590
  this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1572
1591
  this.disconnect();
@@ -1580,13 +1599,13 @@ var RpcStreamSender = class _RpcStreamSender {
1580
1599
  *
1581
1600
  * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1582
1601
  * the item is considered "sent" from the pump's perspective so the replay
1583
- * buffer indexing stays consistent. If a connection is absent, the wire
1602
+ * buffer indexing stays consistent. If the peer isn't connected, the wire
1584
1603
  * message is dropped; the item will be resent from the replay buffer
1585
1604
  * after a reconnect (client sends `Ack(N, mustReset=true)`).
1586
1605
  */
1587
1606
  sendItem(item) {
1588
- if (this._ended) return;
1589
- const conn = this.peer.connection;
1607
+ if (this._isDisposed) return;
1608
+ const conn = this.peer.wireConnection;
1590
1609
  if (conn) {
1591
1610
  this.peer.hub.systemCallSender.item(
1592
1611
  conn,
@@ -1603,8 +1622,8 @@ var RpcStreamSender = class _RpcStreamSender {
1603
1622
  }
1604
1623
  /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
1605
1624
  sendBatch(items) {
1606
- if (this._ended || items.length === 0) return;
1607
- const conn = this.peer.connection;
1625
+ if (this._isDisposed || items.length === 0) return;
1626
+ const conn = this.peer.wireConnection;
1608
1627
  if (conn) {
1609
1628
  this.peer.hub.systemCallSender.batch(
1610
1629
  conn,
@@ -1616,18 +1635,21 @@ var RpcStreamSender = class _RpcStreamSender {
1616
1635
  }
1617
1636
  this._nextIndex += items.length;
1618
1637
  }
1619
- /** Signal stream completion to the client. */
1638
+ /** Signal stream completion to the client. Re-sendable on purpose: if the
1639
+ * peer was not connected the frame is dropped, and the End stays in the
1640
+ * replay buffer for the next reset ack — .NET has no "already ended" latch
1641
+ * (the End is an ordinary buffer entry, RpcSharedStream.cs:185/239). */
1620
1642
  sendEnd(error) {
1621
- if (this._ended) return;
1622
- this._ended = true;
1623
- const conn = this.peer.connection;
1643
+ if (this._isDisposed) return;
1644
+ const index = this._nextIndex++;
1645
+ const conn = this.peer.wireConnection;
1624
1646
  if (!conn) return;
1625
1647
  const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1626
1648
  this.peer.hub.systemCallSender.end(
1627
1649
  conn,
1628
1650
  this.peer.serializationFormat,
1629
1651
  this.id.localId,
1630
- this._nextIndex,
1652
+ index,
1631
1653
  errorInfo
1632
1654
  );
1633
1655
  }
@@ -1639,13 +1661,14 @@ var RpcStreamSender = class _RpcStreamSender {
1639
1661
  */
1640
1662
  async writeFrom(source) {
1641
1663
  await this._started;
1642
- if (this._ended) return;
1664
+ if (this._isDisposed) return;
1643
1665
  const iterator = source[Symbol.asyncIterator]();
1644
1666
  this._iterator = iterator;
1645
1667
  const state = { iteratorDone: false };
1646
1668
  try {
1647
1669
  await this._run(iterator, state);
1648
1670
  } finally {
1671
+ this._isCompleted = true;
1649
1672
  if (state.iteratorDone && this._iterator === iterator) {
1650
1673
  this._iterator = null;
1651
1674
  }
@@ -1658,7 +1681,7 @@ var RpcStreamSender = class _RpcStreamSender {
1658
1681
  * With `bufferSize > ackAdvance` the local ring buffer holds more than the
1659
1682
  * in-flight window — real-time mode uses the extra space to pre-buffer.
1660
1683
  */
1661
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1684
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _isDisposed changes across awaits */
1662
1685
  async _run(iterator, state) {
1663
1686
  const buffer = new RingBuffer(this.bufferSize + 1);
1664
1687
  const isRealTime = this.isRealTime;
@@ -1691,7 +1714,7 @@ var RpcStreamSender = class _RpcStreamSender {
1691
1714
  if (r === pending)
1692
1715
  return false;
1693
1716
  whenMovedNext = null;
1694
- if (this._ended) return false;
1717
+ if (this._isDisposed) return false;
1695
1718
  if (r.done) {
1696
1719
  buffer.pushTail(_endItem);
1697
1720
  isFullyBuffered = true;
@@ -1715,105 +1738,107 @@ var RpcStreamSender = class _RpcStreamSender {
1715
1738
  return true;
1716
1739
  }
1717
1740
  };
1718
- while (true) {
1719
- let ack = this._tryProcessAcks();
1720
- if (!ack) {
1721
- await this._waitAckReady();
1722
- if (this._ended) return;
1723
- ack = this._tryProcessAcks();
1741
+ nextAck:
1742
+ while (true) {
1743
+ let ack = this._tryProcessAcks();
1724
1744
  if (!ack) {
1725
- warnLog5?.log("Something is off: couldn't read an acknowledgement");
1726
- return;
1745
+ await this._waitAckReady();
1746
+ if (this._isDisposed) return;
1747
+ ack = this._tryProcessAcks();
1748
+ if (!ack) {
1749
+ warnLog5?.log("Something is off: couldn't read an acknowledgement");
1750
+ return;
1751
+ }
1727
1752
  }
1728
- }
1729
- this._lastAckedIndex = ack.nextIndex;
1730
- if (isRealTime && ack.mustReset && !isFullyBuffered) {
1731
- buffer.clear();
1732
- bufferStart = this._nextIndex;
1733
- while (true) {
1734
- if (this._ended) return;
1735
- let item;
1736
- let accepted = false;
1737
- try {
1738
- const r = await readNext();
1739
- if (this._ended) return;
1740
- if (r.done) {
1741
- item = _endItem;
1742
- isFullyBuffered = true;
1753
+ this._lastAckedIndex = ack.nextIndex;
1754
+ if (isRealTime && ack.mustReset && !isFullyBuffered) {
1755
+ buffer.clear();
1756
+ bufferStart = this._nextIndex;
1757
+ while (true) {
1758
+ if (this._isDisposed) return;
1759
+ let item;
1760
+ let accepted = false;
1761
+ try {
1762
+ const r = await readNext();
1763
+ if (this._isDisposed) return;
1764
+ if (r.done) {
1765
+ item = _endItem;
1766
+ isFullyBuffered = true;
1767
+ state.iteratorDone = true;
1768
+ } else if (this.canSkipTo(r.value)) {
1769
+ item = { kind: "value", value: r.value };
1770
+ accepted = true;
1771
+ } else {
1772
+ continue;
1773
+ }
1774
+ } catch (e) {
1743
1775
  state.iteratorDone = true;
1744
- } else if (this.canSkipTo(r.value)) {
1745
- item = { kind: "value", value: r.value };
1746
- accepted = true;
1747
- } else {
1748
- continue;
1776
+ isFullyBuffered = true;
1777
+ item = {
1778
+ kind: "error",
1779
+ error: e instanceof Error ? e : new Error(String(e))
1780
+ };
1749
1781
  }
1750
- } catch (e) {
1751
- state.iteratorDone = true;
1752
- isFullyBuffered = true;
1753
- item = {
1754
- kind: "error",
1755
- error: e instanceof Error ? e : new Error(String(e))
1756
- };
1782
+ buffer.pushTail(item);
1783
+ fireBuffered();
1784
+ if (accepted || isFullyBuffered) break;
1757
1785
  }
1758
- buffer.pushTail(item);
1759
- fireBuffered();
1760
- if (accepted || isFullyBuffered) break;
1761
1786
  }
1762
- }
1763
- const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1764
- if (shift > 0) {
1765
- buffer.moveHead(shift);
1766
- bufferStart += shift;
1767
- }
1768
- if (this._nextIndex < bufferStart) {
1769
- if (!this._ended)
1770
- this.sendEnd(new Error("Stream position unavailable."));
1771
- return;
1772
- }
1773
- let bufferIndex = this._nextIndex - bufferStart;
1774
- const maxIndex = ack.nextIndex + this.ackAdvance;
1775
- while (this._nextIndex < maxIndex) {
1776
- if (this._ended) return;
1777
- if (isRealTime) {
1778
- while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1779
- if (!await bufferNext(true, true))
1780
- break;
1781
- }
1782
- if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1783
- await bufferNext(false, true);
1784
- }
1785
- if (ack.nextIndex > 0) {
1786
- const result = _compactBufferedUnsentSuffix(
1787
- buffer,
1788
- bufferIndex,
1789
- this.canSkipTo
1790
- );
1791
- bufferIndex = result.newIndex;
1792
- if (result.skipped > 0)
1793
- this._skipCount += result.skipped;
1794
- }
1795
- if (this._hasPendingAck) break;
1787
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1788
+ if (shift > 0) {
1789
+ buffer.moveHead(shift);
1790
+ bufferStart += shift;
1796
1791
  }
1797
- while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1798
- const item = buffer.get(bufferIndex++);
1799
- if (item.kind === "value") {
1800
- this.sendItem(item.value);
1801
- } else if (item.kind === "end") {
1802
- if (!this._ended) this.sendEnd();
1803
- return;
1804
- } else {
1805
- if (!this._ended) this.sendEnd(item.error);
1806
- return;
1792
+ if (this._nextIndex < bufferStart) {
1793
+ if (!this._isDisposed)
1794
+ this.sendEnd(new Error("Stream position unavailable."));
1795
+ return;
1796
+ }
1797
+ let bufferIndex = this._nextIndex - bufferStart;
1798
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1799
+ while (this._nextIndex < maxIndex) {
1800
+ if (this._isDisposed) return;
1801
+ if (isRealTime) {
1802
+ while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1803
+ if (!await bufferNext(true, true))
1804
+ break;
1805
+ }
1806
+ if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1807
+ whenMovedNext ??= iterator.next();
1808
+ const whenAckReady = this._whenAckReady ??= new PromiseSource3();
1809
+ await Promise.race([whenMovedNext.then(_ignore, _ignore), whenAckReady]);
1810
+ if (!this._hasPendingAck)
1811
+ await bufferNext(false, true);
1812
+ }
1813
+ if (ack.nextIndex > 0) {
1814
+ const result = _compactBufferedUnsentSuffix(
1815
+ buffer,
1816
+ bufferIndex,
1817
+ this.canSkipTo
1818
+ );
1819
+ bufferIndex = result.newIndex;
1820
+ if (result.skipped > 0)
1821
+ this._skipCount += result.skipped;
1822
+ }
1823
+ if (this._hasPendingAck) break;
1824
+ }
1825
+ while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1826
+ const item = buffer.get(bufferIndex++);
1827
+ if (item.kind === "value") {
1828
+ this.sendItem(item.value);
1829
+ } else {
1830
+ this.sendEnd(item.kind === "error" ? item.error : void 0);
1831
+ continue nextAck;
1832
+ }
1807
1833
  }
1834
+ if (this._nextIndex >= maxIndex)
1835
+ break;
1836
+ if (isFullyBuffered) break;
1837
+ if (!buffer.hasRemainingCapacity) break;
1838
+ if (!isRealTime)
1839
+ await bufferNext(false, false);
1808
1840
  }
1809
- if (this._nextIndex >= maxIndex)
1810
- break;
1811
- if (isFullyBuffered) break;
1812
- if (!buffer.hasRemainingCapacity) break;
1813
- if (!isRealTime)
1814
- await bufferNext(false, false);
1815
1841
  }
1816
- }
1817
1842
  }
1818
1843
  /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1819
1844
  get _hasPendingAck() {
@@ -1838,13 +1863,14 @@ var RpcStreamSender = class _RpcStreamSender {
1838
1863
  }
1839
1864
  return { nextIndex, mustReset };
1840
1865
  }
1841
- /** Wait until an ACK is pending (or `_ended`). */
1866
+ /** Wait until an ACK is pending (or the sender is torn down). */
1842
1867
  async _waitAckReady() {
1843
- if (this._hasPendingAck || this._ended)
1868
+ if (this._hasPendingAck || this._isDisposed)
1844
1869
  return;
1845
- this._whenAckReady = new PromiseSource3();
1846
- await this._whenAckReady;
1847
- this._whenAckReady = null;
1870
+ const whenAckReady = this._whenAckReady ??= new PromiseSource3();
1871
+ await whenAckReady;
1872
+ if (this._whenAckReady === whenAckReady)
1873
+ this._whenAckReady = null;
1848
1874
  }
1849
1875
  // One RTT sample per ack: time from sending the newest acked item to the
1850
1876
  // ack's arrival (a slight upper bound — includes the server's ack cadence).
@@ -1870,8 +1896,8 @@ var RpcStreamSender = class _RpcStreamSender {
1870
1896
  reconnect() {
1871
1897
  }
1872
1898
  disconnect() {
1873
- if (this._ended) return;
1874
- this._ended = true;
1899
+ if (this._isDisposed) return;
1900
+ this._isDisposed = true;
1875
1901
  this._abortController.abort();
1876
1902
  if (!this._started.isCompleted) {
1877
1903
  this._started.resolve();
@@ -2030,8 +2056,11 @@ var RpcStream = class _RpcStream {
2030
2056
  }
2031
2057
  }
2032
2058
  /**
2033
- * For local streams: resolves when the sender finishes pumping (after `toRef()` was called).
2034
- * Rejects if the source throws. Throws if `toRef()` hasn't been called yet.
2059
+ * For local streams: resolves once the consumer acknowledges the end
2060
+ * ($sys.AckEnd) or the sender is disconnected — NOT when the source drains,
2061
+ * since the sender keeps serving acks after End so a reconnect can replay it.
2062
+ * A source error reaches the consumer as $sys.End(error) instead of rejecting
2063
+ * this. Throws if `toRef()` hasn't been called yet.
2035
2064
  */
2036
2065
  get whenSent() {
2037
2066
  if (!this._whenSent)
@@ -2297,7 +2326,7 @@ var RpcStream = class _RpcStream {
2297
2326
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
2298
2327
  _sendAck(nextIndex, mustReset) {
2299
2328
  this._ackSentUpTo = nextIndex;
2300
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2329
+ const conn = this.peer.wireConnection;
2301
2330
  if (conn) {
2302
2331
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
2303
2332
  this.peer.hub.systemCallSender.ack(
@@ -2310,7 +2339,7 @@ var RpcStream = class _RpcStream {
2310
2339
  }
2311
2340
  }
2312
2341
  _sendAckEnd() {
2313
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2342
+ const conn = this.peer.wireConnection;
2314
2343
  if (conn) {
2315
2344
  this.peer.hub.systemCallSender.ackEnd(
2316
2345
  conn,
@@ -2727,6 +2756,18 @@ var RpcPeer = class {
2727
2756
  get connection() {
2728
2757
  return this._connection;
2729
2758
  }
2759
+ // The connection you may send a non-handshake message on, or `undefined`.
2760
+ // `_connection` is set before the socket even opens and stays set through
2761
+ // the handshake, and `RpcConnection` buffers pre-OPEN sends and flushes them
2762
+ // from `onopen` — so writing through it during Connecting or Handshaking puts
2763
+ // the frame on the wire ahead of $sys.Handshake and corrupts the remote's
2764
+ // handshake. `_isConnected` is the only flag false across both phases.
2765
+ // Port of C#'s `RpcPeer.Transport` (RpcPeer.cs:29-52), which hides the
2766
+ // transport for exactly this reason. $sys.Handshake itself must use
2767
+ // `connection` — it is the frame this gate exists to let through first.
2768
+ get wireConnection() {
2769
+ return this._isConnected ? this._connection : void 0;
2770
+ }
2730
2771
  get connectionState() {
2731
2772
  return this._connectionState;
2732
2773
  }
@@ -2757,7 +2798,7 @@ var RpcPeer = class {
2757
2798
  this._isConnected = true;
2758
2799
  this._disconnectedAt = 0;
2759
2800
  this._armKeepAliveWatchdog();
2760
- } else if (value === 0 /* Disconnected */) {
2801
+ } else {
2761
2802
  if (previousState === 3 /* Connected */) {
2762
2803
  this._isConnected = false;
2763
2804
  this._disconnectedAt = Date.now();
@@ -2847,8 +2888,9 @@ var RpcPeer = class {
2847
2888
  if (this.outboundCalls.remove(callId) !== void 0) {
2848
2889
  outboundCall.result.reject(cancellationError("Call cancelled."));
2849
2890
  outboundCall.onDisconnect();
2850
- if (this._isConnected && this._connection !== void 0)
2851
- this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
2891
+ const conn = this.wireConnection;
2892
+ if (conn !== void 0)
2893
+ this.hub.systemCallSender.cancel(conn, this.serializationFormat, callId);
2852
2894
  }
2853
2895
  };
2854
2896
  signal.addEventListener("abort", onAbort, { once: true });
@@ -2891,9 +2933,11 @@ var RpcPeer = class {
2891
2933
  this._setConnectionState(0 /* Disconnected */);
2892
2934
  infoLog?.log(`'${this.ref}': Stopped`);
2893
2935
  }
2894
- /** Send pre-serialized wire data through the current connection. */
2936
+ /** Send pre-serialized wire data through the current connection. Callers are
2937
+ * already gated on `_isConnected`; the check is repeated here so the next one
2938
+ * cannot reintroduce a pre-handshake write. $sys.Handshake does not use this. */
2895
2939
  _sendWireData(data) {
2896
- if (this._connection === void 0) return;
2940
+ if (!this._isConnected || this._connection === void 0) return;
2897
2941
  if (typeof data === "string") {
2898
2942
  this._connection.send(data);
2899
2943
  } else {
@@ -2976,9 +3020,10 @@ var RpcPeer = class {
2976
3020
  const ref = stream.toRef(this);
2977
3021
  const send = () => {
2978
3022
  if (call2?.isCancelled === true) return;
2979
- if (this._connection !== void 0)
3023
+ const conn = this.wireConnection;
3024
+ if (conn !== void 0)
2980
3025
  this.hub.systemCallSender.ok(
2981
- this._connection,
3026
+ conn,
2982
3027
  this.serializationFormat,
2983
3028
  relatedId,
2984
3029
  ref
@@ -2993,9 +3038,10 @@ var RpcPeer = class {
2993
3038
  } else if (call2 !== void 0) {
2994
3039
  const send = () => {
2995
3040
  if (call2.isCancelled) return;
2996
- if (this._connection !== void 0)
3041
+ const conn = this.wireConnection;
3042
+ if (conn !== void 0)
2997
3043
  this.hub.systemCallSender.ok(
2998
- this._connection,
3044
+ conn,
2999
3045
  this.serializationFormat,
3000
3046
  relatedId,
3001
3047
  result
@@ -3009,9 +3055,10 @@ var RpcPeer = class {
3009
3055
  if (call2 !== void 0) {
3010
3056
  const send = () => {
3011
3057
  if (call2.isCancelled) return;
3012
- if (this._connection !== void 0)
3058
+ const conn = this.wireConnection;
3059
+ if (conn !== void 0)
3013
3060
  this.hub.systemCallSender.error(
3014
- this._connection,
3061
+ conn,
3015
3062
  this.serializationFormat,
3016
3063
  relatedId,
3017
3064
  e
@@ -3044,8 +3091,9 @@ var RpcPeer = class {
3044
3091
  * Mirrors .NET `RpcObjectTrackers.Maintain`'s `KeepAliveTimeout` branch. */
3045
3092
  _armKeepAliveWatchdog() {
3046
3093
  this._keepAliveTimer ??= setInterval(() => {
3047
- if (this._connection !== void 0) {
3048
- this.hub.systemCallSender.keepAlive(this._connection, this.serializationFormat, [
3094
+ const conn = this.wireConnection;
3095
+ if (conn !== void 0) {
3096
+ this.hub.systemCallSender.keepAlive(conn, this.serializationFormat, [
3049
3097
  ...this.remoteObjects.keys()
3050
3098
  ]);
3051
3099
  }
@@ -3193,9 +3241,26 @@ var RpcClientPeer = class extends RpcPeer {
3193
3241
  connectWith(conn, isPeerChanged = true) {
3194
3242
  const sentCalls = this.outboundCalls.getSentCalls();
3195
3243
  this.setupConnection(conn);
3244
+ const whenHandshake = isPeerChanged ? void 0 : this._pendingHandshake = new PromiseSource5();
3245
+ this.hub.systemCallSender.handshake(
3246
+ conn,
3247
+ this.serializationFormat,
3248
+ this.id,
3249
+ this.hub.hubId,
3250
+ this.nextOwnHandshakeIndex()
3251
+ );
3196
3252
  this._isConnected = true;
3197
3253
  this.connectionStateChanged.trigger(3 /* Connected */);
3198
- void this._reconnect(sentCalls, isPeerChanged);
3254
+ if (whenHandshake === void 0) {
3255
+ void this._reconnect(sentCalls, true);
3256
+ return;
3257
+ }
3258
+ const whenClosed = new PromiseSource5();
3259
+ conn.closed.add(() => whenClosed.resolve());
3260
+ void Promise.race([whenHandshake, whenClosed]).then(() => {
3261
+ this._pendingHandshake = void 0;
3262
+ return this._reconnect(sentCalls, false);
3263
+ });
3199
3264
  }
3200
3265
  /** Kick off the reconnect loop. Idempotent — subsequent calls are
3201
3266
  * no-ops; await `peer.whenRunning` if you need the loop's completion
@@ -3513,6 +3578,7 @@ var RpcClientPeer = class extends RpcPeer {
3513
3578
  return 2 /* Changed */;
3514
3579
  }
3515
3580
  _onHandshakeReceived(handshake) {
3581
+ this._remoteHandshakeIndex = handshake.Index ?? 0;
3516
3582
  this._pendingHandshake?.resolve(handshake);
3517
3583
  }
3518
3584
  close() {