@testdriverai/runner 7.8.0-test.39 → 7.8.0-test.40

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.
Files changed (2) hide show
  1. package/lib/ably-service.js +108 -17
  2. package/package.json +1 -1
@@ -170,6 +170,7 @@ class AblyService extends EventEmitter {
170
170
  callback(null, this._ablyToken);
171
171
  },
172
172
  clientId: this._clientId,
173
+ echoMessages: false, // don't receive our own published messages
173
174
  disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
174
175
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
175
176
  logHandler: (msg) => {
@@ -230,7 +231,7 @@ class AblyService extends EventEmitter {
230
231
  level: 'info',
231
232
  message,
232
233
  timestamp: Date.now(),
233
- }).catch(() => {}); // best-effort
234
+ }).catch(() => { }); // best-effort
234
235
  });
235
236
  this._automation.on('warn', (message) => {
236
237
  if (!this._debugMode) return;
@@ -239,7 +240,7 @@ class AblyService extends EventEmitter {
239
240
  level: 'warn',
240
241
  message,
241
242
  timestamp: Date.now(),
242
- }).catch(() => {}); // best-effort
243
+ }).catch(() => { }); // best-effort
243
244
  });
244
245
  this._automation.on('error', (message) => {
245
246
  if (!this._debugMode) return;
@@ -248,21 +249,24 @@ class AblyService extends EventEmitter {
248
249
  level: 'error',
249
250
  message: typeof message === 'string' ? message : message.message || String(message),
250
251
  timestamp: Date.now(),
251
- }).catch(() => {}); // best-effort
252
+ }).catch(() => { }); // best-effort
252
253
  });
253
254
 
254
- // Forward exec streaming chunks to SDK
255
+ // Forward exec streaming chunks to SDK with rate limiting.
256
+ // Exec output can produce many chunks rapidly (e.g. verbose commands);
257
+ // throttle to avoid hitting Ably's 50 msg/sec per-connection limit.
258
+ this._execOutputLastTime = 0;
259
+ this._execOutputMinIntervalMs = 50; // 20 msg/sec max for exec.output
260
+ this._execOutputQueue = []; // queued chunks waiting to send
261
+ this._execOutputDraining = false;
262
+
255
263
  this._automation.on('exec.output', ({ requestId, chunk }) => {
256
- this._sendResponse({
257
- type: 'exec.output',
258
- requestId,
259
- chunk,
260
- timestamp: Date.now(),
261
- }).catch(() => {}); // best-effort, don't block exec
264
+ this._execOutputQueue.push({ requestId, chunk });
265
+ this._drainExecOutputQueue();
262
266
  });
263
267
 
