echoclaw-relay-agent 0.9.3 → 0.9.5

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.
@@ -30,6 +30,7 @@ export declare class RelayAgent extends EventEmitter {
30
30
  private _stopped;
31
31
  private _starting;
32
32
  private _paired;
33
+ private _rekeyRetryCount;
33
34
  private _activePairing;
34
35
  private readonly config;
35
36
  private readonly subscribers;
@@ -97,6 +97,12 @@ export class RelayAgent extends EventEmitter {
97
97
  writable: true,
98
98
  value: false
99
99
  });
100
+ Object.defineProperty(this, "_rekeyRetryCount", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: 0
105
+ });
100
106
  Object.defineProperty(this, "_activePairing", {
101
107
  enumerable: true,
102
108
  configurable: true,
@@ -398,6 +404,7 @@ export class RelayAgent extends EventEmitter {
398
404
  // If desktop is online, ECDH completes in <1s; 30s gives ample margin.
399
405
  pairing.pairAsAgent(undefined, 30000)
400
406
  .then(result => {
407
+ this._rekeyRetryCount = 0; // Reset on success
401
408
  this.onPaired(result);
402
409
  // V2: Reattach gateway callbacks after reconnect
403
410
  if (this.gatewayManager) {
@@ -411,11 +418,28 @@ export class RelayAgent extends EventEmitter {
411
418
  })
412
419
  .catch(err => {
413
420
  this._activePairing = null;
421
+ this._rekeyRetryCount++;
422
+ // Circuit breaker: after 5 consecutive failures, force full reconnect cycle
423
+ // (disconnect + restart resets the relay server connection from scratch)
424
+ if (this._rekeyRetryCount >= 5) {
425
+ this._rekeyRetryCount = 0; // Reset for next cycle
426
+ this.emit('error', new Error(`Re-key failed 5 times, forcing fresh reconnect`));
427
+ if (this.transport) {
428
+ this.transport.disconnect();
429
+ this.transport.restart();
430
+ }
431
+ return;
432
+ }
414
433
  // Re-key failed — keep old crypto intact, mark as connected
415
434
  // (data handler still works with old keys if desktop didn't re-key either)
416
435
  if (this._paired && this.sessionKey) {
417
436
  this.setStatus('connected');
418
437
  }
438
+ // If WS is still OPEN, force reconnect to trigger fresh on('open')
439
+ if (this.transport?.isConnected) {
440
+ this.transport.disconnect();
441
+ this.transport.restart();
442
+ }
419
443
  this.emit('error', err);
420
444
  });
421
445
  }
@@ -36,6 +36,7 @@ export declare class RelayClient extends EventEmitter {
36
36
  private _stopped;
37
37
  private _paired;
38
38
  private _needsRekey;
39
+ private _rekeyRetryCount;
39
40
  private _activePairing;
40
41
  private readonly config;
41
42
  private readonly sessionStore;
@@ -91,6 +91,12 @@ export class RelayClient extends EventEmitter {
91
91
  writable: true,
92
92
  value: false
93
93
  });
94
+ Object.defineProperty(this, "_rekeyRetryCount", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: 0
99
+ });
94
100
  Object.defineProperty(this, "_activePairing", {
95
101
  enumerable: true,
96
102
  configurable: true,
@@ -371,12 +377,30 @@ export class RelayClient extends EventEmitter {
371
377
  const pairing = new PairingProtocol(this.transport);
372
378
  this._activePairing = pairing;
373
379
  pairing.pairAsClient(this.pairingCode)
374
- .then(result => this.onPaired(result, 'resumed'))
380
+ .then(result => {
381
+ this._rekeyRetryCount = 0; // Reset on success
382
+ this.onPaired(result, 'resumed');
383
+ })
375
384
  .catch(err => {
376
385
  this._activePairing = null;
377
- // Re-key failed — set _needsRekey back to true so next reconnect retries.
378
- // Without this, the client gets stuck with no crypto and no recovery path.
386
+ this._rekeyRetryCount++;
387
+ // Circuit breaker: stop after 5 consecutive re-key failures
388
+ if (this._rekeyRetryCount >= 5) {
389
+ this._needsRekey = false;
390
+ this.emit('error', new Error(`Re-key failed after ${this._rekeyRetryCount} retries, giving up`));
391
+ // Force transport to close and reconnect from scratch
392
+ this.transport?.disconnect();
393
+ return;
394
+ }
395
+ // Re-key failed — set _needsRekey back to true for retry.
379
396
  this._needsRekey = true;
397
+ // If WS is still OPEN (pairing failed at protocol level, not transport level),
398
+ // _needsRekey won't be consumed until next on('open').
399
+ // Force a reconnect to trigger a fresh on('open').
400
+ if (this.transport?.isConnected) {
401
+ this.transport.disconnect();
402
+ this.transport.restart();
403
+ }
380
404
  this.emit('error', err);
381
405
  });
382
406
  }
@@ -149,7 +149,10 @@ export declare class ChatHandler {
149
149
  private _extractText;
150
150
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
151
151
  private _send;
152
- /** Flush buffered messages to desktop after relay reconnects. */
152
+ /** Sequential send queue to prevent message reordering between flush and real-time messages. */
153
+ private _sendQueue;
154
+ private _enqueueSend;
155
+ /** Flush buffered messages to desktop after relay reconnects (sequential, preserves order). */
153
156
  private _flushMessageBuffer;
154
157
  /**
155
158
  * Send a message via a captured sendBack reference.
@@ -37,6 +37,8 @@ const RUN_TTL_MS = 10 * 60 * 1000;
37
37
  const RUN_CLEANUP_INTERVAL_MS = 60 * 1000;
38
38
  /** Max buffered messages when sendBack is null (relay disconnected). */
39
39
  const MAX_BUFFERED_MESSAGES = 50;
40
+ /** Timeout for a single _sendBack call to prevent queue freeze (5 seconds). */
41
+ const SEND_TIMEOUT_MS = 5000;
40
42
  // ── ChatHandler ──────────────────────────────────────────────────
41
43
  export class ChatHandler {
42
44
  constructor(config) {
@@ -112,6 +114,13 @@ export class ChatHandler {
112
114
  writable: true,
113
115
  value: []
114
116
  });
117
+ /** Sequential send queue to prevent message reordering between flush and real-time messages. */
118
+ Object.defineProperty(this, "_sendQueue", {
119
+ enumerable: true,
120
+ configurable: true,
121
+ writable: true,
122
+ value: Promise.resolve()
123
+ });
115
124
  this.sessionKey = config?.sessionKey ?? 'main';
116
125
  this.requestTimeoutMs = config?.requestTimeoutMs ?? 30000;
117
126
  this.wsClient = new OpenClawWsClient({
@@ -569,7 +578,8 @@ export class ChatHandler {
569
578
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
570
579
  async _send(msg) {
571
580
  if (this._sendBack) {
572
- await this._sendBack(msg);
581
+ // Use send queue to guarantee ordering (flush + real-time share same queue)
582
+ await this._enqueueSend(msg);
573
583
  }
574
584
  else {
575
585
  // Buffer important messages while relay is disconnected (OpenClaw may still be responding)
@@ -580,13 +590,34 @@ export class ChatHandler {
580
590
  }
581
591
  }
582
592
  }
583
- /** Flush buffered messages to desktop after relay reconnects. */
593
+ _enqueueSend(msg) {
594
+ const p = this._sendQueue.then(async () => {
595
+ if (!this._sendBack) {
596
+ // sendBack was cleared (relay disconnected) while message was queued.
597
+ // Re-buffer important messages to avoid permanent loss.
598
+ if (msg.type === 'chat_reply' || msg.type === 'chat_delta' || msg.type === 'chat_error') {
599
+ if (this._messageBuffer.length < MAX_BUFFERED_MESSAGES) {
600
+ this._messageBuffer.push(msg);
601
+ }
602
+ }
603
+ return;
604
+ }
605
+ // Timeout protection: prevent a single hung _sendBack from freezing the entire queue.
606
+ await Promise.race([
607
+ this._sendBack(msg),
608
+ new Promise((_, reject) => setTimeout(() => reject(new Error('send timeout')), SEND_TIMEOUT_MS)),
609
+ ]);
610
+ }).catch(() => { });
611
+ this._sendQueue = p;
612
+ return p;
613
+ }
614
+ /** Flush buffered messages to desktop after relay reconnects (sequential, preserves order). */
584
615
  _flushMessageBuffer() {
585
616
  if (!this._sendBack || this._messageBuffer.length === 0)
586
617
  return;
587
618
  const buf = this._messageBuffer.splice(0);
588
619
  for (const msg of buf) {
589
- this._sendBack(msg).catch(() => { });
620
+ this._enqueueSend(msg);
590
621
  }
591
622
  }
592
623
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",