@actuallab/rpc 14.3.34 → 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.cjs CHANGED
@@ -993,7 +993,8 @@ var RpcCallTimeouts = class _RpcCallTimeouts {
993
993
  static None = new _RpcCallTimeouts(Infinity, Infinity);
994
994
  /** Command default: 1.5 s connect / 10 s run (.NET `RpcCallTimeouts.Default.Command`). */
995
995
  static Command = new _RpcCallTimeouts(1500, 1e4);
996
- /** Max time a call may wait for the connection before it is sent. */
996
+ /** Max time a call may wait for a connection - before it is sent, and again for the
997
+ * duration of any outage it is still pending through. */
997
998
  connectTimeoutMs;
998
999
  /** Max time a sent call may stay unanswered before it is failed. */
999
1000
  runTimeoutMs;
@@ -1192,13 +1193,17 @@ var RpcSystemCallHandler = class {
1192
1193
  case RpcSystemCalls.keepAlive: {
1193
1194
  peer.notifyKeepAliveReceived();
1194
1195
  const keepAliveIds = args[0];
1195
- if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1196
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.wireConnection !== void 0) {
1196
1197
  const unknownIds = [];
1197
1198
  for (const id of keepAliveIds)
1198
1199
  if (peer.sharedObjects.get(id) === void 0)
1199
1200
  unknownIds.push(id);
1200
1201
  if (unknownIds.length > 0)
1201
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1202
+ peer.hub.systemCallSender.disconnect(
1203
+ peer.wireConnection,
1204
+ peer.serializationFormat,
1205
+ unknownIds
1206
+ );
1202
1207
  }
1203
1208
  break;
1204
1209
  }
@@ -1244,16 +1249,24 @@ var RpcSystemCallHandler = class {
1244
1249
  const sender = peer.sharedObjects.get(relatedId);
1245
1250
  if (sender)
1246
1251
  sender.onAck(args[0], args[1]);
1247
- else if (peer.connection !== void 0)
1248
- 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
+ );
1249
1258
  break;
1250
1259
  }
1251
1260
  case RpcSystemCalls.ackEnd: {
1252
1261
  const sender = peer.sharedObjects.get(relatedId);
1253
1262
  if (sender)
1254
1263
  sender.onAckEnd(args[0]);
1255
- else if (peer.connection !== void 0)
1256
- 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
+ );
1257
1270
  break;
1258
1271
  }