264
- // Subscribe to commands
265
- this._sessionChannel.subscribe('command', async (msg) => {
268
+ // Subscribe to commands — save subscription ref for historyBeforeSubscribe()
269
+ this._commandSubscription = await this._sessionChannel.subscribe('command', async (msg) => {
266
270
  const message = msg.data;
267
271
  if (!message) return;
268
272
 
@@ -293,7 +297,7 @@ class AblyService extends EventEmitter {
293
297
 
294
298
  // Screenshots are now handled by automation.js (returns { s3Key })
295
299
  // No need to check type here - just pass through the result
296
-
300
+
297
301
  await this._sendResponse({
298
302
  requestId,
299
303
  type: `${type}.reply`,
@@ -407,11 +411,27 @@ class AblyService extends EventEmitter {
407
411
  Sentry.captureException(err);
408
412
  });
409
413
  }
414
+
415
+ // Detect discontinuity: channel re-attached but message continuity was lost.
416
+ // Use historyBeforeSubscribe() on each subscription to recover missed messages.
417
+ if (current === 'attached' && stateChange.resumed === false && previous) {
418
+ this.emit('log', `Ably channel [session]: DISCONTINUITY (resumed=false)${reasonMsg ? ' — ' + reasonMsg : ''}`);
419
+
420
+ Sentry.withScope((scope) => {
421
+ scope.setTag('ably.client', 'runner');
422
+ scope.setTag('ably.channel', sessionCh.name);
423
+ scope.setTag('ably.issue', 'discontinuity');
424
+ scope.setFingerprint(['ably-channel-discontinuity', 'runner']);
425
+ Sentry.captureMessage('Ably channel discontinuity (runner)', 'warning');
426
+ });
427
+
428
+ this._recoverFromDiscontinuity();
429
+ }
410
430
  });
411
431
  }
412
432
 
413
- // Subscribe to control messages
414
- this._sessionChannel.subscribe('control', async (msg) => {
433
+ // Subscribe to control messages — save subscription ref for historyBeforeSubscribe()
434
+ this._controlSubscription = await this._sessionChannel.subscribe('control', async (msg) => {
415
435
  const message = msg.data;
416
436
  if (!message) return;
417
437
 
@@ -453,6 +473,77 @@ class AblyService extends EventEmitter {
453
473
  this.emit('log', 'Published runner.ready signal');
454
474
  }
455
475
 
476
+ /**
477
+ * Drain the exec.output queue, respecting the rate limit interval.
478
+ * Coalesces queued chunks per-requestId into single messages to reduce
479
+ * message count when output arrives faster than we can send.
480
+ */
481
+ async _drainExecOutputQueue() {
482
+ if (this._execOutputDraining) return; // already draining
483
+ this._execOutputDraining = true;
484
+
485
+ try {
486
+ while (this._execOutputQueue.length > 0) {
487
+ // Rate limit: wait if needed
488
+ const now = Date.now();
489
+ const elapsed = now - this._execOutputLastTime;
490
+ if (elapsed < this._execOutputMinIntervalMs) {
491
+ await new Promise((resolve) => setTimeout(resolve, this._execOutputMinIntervalMs - elapsed));
492
+ }
493
+
494
+ // Coalesce all queued chunks for the same requestId
495
+ const batch = {};
496
+ while (this._execOutputQueue.length > 0) {
497
+ const { requestId, chunk } = this._execOutputQueue.shift();
498
+ if (!batch[requestId]) batch[requestId] = '';
499
+ batch[requestId] += chunk;
500
+ }
501
+
502
+ this._execOutputLastTime = Date.now();
503
+
504
+ // Send one message per requestId
505
+ for (const [requestId, chunk] of Object.entries(batch)) {
506
+ this._sendResponse({
507
+ type: 'exec.output',
508
+ requestId,
509
+ chunk,
510
+ timestamp: Date.now(),
511
+ }).catch(() => { }); // best-effort
512
+ }
513
+ }
514
+ } finally {
515
+ this._execOutputDraining = false;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Recover missed messages after a channel discontinuity.
521
+ * Uses historyBeforeSubscribe() on each subscription, which guarantees
522
+ * no gap between historical and live messages.
523
+ */
524
+ async _recoverFromDiscontinuity() {
525
+ const subs = [
526
+ { name: 'command', sub: this._commandSubscription },
527
+ { name: 'control', sub: this._controlSubscription },
528
+ ];
529
+ for (const { name, sub } of subs) {
530
+ if (!sub) continue;
531
+ try {
532
+ this.emit('log', `Discontinuity recovery: fetching historyBeforeSubscribe for ${name}...`);
533
+ let page = await sub.historyBeforeSubscribe({ limit: 100 });
534
+ let recovered = 0;
535
+ while (page) {
536
+ recovered += page.items.length;
537
+ page = page.hasNext() ? await page.next() : null;
538
+ }
539
+ this.emit('log', `Discontinuity recovery: found ${recovered} ${name} message(s) in gap`);
540
+ } catch (err) {
541
+ this.emit('log', `Discontinuity recovery failed for ${name}: ${err.message}`);
542
+ Sentry.captureException(err);
543
+ }
544
+ }
545
+ }
546
+
456
547
  /**
457
548
  * Send a response on the session channel.
458
549
  */
@@ -518,12 +609,12 @@ class AblyService extends EventEmitter {
518
609
 
519
610
  try {
520
611
  if (this._sessionChannel) this._sessionChannel.detach();
521
- } catch {}
612
+ } catch { }
522
613
 
523
614
  if (this._ably) {
524
615
  try {
525
616
  this._ably.close();
526
- } catch {}
617
+ } catch { }
527
618
  this._ably = null;
528
619
  }
529
620
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.8.0-test.39",
3
+ "version": "7.8.0-test.40",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {