@actuallab/rpc 14.4.3 → 14.4.14

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
@@ -1193,13 +1193,17 @@ var RpcSystemCallHandler = class {
1193
1193
  case RpcSystemCalls.keepAlive: {
1194
1194
  peer.notifyKeepAliveReceived();
1195
1195
  const keepAliveIds = args[0];
1196
- if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1196
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.wireConnection !== void 0) {
1197
1197
  const unknownIds = [];
1198
1198
  for (const id of keepAliveIds)
1199
1199
  if (peer.sharedObjects.get(id) === void 0)
1200
1200
  unknownIds.push(id);
1201
1201
  if (unknownIds.length > 0)
1202
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1202
+ peer.hub.systemCallSender.disconnect(
1203
+ peer.wireConnection,
1204
+ peer.serializationFormat,
1205
+ unknownIds
1206
+ );
1203
1207
  }
1204
1208
  break;
1205
1209
  }
@@ -1245,16 +1249,24 @@ var RpcSystemCallHandler = class {
1245
1249
  const sender = peer.sharedObjects.get(relatedId);
1246
1250
  if (sender)
1247
1251
  sender.onAck(args[0], args[1]);
1248
- else if (peer.connection !== void 0)
1249
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1252
+ else if (peer.wireConnection !== void 0)
1253
+ peer.hub.systemCallSender.disconnect(
1254
+ peer.wireConnection,
1255
+ peer.serializationFormat,
1256
+ [relatedId]
1257
+ );
1250
1258
  break;
1251
1259
  }
1252
1260
  case RpcSystemCalls.ackEnd: {
1253
1261
  const sender = peer.sharedObjects.get(relatedId);
1254
1262
  if (sender)
1255
1263
  sender.onAckEnd(args[0]);
1256
- else if (peer.connection !== void 0)
1257
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1264
+ else if (peer.wireConnection !== void 0)
1265
+ peer.hub.systemCallSender.disconnect(
1266
+ peer.wireConnection,
1267
+ peer.serializationFormat,
1268
+ [relatedId]
1269
+ );
1258
1270
  break;
1259
1271
  }
1260
1272
  case RpcSystemCalls.disconnect: {
@@ -1283,12 +1295,13 @@ var RpcSystemCallHandler = class {
1283
1295
  * with the same `RelatedId`.
1284
1296
  */
1285
1297
  _handleReconnect(relatedId, args, peer) {
1286
- if (peer.connection === void 0) return;
1298
+ const conn = peer.wireConnection;
1299
+ if (conn === void 0) return;
1287
1300
  const handshakeIndex = args[0];
1288
1301
  if (typeof handshakeIndex !== "number" || handshakeIndex !== peer.ownHandshakeIndex) {
1289
1302
  const got = typeof handshakeIndex === "number" ? handshakeIndex : `<${typeof handshakeIndex}>`;
1290
1303
  peer.hub.systemCallSender.error(
1291
- peer.connection,
1304
+ conn,
1292
1305
  peer.serializationFormat,
1293
1306
  relatedId,
1294
1307
  new Error(
@@ -1299,7 +1312,7 @@ var RpcSystemCallHandler = class {
1299
1312
  }
1300
1313
  if (!peer.tryClaimReconnect()) {
1301
1314
  peer.hub.systemCallSender.error(
1302
- peer.connection,
1315
+ conn,
1303
1316
  peer.serializationFormat,
1304
1317
  relatedId,
1305
1318
  new Error("TooLateToReconnect: this connection was already reconnected")
@@ -1330,7 +1343,7 @@ var RpcSystemCallHandler = class {
1330
1343
  }
1331
1344
  const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1332
1345
  const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1333
- peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1346
+ peer.hub.systemCallSender.ok(conn, peer.serializationFormat, relatedId, responseValue);
1334
1347
  }
1335
1348
  };
1336
1349
  function polymorphicPayloadError(method, got, expected) {
@@ -1513,6 +1526,8 @@ var import_core6 = require("@actuallab/core");
1513
1526
  // src/rpc-stream-sender.ts
1514
1527
  var import_core5 = require("@actuallab/core");
1515
1528
  var { warnLog: warnLog5 } = getLogs("RpcSharedStream");
1529
+ function _ignore() {
1530
+ }
1516
1531
  var DEFAULT_ACK_PERIOD = 256;
1517
1532
  var DEFAULT_ACK_ADVANCE = 128;
1518
1533
  var RTT_WINDOW_MS = 2e4;
@@ -1552,7 +1567,11 @@ var RpcStreamSender = class _RpcStreamSender {
1552
1567
  // -- Pump state --
1553
1568
  /** Next stream index that will be sent (matches .NET's `index`). */
1554
1569
  _nextIndex = 0;
1555
- _ended = false;
1570
+ /** Torn down — `disconnect()` ran. .NET's counterpart is disposal; note it
1571
+ * does NOT mean "an End was sent", which .NET does not track at all. */
1572
+ _isDisposed = false;
1573
+ /** The pump has returned. .NET's `WhenRunning.IsCompleted`. */
1574
+ _isCompleted = false;
1556
1575
  _started = new import_core5.PromiseSource();
1557
1576
  /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
1558
1577
  _lastAckedIndex = 0;
@@ -1659,7 +1678,7 @@ var RpcStreamSender = class _RpcStreamSender {
1659
1678
  this._sendDisconnect();
1660
1679
  return;
1661
1680
  }
1662
- } else if (this._ended) {
1681
+ } else if (this._isCompleted) {
1663
1682
  this._sendDisconnect();
1664
1683
  return;
1665
1684
  } else if (mustReset && !this.allowReconnect) {
@@ -1680,7 +1699,7 @@ var RpcStreamSender = class _RpcStreamSender {
1680
1699
  // Tells the consumer this shared stream is gone, then disposes it —
1681
1700
  // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1682
1701
  _sendDisconnect() {
1683
- const conn = this.peer.connection;
1702
+ const conn = this.peer.wireConnection;
1684
1703
  if (conn)
1685
1704
  this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1686
1705
  this.disconnect();
@@ -1694,13 +1713,13 @@ var RpcStreamSender = class _RpcStreamSender {
1694
1713
  *
1695
1714
  * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1696
1715
  * the item is considered "sent" from the pump's perspective so the replay
1697
- * buffer indexing stays consistent. If a connection is absent, the wire
1716
+ * buffer indexing stays consistent. If the peer isn't connected, the wire
1698
1717
  * message is dropped; the item will be resent from the replay buffer
1699
1718
  * after a reconnect (client sends `Ack(N, mustReset=true)`).
1700
1719
  */
1701
1720
  sendItem(item) {
1702
- if (this._ended) return;
1703
- const conn = this.peer.connection;
1721
+ if (this._isDisposed) return;
1722
+ const conn = this.peer.wireConnection;
1704
1723
  if (conn) {
1705
1724
  this.peer.hub.systemCallSender.item(
1706
1725
  conn,
@@ -1717,8 +1736,8 @@ var RpcStreamSender = class _RpcStreamSender {
1717
1736
  }
1718
1737
  /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
1719
1738
  sendBatch(items) {
1720
- if (this._ended || items.length === 0) return;
1721
- const conn = this.peer.connection;
1739
+ if (this._isDisposed || items.length === 0) return;
1740
+ const conn = this.peer.wireConnection;
1722
1741
  if (conn) {
1723
1742
  this.peer.hub.systemCallSender.batch(
1724
1743
  conn,
@@ -1730,18 +1749,21 @@ var RpcStreamSender = class _RpcStreamSender {
1730
1749
  }
1731
1750
  this._nextIndex += items.length;
1732
1751
  }
1733
- /** Signal stream completion to the client. */
1752
+ /** Signal stream completion to the client. Re-sendable on purpose: if the
1753
+ * peer was not connected the frame is dropped, and the End stays in the
1754
+ * replay buffer for the next reset ack — .NET has no "already ended" latch
1755
+ * (the End is an ordinary buffer entry, RpcSharedStream.cs:185/239). */
1734
1756
  sendEnd(error) {
1735
- if (this._ended) return;
1736
- this._ended = true;
1737
- const conn = this.peer.connection;
1757
+ if (this._isDisposed) return;
1758
+ const index = this._nextIndex++;
1759
+ const conn = this.peer.wireConnection;
1738
1760
  if (!conn) return;
1739
1761
  const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1740
1762
  this.peer.hub.systemCallSender.end(
1741
1763
  conn,
1742
1764
  this.peer.serializationFormat,
1743
1765
  this.id.localId,
1744
- this._nextIndex,
1766
+ index,
1745
1767
  errorInfo
1746
1768
  );
1747
1769
  }
@@ -1753,13 +1775,14 @@ var RpcStreamSender = class _RpcStreamSender {
1753
1775
  */
1754
1776
  async writeFrom(source) {
1755
1777
  await this._started;
1756
- if (this._ended) return;
1778
+ if (this._isDisposed) return;
1757
1779
  const iterator = source[Symbol.asyncIterator]();
1758
1780
  this._iterator = iterator;
1759
1781
  const state = { iteratorDone: false };
1760
1782
  try {
1761
1783
  await this._run(iterator, state);
1762
1784
  } finally {
1785
+ this._isCompleted = true;
1763
1786
  if (state.iteratorDone && this._iterator === iterator) {
1764
1787
  this._iterator = null;
1765
1788
  }
@@ -1772,7 +1795,7 @@ var RpcStreamSender = class _RpcStreamSender {
1772
1795
  * With `bufferSize > ackAdvance` the local ring buffer holds more than the
1773
1796
  * in-flight window — real-time mode uses the extra space to pre-buffer.
1774
1797
  */
1775
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1798
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _isDisposed changes across awaits */
1776
1799
  async _run(iterator, state) {
1777
1800
  const buffer = new import_core5.RingBuffer(this.bufferSize + 1);
1778
1801
  const isRealTime = this.isRealTime;
@@ -1805,7 +1828,7 @@ var RpcStreamSender = class _RpcStreamSender {
1805
1828
  if (r === pending)
1806
1829
  return false;
1807
1830
  whenMovedNext = null;
1808
- if (this._ended) return false;
1831
+ if (this._isDisposed) return false;
1809
1832
  if (r.done) {
1810
1833
  buffer.pushTail(_endItem);
1811
1834
  isFullyBuffered = true;
@@ -1829,105 +1852,107 @@ var RpcStreamSender = class _RpcStreamSender {
1829
1852
  return true;
1830
1853
  }
1831
1854
  };
1832
- while (true) {
1833
- let ack = this._tryProcessAcks();
1834
- if (!ack) {
1835
- await this._waitAckReady();
1836
- if (this._ended) return;
1837
- ack = this._tryProcessAcks();
1855
+ nextAck:
1856
+ while (true) {
1857
+ let ack = this._tryProcessAcks();
1838
1858
  if (!ack) {
1839
- warnLog5?.log("Something is off: couldn't read an acknowledgement");
1840
- return;
1859
+ await this._waitAckReady();
1860
+ if (this._isDisposed) return;
1861
+ ack = this._tryProcessAcks();
1862
+ if (!ack) {
1863
+ warnLog5?.log("Something is off: couldn't read an acknowledgement");
1864
+ return;
1865
+ }
1841
1866
  }
1842
- }
1843
- this._lastAckedIndex = ack.nextIndex;
1844
- if (isRealTime && ack.mustReset && !isFullyBuffered) {
1845
- buffer.clear();
1846
- bufferStart = this._nextIndex;
1847
- while (true) {
1848
- if (this._ended) return;
1849
- let item;
1850
- let accepted = false;
1851
- try {
1852
- const r = await readNext();
1853
- if (this._ended) return;
1854
- if (r.done) {
1855
- item = _endItem;
1856
- isFullyBuffered = true;
1867
+ this._lastAckedIndex = ack.nextIndex;
1868
+ if (isRealTime && ack.mustReset && !isFullyBuffered) {
1869
+ buffer.clear();
1870
+ bufferStart = this._nextIndex;
1871
+ while (true) {
1872
+ if (this._isDisposed) return;
1873
+ let item;
1874
+ let accepted = false;
1875
+ try {
1876
+ const r = await readNext();
1877
+ if (this._isDisposed) return;
1878
+ if (r.done) {
1879
+ item = _endItem;
1880
+ isFullyBuffered = true;
1881
+ state.iteratorDone = true;
1882
+ } else if (this.canSkipTo(r.value)) {
1883
+ item = { kind: "value", value: r.value };
1884
+ accepted = true;
1885
+ } else {
1886
+ continue;
1887
+ }
1888
+ } catch (e) {
1857
1889
  state.iteratorDone = true;
1858
- } else if (this.canSkipTo(r.value)) {
1859
- item = { kind: "value", value: r.value };
1860
- accepted = true;
1861
- } else {
1862
- continue;
1890
+ isFullyBuffered = true;
1891
+ item = {
1892
+ kind: "error",
1893
+ error: e instanceof Error ? e : new Error(String(e))
1894
+ };
1863
1895
  }
1864
- } catch (e) {
1865
- state.iteratorDone = true;
1866
- isFullyBuffered = true;
1867
- item = {
1868
- kind: "error",
1869
- error: e instanceof Error ? e : new Error(String(e))
1870
- };
1896
+ buffer.pushTail(item);
1897
+ fireBuffered();
1898
+ if (accepted || isFullyBuffered) break;
1871
1899
  }
1872
- buffer.pushTail(item);
1873
- fireBuffered();
1874
- if (accepted || isFullyBuffered) break;
1875
1900
  }
1876
- }
1877
- const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1878
- if (shift > 0) {
1879
- buffer.moveHead(shift);
1880
- bufferStart += shift;
1881
- }
1882
- if (this._nextIndex < bufferStart) {
1883
- if (!this._ended)
1884
- this.sendEnd(new Error("Stream position unavailable."));
1885
- return;
1886
- }
1887
- let bufferIndex = this._nextIndex - bufferStart;
1888
- const maxIndex = ack.nextIndex + this.ackAdvance;
1889
- while (this._nextIndex < maxIndex) {
1890
- if (this._ended) return;
1891
- if (isRealTime) {
1892
- while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1893
- if (!await bufferNext(true, true))
1894
- break;
1895
- }
1896
- if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1897
- await bufferNext(false, true);
1898
- }
1899
- if (ack.nextIndex > 0) {
1900
- const result = _compactBufferedUnsentSuffix(
1901
- buffer,
1902
- bufferIndex,
1903
- this.canSkipTo
1904
- );
1905
- bufferIndex = result.newIndex;
1906
- if (result.skipped > 0)
1907
- this._skipCount += result.skipped;
1908
- }
1909
- if (this._hasPendingAck) break;
1901
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1902
+ if (shift > 0) {
1903
+ buffer.moveHead(shift);
1904
+ bufferStart += shift;
1910
1905
  }
1911
- while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1912
- const item = buffer.get(bufferIndex++);
1913
- if (item.kind === "value") {
1914
- this.sendItem(item.value);
1915
- } else if (item.kind === "end") {
1916
- if (!this._ended) this.sendEnd();
1917
- return;
1918
- } else {
1919
- if (!this._ended) this.sendEnd(item.error);
1920
- return;
1906
+ if (this._nextIndex < bufferStart) {
1907
+ if (!this._isDisposed)
1908
+ this.sendEnd(new Error("Stream position unavailable."));
1909
+ return;
1910
+ }
1911
+ let bufferIndex = this._nextIndex - bufferStart;
1912
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1913
+ while (this._nextIndex < maxIndex) {
1914
+ if (this._isDisposed) return;
1915
+ if (isRealTime) {
1916
+ while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1917
+ if (!await bufferNext(true, true))
1918
+ break;
1919
+ }
1920
+ if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1921
+ whenMovedNext ??= iterator.next();
1922
+ const whenAckReady = this._whenAckReady ??= new import_core5.PromiseSource();
1923
+ await Promise.race([whenMovedNext.then(_ignore, _ignore), whenAckReady]);
1924
+ if (!this._hasPendingAck)
1925
+ await bufferNext(false, true);
1926
+ }
1927
+ if (ack.nextIndex > 0) {
1928
+ const result = _compactBufferedUnsentSuffix(
1929
+ buffer,
1930
+ bufferIndex,
1931
+ this.canSkipTo
1932
+ );
1933
+ bufferIndex = result.newIndex;
1934
+ if (result.skipped > 0)
1935
+ this._skipCount += result.skipped;
1936
+ }
1937
+ if (this._hasPendingAck) break;
1938
+ }
1939
+ while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1940
+ const item = buffer.get(bufferIndex++);
1941
+ if (item.kind === "value") {
1942
+ this.sendItem(item.value);
1943
+ } else {
1944
+ this.sendEnd(item.kind === "error" ? item.error : void 0);
1945
+ continue nextAck;
1946
+ }
1921
1947
  }
1948
+ if (this._nextIndex >= maxIndex)
1949
+ break;
1950
+ if (isFullyBuffered) break;
1951
+ if (!buffer.hasRemainingCapacity) break;
1952
+ if (!isRealTime)
1953
+ await bufferNext(false, false);
1922
1954
  }
1923
- if (this._nextIndex >= maxIndex)
1924
- break;
1925
- if (isFullyBuffered) break;
1926
- if (!buffer.hasRemainingCapacity) break;
1927
- if (!isRealTime)
1928
- await bufferNext(false, false);
1929
1955
  }
1930
- }
1931
1956
  }
1932
1957
  /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1933
1958
  get _hasPendingAck() {
@@ -1952,13 +1977,14 @@ var RpcStreamSender = class _RpcStreamSender {
1952
1977
  }
1953
1978
  return { nextIndex, mustReset };
1954
1979
  }
1955
- /** Wait until an ACK is pending (or `_ended`). */
1980
+ /** Wait until an ACK is pending (or the sender is torn down). */
1956
1981
  async _waitAckReady() {
1957
- if (this._hasPendingAck || this._ended)
1982
+ if (this._hasPendingAck || this._isDisposed)
1958
1983
  return;
1959
- this._whenAckReady = new import_core5.PromiseSource();
1960
- await this._whenAckReady;
1961
- this._whenAckReady = null;
1984
+ const whenAckReady = this._whenAckReady ??= new import_core5.PromiseSource();
1985
+ await whenAckReady;
1986
+ if (this._whenAckReady === whenAckReady)
1987
+ this._whenAckReady = null;
1962
1988
  }
1963
1989
  // One RTT sample per ack: time from sending the newest acked item to the
1964
1990
  // ack's arrival (a slight upper bound — includes the server's ack cadence).
@@ -1984,8 +2010,8 @@ var RpcStreamSender = class _RpcStreamSender {
1984
2010
  reconnect() {
1985
2011
  }
1986
2012
  disconnect() {
1987
- if (this._ended) return;
1988
- this._ended = true;
2013
+ if (this._isDisposed) return;
2014
+ this._isDisposed = true;
1989
2015
  this._abortController.abort();
1990
2016
  if (!this._started.isCompleted) {
1991
2017
  this._started.resolve();
@@ -2144,8 +2170,11 @@ var RpcStream = class _RpcStream {
2144
2170
  }
2145
2171
  }
2146
2172
  /**
2147
- * For local streams: resolves when the sender finishes pumping (after `toRef()` was called).
2148
- * Rejects if the source throws. Throws if `toRef()` hasn't been called yet.
2173
+ * For local streams: resolves once the consumer acknowledges the end
2174
+ * ($sys.AckEnd) or the sender is disconnected — NOT when the source drains,
2175
+ * since the sender keeps serving acks after End so a reconnect can replay it.
2176
+ * A source error reaches the consumer as $sys.End(error) instead of rejecting
2177
+ * this. Throws if `toRef()` hasn't been called yet.
2149
2178
  */
2150
2179
  get whenSent() {
2151
2180
  if (!this._whenSent)
@@ -2411,7 +2440,7 @@ var RpcStream = class _RpcStream {
2411
2440
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
2412
2441
  _sendAck(nextIndex, mustReset) {
2413
2442
  this._ackSentUpTo = nextIndex;
2414
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2443
+ const conn = this.peer.wireConnection;
2415
2444
  if (conn) {
2416
2445
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
2417
2446
  this.peer.hub.systemCallSender.ack(
@@ -2424,7 +2453,7 @@ var RpcStream = class _RpcStream {
2424
2453
  }
2425
2454
  }
2426
2455
  _sendAckEnd() {
2427
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2456
+ const conn = this.peer.wireConnection;
2428
2457
  if (conn) {
2429
2458
  this.peer.hub.systemCallSender.ackEnd(
2430
2459
  conn,
@@ -2841,6 +2870,18 @@ var RpcPeer = class {
2841
2870
  get connection() {
2842
2871
  return this._connection;
2843
2872
  }
2873
+ // The connection you may send a non-handshake message on, or `undefined`.
2874
+ // `_connection` is set before the socket even opens and stays set through
2875
+ // the handshake, and `RpcConnection` buffers pre-OPEN sends and flushes them
2876
+ // from `onopen` — so writing through it during Connecting or Handshaking puts
2877
+ // the frame on the wire ahead of $sys.Handshake and corrupts the remote's
2878
+ // handshake. `_isConnected` is the only flag false across both phases.
2879
+ // Port of C#'s `RpcPeer.Transport` (RpcPeer.cs:29-52), which hides the
2880
+ // transport for exactly this reason. $sys.Handshake itself must use
2881
+ // `connection` — it is the frame this gate exists to let through first.
2882
+ get wireConnection() {
2883
+ return this._isConnected ? this._connection : void 0;
2884
+ }
2844
2885
  get connectionState() {
2845
2886
  return this._connectionState;
2846
2887
  }
@@ -2871,7 +2912,7 @@ var RpcPeer = class {
2871
2912
  this._isConnected = true;
2872
2913
  this._disconnectedAt = 0;
2873
2914
  this._armKeepAliveWatchdog();
2874
- } else if (value === 0 /* Disconnected */) {
2915
+ } else {
2875
2916
  if (previousState === 3 /* Connected */) {
2876
2917
  this._isConnected = false;
2877
2918
  this._disconnectedAt = Date.now();
@@ -2961,8 +3002,9 @@ var RpcPeer = class {
2961
3002
  if (this.outboundCalls.remove(callId) !== void 0) {
2962
3003
  outboundCall.result.reject((0, import_core7.cancellationError)("Call cancelled."));
2963
3004
  outboundCall.onDisconnect();
2964
- if (this._isConnected && this._connection !== void 0)
2965
- this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
3005
+ const conn = this.wireConnection;
3006
+ if (conn !== void 0)
3007
+ this.hub.systemCallSender.cancel(conn, this.serializationFormat, callId);
2966
3008
  }
2967
3009
  };
2968
3010
  signal.addEventListener("abort", onAbort, { once: true });
@@ -3005,9 +3047,11 @@ var RpcPeer = class {
3005
3047
  this._setConnectionState(0 /* Disconnected */);
3006
3048
  infoLog?.log(`'${this.ref}': Stopped`);
3007
3049
  }
3008
- /** Send pre-serialized wire data through the current connection. */
3050
+ /** Send pre-serialized wire data through the current connection. Callers are
3051
+ * already gated on `_isConnected`; the check is repeated here so the next one
3052
+ * cannot reintroduce a pre-handshake write. $sys.Handshake does not use this. */
3009
3053
  _sendWireData(data) {
3010
- if (this._connection === void 0) return;
3054
+ if (!this._isConnected || this._connection === void 0) return;
3011
3055
  if (typeof data === "string") {
3012
3056
  this._connection.send(data);
3013
3057
  } else {
@@ -3090,9 +3134,10 @@ var RpcPeer = class {
3090
3134
  const ref = stream.toRef(this);
3091
3135
  const send = () => {
3092
3136
  if (call2?.isCancelled === true) return;
3093
- if (this._connection !== void 0)
3137
+ const conn = this.wireConnection;
3138
+ if (conn !== void 0)
3094
3139
  this.hub.systemCallSender.ok(
3095
- this._connection,
3140
+ conn,
3096
3141
  this.serializationFormat,
3097
3142
  relatedId,
3098
3143
  ref
@@ -3107,9 +3152,10 @@ var RpcPeer = class {
3107
3152
  } else if (call2 !== void 0) {
3108
3153
  const send = () => {
3109
3154
  if (call2.isCancelled) return;
3110
- if (this._connection !== void 0)
3155
+ const conn = this.wireConnection;
3156
+ if (conn !== void 0)
3111
3157
  this.hub.systemCallSender.ok(
3112
- this._connection,
3158
+ conn,
3113
3159
  this.serializationFormat,
3114
3160
  relatedId,
3115
3161
  result
@@ -3123,9 +3169,10 @@ var RpcPeer = class {
3123
3169
  if (call2 !== void 0) {
3124
3170
  const send = () => {
3125
3171
  if (call2.isCancelled) return;
3126
- if (this._connection !== void 0)
3172
+ const conn = this.wireConnection;
3173
+ if (conn !== void 0)
3127
3174
  this.hub.systemCallSender.error(
3128
- this._connection,
3175
+ conn,
3129
3176
  this.serializationFormat,
3130
3177
  relatedId,
3131
3178
  e
@@ -3158,8 +3205,9 @@ var RpcPeer = class {
3158
3205
  * Mirrors .NET `RpcObjectTrackers.Maintain`'s `KeepAliveTimeout` branch. */
3159
3206
  _armKeepAliveWatchdog() {
3160
3207
  this._keepAliveTimer ??= setInterval(() => {
3161
- if (this._connection !== void 0) {
3162
- this.hub.systemCallSender.keepAlive(this._connection, this.serializationFormat, [
3208
+ const conn = this.wireConnection;
3209
+ if (conn !== void 0) {
3210
+ this.hub.systemCallSender.keepAlive(conn, this.serializationFormat, [
3163
3211
  ...this.remoteObjects.keys()
3164
3212
  ]);
3165
3213
  }
@@ -3307,9 +3355,26 @@ var RpcClientPeer = class extends RpcPeer {
3307
3355
  connectWith(conn, isPeerChanged = true) {
3308
3356
  const sentCalls = this.outboundCalls.getSentCalls();
3309
3357
  this.setupConnection(conn);
3358
+ const whenHandshake = isPeerChanged ? void 0 : this._pendingHandshake = new import_core7.PromiseSource();
3359
+ this.hub.systemCallSender.handshake(
3360
+ conn,
3361
+ this.serializationFormat,
3362
+ this.id,
3363
+ this.hub.hubId,
3364
+ this.nextOwnHandshakeIndex()
3365
+ );
3310
3366
  this._isConnected = true;
3311
3367
  this.connectionStateChanged.trigger(3 /* Connected */);
3312
- void this._reconnect(sentCalls, isPeerChanged);
3368
+ if (whenHandshake === void 0) {
3369
+ void this._reconnect(sentCalls, true);
3370
+ return;
3371
+ }
3372
+ const whenClosed = new import_core7.PromiseSource();
3373
+ conn.closed.add(() => whenClosed.resolve());
3374
+ void Promise.race([whenHandshake, whenClosed]).then(() => {
3375
+ this._pendingHandshake = void 0;
3376
+ return this._reconnect(sentCalls, false);
3377
+ });
3313
3378
  }
3314
3379
  /** Kick off the reconnect loop. Idempotent — subsequent calls are
3315
3380
  * no-ops; await `peer.whenRunning` if you need the loop's completion
@@ -3627,6 +3692,7 @@ var RpcClientPeer = class extends RpcPeer {
3627
3692
  return 2 /* Changed */;
3628
3693
  }
3629
3694
  _onHandshakeReceived(handshake) {
3695
+ this._remoteHandshakeIndex = handshake.Index ?? 0;
3630
3696
  this._pendingHandshake?.resolve(handshake);
3631
3697
  }
3632
3698
  close() {