1259
1272
  case RpcSystemCalls.disconnect: {
@@ -1282,12 +1295,13 @@ var RpcSystemCallHandler = class {
1282
1295
  * with the same `RelatedId`.
1283
1296
  */
1284
1297
  _handleReconnect(relatedId, args, peer) {
1285
- if (peer.connection === void 0) return;
1298
+ const conn = peer.wireConnection;
1299
+ if (conn === void 0) return;
1286
1300
  const handshakeIndex = args[0];
1287
1301
  if (typeof handshakeIndex !== "number" || handshakeIndex !== peer.ownHandshakeIndex) {
1288
1302
  const got = typeof handshakeIndex === "number" ? handshakeIndex : `<${typeof handshakeIndex}>`;
1289
1303
  peer.hub.systemCallSender.error(
1290
- peer.connection,
1304
+ conn,
1291
1305
  peer.serializationFormat,
1292
1306
  relatedId,
1293
1307
  new Error(
@@ -1298,7 +1312,7 @@ var RpcSystemCallHandler = class {
1298
1312
  }
1299
1313
  if (!peer.tryClaimReconnect()) {
1300
1314
  peer.hub.systemCallSender.error(
1301
- peer.connection,
1315
+ conn,
1302
1316
  peer.serializationFormat,
1303
1317
  relatedId,
1304
1318
  new Error("TooLateToReconnect: this connection was already reconnected")
@@ -1329,7 +1343,7 @@ var RpcSystemCallHandler = class {
1329
1343
  }
1330
1344
  const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1331
1345
  const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1332
- peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1346
+ peer.hub.systemCallSender.ok(conn, peer.serializationFormat, relatedId, responseValue);
1333
1347
  }
1334
1348
  };
1335
1349
  function polymorphicPayloadError(method, got, expected) {
@@ -1512,6 +1526,8 @@ var import_core6 = require("@actuallab/core");
1512
1526
  // src/rpc-stream-sender.ts
1513
1527
  var import_core5 = require("@actuallab/core");
1514
1528
  var { warnLog: warnLog5 } = getLogs("RpcSharedStream");
1529
+ function _ignore() {
1530
+ }
1515
1531
  var DEFAULT_ACK_PERIOD = 256;
1516
1532
  var DEFAULT_ACK_ADVANCE = 128;
1517
1533
  var RTT_WINDOW_MS = 2e4;
@@ -1551,7 +1567,11 @@ var RpcStreamSender = class _RpcStreamSender {
1551
1567
  // -- Pump state --
1552
1568
  /** Next stream index that will be sent (matches .NET's `index`). */
1553
1569
  _nextIndex = 0;
1554
- _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;
1555
1575
  _started = new import_core5.PromiseSource();
1556
1576
  /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
1557
1577
  _lastAckedIndex = 0;
@@ -1658,7 +1678,7 @@ var RpcStreamSender = class _RpcStreamSender {
1658
1678
  this._sendDisconnect();
1659
1679
  return;
1660
1680
  }
1661
- } else if (this._ended) {
1681
+ } else if (this._isCompleted) {
1662
1682
  this._sendDisconnect();
1663
1683
  return;
1664
1684
  } else if (mustReset && !this.allowReconnect) {
@@ -1679,7 +1699,7 @@ var RpcStreamSender = class _RpcStreamSender {
1679
1699
  // Tells the consumer this shared stream is gone, then disposes it —
1680
1700
  // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1681
1701
  _sendDisconnect() {
1682
- const conn = this.peer.connection;
1702
+ const conn = this.peer.wireConnection;
1683
1703
  if (conn)
1684
1704
  this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1685
1705
  this.disconnect();
@@ -1693,13 +1713,13 @@ var RpcStreamSender = class _RpcStreamSender {
1693
1713
  *
1694
1714
  * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1695
1715
  * the item is considered "sent" from the pump's perspective so the replay
1696
- * buffer indexing stays consistent. If a connection is absent, the wire
1716
+ * buffer indexing stays consistent. If the peer isn't connected, the wire
1697
1717
  * message is dropped; the item will be resent from the replay buffer
1698
1718
  * after a reconnect (client sends `Ack(N, mustReset=true)`).
1699
1719
  */
1700
1720
  sendItem(item) {
1701
- if (this._ended) return;
1702
- const conn = this.peer.connection;
1721
+ if (this._isDisposed) return;
1722
+ const conn = this.peer.wireConnection;
1703
1723
  if (conn) {
1704
1724
  this.peer.hub.systemCallSender.item(
1705
1725
  conn,
@@ -1716,8 +1736,8 @@ var RpcStreamSender = class _RpcStreamSender {
1716
1736
  }
1717
1737
  /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
1718
1738
  sendBatch(items) {
1719
- if (this._ended || items.length === 0) return;
1720
- const conn = this.peer.connection;
1739
+ if (this._isDisposed || items.length === 0) return;
1740
+ const conn = this.peer.wireConnection;
1721
1741
  if (conn) {
1722
1742
  this.peer.hub.systemCallSender.batch(
1723
1743
  conn,
@@ -1729,18 +1749,21 @@ var RpcStreamSender = class _RpcStreamSender {
1729
1749
  }
1730
1750
  this._nextIndex += items.length;
1731
1751
  }
1732
- /** 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). */
1733
1756
  sendEnd(error) {
1734
- if (this._ended) return;
1735
- this._ended = true;
1736
- const conn = this.peer.connection;
1757
+ if (this._isDisposed) return;
1758
+ const index = this._nextIndex++;
1759
+ const conn = this.peer.wireConnection;
1737
1760
  if (!conn) return;
1738
1761
  const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1739
1762
  this.peer.hub.systemCallSender.end(
1740
1763
  conn,
1741
1764
  this.peer.serializationFormat,
1742
1765
  this.id.localId,
1743
- this._nextIndex,
1766
+ index,
1744
1767
  errorInfo
1745
1768
  );
1746
1769
  }
@@ -1752,13 +1775,14 @@ var RpcStreamSender = class _RpcStreamSender {
1752
1775
  */
1753
1776
  async writeFrom(source) {
1754
1777
  await this._started;
1755
- if (this._ended) return;
1778
+ if (this._isDisposed) return;
1756
1779
  const iterator = source[Symbol.asyncIterator]();
1757
1780
  this._iterator = iterator;
1758
1781
  const state = { iteratorDone: false };
1759
1782
  try {
1760
1783
  await this._run(iterator, state);
1761
1784
  } finally {
1785
+ this._isCompleted = true;
1762
1786
  if (state.iteratorDone && this._iterator === iterator) {
1763
1787
  this._iterator = null;
1764
1788
  }
@@ -1771,7 +1795,7 @@ var RpcStreamSender = class _RpcStreamSender {
1771
1795
  * With `bufferSize > ackAdvance` the local ring buffer holds more than the
1772
1796
  * in-flight window — real-time mode uses the extra space to pre-buffer.
1773
1797
  */
1774
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1798
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _isDisposed changes across awaits */
1775
1799
  async _run(iterator, state) {
1776
1800
  const buffer = new import_core5.RingBuffer(this.bufferSize + 1);
1777
1801
  const isRealTime = this.isRealTime;
@@ -1804,7 +1828,7 @@ var RpcStreamSender = class _RpcStreamSender {
1804
1828
  if (r === pending)
1805
1829
  return false;
1806
1830
  whenMovedNext = null;
1807
- if (this._ended) return false;
1831
+ if (this._isDisposed) return false;
1808
1832
  if (r.done) {
1809
1833
  buffer.pushTail(_endItem);
1810
1834
  isFullyBuffered = true;
@@ -1828,105 +1852,107 @@ var RpcStreamSender = class _RpcStreamSender {
1828
1852
  return true;
1829
1853
  }
1830
1854
  };
1831
- while (true) {
1832
- let ack = this._tryProcessAcks();
1833
- if (!ack) {
1834
- await this._waitAckReady();
1835
- if (this._ended) return;
1836
- ack = this._tryProcessAcks();
1855
+ nextAck:
1856
+ while (true) {
1857
+ let ack = this._tryProcessAcks();
1837
1858
  if (!ack) {
1838
- warnLog5?.log("Something is off: couldn't read an acknowledgement");
1839
- 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
+ }
1840
1866
  }
1841
- }
1842
- this._lastAckedIndex = ack.nextIndex;
1843
- if (isRealTime && ack.mustReset && !isFullyBuffered) {
1844
- buffer.clear();
1845
- bufferStart = this._nextIndex;
1846
- while (true) {
1847
- if (this._ended) return;
1848
- let item;
1849
- let accepted = false;
1850
- try {
1851
- const r = await readNext();
1852
- if (this._ended) return;
1853
- if (r.done) {
1854
- item = _endItem;
1855
- 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) {
1856
1889
  state.iteratorDone = true;
1857
- } else if (this.canSkipTo(r.value)) {
1858
- item = { kind: "value", value: r.value };
1859
- accepted = true;
1860
- } else {
1861
- continue;
1890
+ isFullyBuffered = true;
1891
+ item = {
1892
+ kind: "error",
1893
+ error: e instanceof Error ? e : new Error(String(e))
1894
+ };
1862
1895
  }
1863
- } catch (e) {
1864
- state.iteratorDone = true;
1865
- isFullyBuffered = true;
1866
- item = {
1867
- kind: "error",
1868
- error: e instanceof Error ? e : new Error(String(e))
1869
- };
1896
+ buffer.pushTail(item);
1897
+ fireBuffered();
1898
+ if (accepted || isFullyBuffered) break;
1870
1899
  }
1871
- buffer.pushTail(item);
1872
- fireBuffered();
1873
- if (accepted || isFullyBuffered) break;
1874
1900
  }
1875
- }
1876
- const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1877
- if (shift > 0) {
1878
- buffer.moveHead(shift);
1879
- bufferStart += shift;
1880
- }
1881
- if (this._nextIndex < bufferStart) {
1882
- if (!this._ended)
1883
- this.sendEnd(new Error("Stream position unavailable."));
1884
- return;
1885
- }
1886
- let bufferIndex = this._nextIndex - bufferStart;
1887
- const maxIndex = ack.nextIndex + this.ackAdvance;
1888
- while (this._nextIndex < maxIndex) {
1889
- if (this._ended) return;
1890
- if (isRealTime) {
1891
- while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1892
- if (!await bufferNext(true, true))
1893
- break;
1894
- }
1895
- if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1896
- await bufferNext(false, true);
1897
- }
1898
- if (ack.nextIndex > 0) {
1899
- const result = _compactBufferedUnsentSuffix(
1900
- buffer,
1901
- bufferIndex,
1902
- this.canSkipTo
1903
- );
1904
- bufferIndex = result.newIndex;
1905
- if (result.skipped > 0)
1906
- this._skipCount += result.skipped;
1907
- }
1908
- 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;
1909
1905
  }
1910
- while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1911
- const item = buffer.get(bufferIndex++);
1912
- if (item.kind === "value") {
1913
- this.sendItem(item.value);
1914
- } else if (item.kind === "end") {
1915
- if (!this._ended) this.sendEnd();
1916
- return;
1917
- } else {
1918
- if (!this._ended) this.sendEnd(item.error);
1919
- 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;
1920
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
+ }
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);
1921
1954
  }
1922
- if (this._nextIndex >= maxIndex)
1923
- break;
1924
- if (isFullyBuffered) break;
1925
- if (!buffer.hasRemainingCapacity) break;
1926
- if (!isRealTime)
1927
- await bufferNext(false, false);
1928
1955
  }
1929
- }
1930
1956
  }
1931
1957
  /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1932
1958
  get _hasPendingAck() {
@@ -1951,13 +1977,14 @@ var RpcStreamSender = class _RpcStreamSender {
1951
1977
  }
1952
1978
  return { nextIndex, mustReset };
1953
1979
  }
1954
- /** Wait until an ACK is pending (or `_ended`). */
1980
+ /** Wait until an ACK is pending (or the sender is torn down). */
1955
1981
  async _waitAckReady() {
1956
- if (this._hasPendingAck || this._ended)
1982
+ if (this._hasPendingAck || this._isDisposed)
1957
1983
  return;
1958
- this._whenAckReady = new import_core5.PromiseSource();
1959
- await this._whenAckReady;
1960
- this._whenAckReady = null;
1984
+ const whenAckReady = this._whenAckReady ??= new import_core5.PromiseSource();
1985
+ await whenAckReady;
1986
+ if (this._whenAckReady === whenAckReady)
1987
+ this._whenAckReady = null;
1961
1988
  }
1962
1989
  // One RTT sample per ack: time from sending the newest acked item to the
1963
1990
  // ack's arrival (a slight upper bound — includes the server's ack cadence).
@@ -1983,8 +2010,8 @@ var RpcStreamSender = class _RpcStreamSender {
1983
2010
  reconnect() {
1984
2011
  }
1985
2012
  disconnect() {
1986
- if (this._ended) return;
1987
- this._ended = true;
2013
+ if (this._isDisposed) return;
2014
+ this._isDisposed = true;
1988
2015
  this._abortController.abort();
1989
2016
  if (!this._started.isCompleted) {
1990
2017
  this._started.resolve();
@@ -2143,8 +2170,11 @@ var RpcStream = class _RpcStream {
2143
2170
  }
2144
2171
  }
2145
2172
  /**
2146
- * For local streams: resolves when the sender finishes pumping (after `toRef()` was called).
2147
- * 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.
2148
2178
  */
2149
2179
  get whenSent() {
2150
2180
  if (!this._whenSent)
@@ -2410,7 +2440,7 @@ var RpcStream = class _RpcStream {
2410
2440
  static _emptyGuid = "00000000-0000-0000-0000-000000000000";
2411
2441
  _sendAck(nextIndex, mustReset) {
2412
2442
  this._ackSentUpTo = nextIndex;
2413
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2443
+ const conn = this.peer.wireConnection;
2414
2444
  if (conn) {
2415
2445
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
2416
2446
  this.peer.hub.systemCallSender.ack(
@@ -2423,7 +2453,7 @@ var RpcStream = class _RpcStream {
2423
2453
  }
2424
2454
  }
2425
2455
  _sendAckEnd() {
2426
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
2456
+ const conn = this.peer.wireConnection;
2427
2457
  if (conn) {
2428
2458
  this.peer.hub.systemCallSender.ackEnd(
2429
2459
  conn,
@@ -2787,6 +2817,8 @@ var RpcPeer = class {
2787
2817
  /** Wall-clock time of the last inbound `$sys.KeepAlive`, or 0 if none yet
2788
2818
  * for the current connection. Useful for diagnostics. */
2789
2819
  _lastKeepAliveAt = 0;
2820
+ /** When the current outage began; 0 until the peer has been connected at least once. */
2821
+ _disconnectedAt = 0;
2790
2822
  /** Outbound keep-alive send period. Initialized from `hub.limits` at
2791
2823
  * construction; set directly to override on a single peer. */
2792
2824
  keepAlivePeriodMs;
@@ -2807,9 +2839,10 @@ var RpcPeer = class {
2807
2839
  }
2808
2840
  /** Fail outbound calls whose per-call {@link RpcCallTimeouts} elapsed —
2809
2841
  * the connect timeout while a call still awaits the wire, the run timeout
2810
- * once it has been sent. Mirrors .NET `RpcOutboundCallTracker.Maintain`
2811
- * (RpcCallTrackers.cs:108-207). Calls without timeouts (queries) never
2812
- * time out here. */
2842
+ * once it has been sent, and the connect timeout again for any pending call while the
2843
+ * peer is away. Mirrors .NET `RpcOutboundCallTracker.HandleDisconnect` (ConnectTimeout,
2844
+ * per outage) plus `Maintain` (RunTimeout, per connection). Calls without timeouts
2845
+ * (queries) never time out here. */
2813
2846
  _maintainOutboundCalls() {
2814
2847
  const now = Date.now();
2815
2848
  const canRunTimeout = this._isConnected && (this._lastKeepAliveAt === 0 || now - this._lastKeepAliveAt <= this.keepAliveTimeoutMs);
@@ -2818,7 +2851,11 @@ var RpcPeer = class {
2818
2851
  if (timeouts === void 0 || call.result.isCompleted)
2819
2852
  continue;
2820
2853
  let timedOut;
2821
- if (call.sentAt === 0) {
2854
+ if (!this._isConnected) {
2855
+ const since = Math.max(this._disconnectedAt, call.startedAt);
2856
+ if (now - since >= timeouts.connectTimeoutMs)
2857
+ timedOut = `connect timeout (${timeouts.connectTimeoutMs}ms)`;
2858
+ } else if (call.sentAt === 0) {
2822
2859
  if (now - call.startedAt >= timeouts.connectTimeoutMs)
2823
2860
  timedOut = `connect timeout (${timeouts.connectTimeoutMs}ms)`;
2824
2861
  } else if (canRunTimeout && now - call.sentAt >= timeouts.runTimeoutMs)
@@ -2833,6 +2870,18 @@ var RpcPeer = class {
2833
2870
  get connection() {
2834
2871
  return this._connection;
2835
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
+ }
2836
2885
  get connectionState() {
2837
2886
  return this._connectionState;
2838
2887
  }
@@ -2861,10 +2910,13 @@ var RpcPeer = class {
2861
2910
  this._connectionState = value;
2862
2911
  if (value === 3 /* Connected */) {
2863
2912
  this._isConnected = true;
2913
+ this._disconnectedAt = 0;
2864
2914
  this._armKeepAliveWatchdog();
2865
- } else if (value === 0 /* Disconnected */) {
2866
- if (previousState === 3 /* Connected */)
2915
+ } else {
2916
+ if (previousState === 3 /* Connected */) {
2867
2917
  this._isConnected = false;
2918
+ this._disconnectedAt = Date.now();
2919
+ }
2868
2920
  this._disarmKeepAliveWatchdog();
2869
2921
  }
2870
2922
  this.connectionStateChanged.trigger(value);
@@ -2889,6 +2941,8 @@ var RpcPeer = class {
2889
2941
  conn.closed.add((ev) => {
2890
2942
  if (this._connection !== conn) return;
2891
2943
  this._connection = void 0;
2944
+ if (this._isConnected)
2945
+ this._disconnectedAt = Date.now();
2892
2946
  this._isConnected = false;
2893
2947
  if (ev.reason)
2894
2948
  infoLog?.log(`'${this.ref}': Disconnected: ${ev.reason} (code=${ev.code})`);
@@ -2948,8 +3002,9 @@ var RpcPeer = class {
2948
3002
  if (this.outboundCalls.remove(callId) !== void 0) {
2949
3003
  outboundCall.result.reject((0, import_core7.cancellationError)("Call cancelled."));
2950
3004
  outboundCall.onDisconnect();
2951
- if (this._isConnected && this._connection !== void 0)
2952
- 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);
2953
3008
  }
2954
3009
  };
2955
3010
  signal.addEventListener("abort", onAbort, { once: true });
@@ -2992,9 +3047,11 @@ var RpcPeer = class {
2992
3047
  this._setConnectionState(0 /* Disconnected */);
2993
3048
  infoLog?.log(`'${this.ref}': Stopped`);
2994
3049
  }
2995
- /** 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. */
2996
3053
  _sendWireData(data) {
2997
- if (this._connection === void 0) return;
3054
+ if (!this._isConnected || this._connection === void 0) return;
2998
3055
  if (typeof data === "string") {
2999
3056
  this._connection.send(data);
3000
3057
  } else {
@@ -3077,9 +3134,10 @@ var RpcPeer = class {
3077
3134
  const ref = stream.toRef(this);
3078
3135
  const send = () => {
3079
3136
  if (call2?.isCancelled === true) return;
3080
- if (this._connection !== void 0)
3137
+ const conn = this.wireConnection;
3138
+ if (conn !== void 0)
3081
3139
  this.hub.systemCallSender.ok(
3082
- this._connection,
3140
+ conn,
3083
3141
  this.serializationFormat,
3084
3142
  relatedId,
3085
3143
  ref
@@ -3094,9 +3152,10 @@ var RpcPeer = class {
3094
3152
  } else if (call2 !== void 0) {
3095
3153
  const send = () => {
3096
3154
  if (call2.isCancelled) return;
3097
- if (this._connection !== void 0)
3155
+ const conn = this.wireConnection;
3156
+ if (conn !== void 0)
3098
3157
  this.hub.systemCallSender.ok(
3099
- this._connection,
3158
+ conn,
3100
3159
  this.serializationFormat,
3101
3160
  relatedId,
3102
3161
  result
@@ -3110,9 +3169,10 @@ var RpcPeer = class {
3110
3169
  if (call2 !== void 0) {
3111
3170
  const send = () => {
3112
3171
  if (call2.isCancelled) return;
3113
- if (this._connection !== void 0)
3172
+ const conn = this.wireConnection;
3173
+ if (conn !== void 0)
3114
3174
  this.hub.systemCallSender.error(
3115
- this._connection,
3175
+ conn,
3116
3176
  this.serializationFormat,
3117
3177
  relatedId,
3118
3178
  e
@@ -3145,8 +3205,9 @@ var RpcPeer = class {
3145
3205
  * Mirrors .NET `RpcObjectTrackers.Maintain`'s `KeepAliveTimeout` branch. */
3146
3206
  _armKeepAliveWatchdog() {
3147
3207
  this._keepAliveTimer ??= setInterval(() => {
3148
- if (this._connection !== void 0) {
3149
- 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, [
3150
3211
  ...this.remoteObjects.keys()
3151
3212
  ]);
3152
3213
  }
@@ -3294,9 +3355,26 @@ var RpcClientPeer = class extends RpcPeer {
3294
3355
  connectWith(conn, isPeerChanged = true) {
3295
3356
  const sentCalls = this.outboundCalls.getSentCalls();
3296
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
+ );
3297
3366
  this._isConnected = true;
3298
3367
  this.connectionStateChanged.trigger(3 /* Connected */);
3299
- 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
+ });
3300
3378
  }
3301
3379
  /** Kick off the reconnect loop. Idempotent — subsequent calls are
3302
3380
  * no-ops; await `peer.whenRunning` if you need the loop's completion
@@ -3614,6 +3692,7 @@ var RpcClientPeer = class extends RpcPeer {
3614
3692
  return 2 /* Changed */;
3615
3693
  }
3616
3694
  _onHandshakeReceived(handshake) {
3695
+ this._remoteHandshakeIndex = handshake.Index ?? 0;
3617
3696
  this._pendingHandshake?.resolve(handshake);
3618
3697
  }
3619
3698
  close() {