@testdriverai/runner 7.11.188-test → 7.11.189-canary

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.
@@ -25,6 +25,15 @@ const https = require('https');
25
25
  const { EventEmitter } = require('events');
26
26
  const { maskCommandPayload } = require('./mask-command');
27
27
 
28
+ // Coalescing ceiling for queued exec.output chunks. Ably's hard message limit
29
+ // is 64KB; stay well under it to leave room for the JSON envelope + escaping.
30
+ const EXEC_MESSAGE_MAX_BYTES = 48 * 1024;
31
+
32
+ // Debug log batching: one message per interval, with a cap on how many lines
33
+ // can pile up between flushes.
34
+ const RUNNER_LOG_FLUSH_MS = 250;
35
+ const RUNNER_LOG_MAX_PENDING = 200;
36
+
28
37
  /**
29
38
  * Get the local runner version from package.json.
30
39
  */
@@ -206,6 +215,19 @@ class AblyService extends EventEmitter {
206
215
  this._statsInterval = null;
207
216
  this._closed = false; // set to true in close() to suppress post-close events
208
217
  this._inFlight = new Map(); // requestId → { type, startedAt }; commands currently executing
218
+
219
+ // Ably enforces `channel.maxRate` (50 msg/sec) across *every* publisher on
220
+ // the session channel — the SDK and the API publish here too. The runner
221
+ // takes a 20 msg/sec slice and funnels all of its publishes through one
222
+ // queue so bursts of responses/logs/exec output can't blow the budget.
223
+ this._publishQueue = [];
224
+ this._publishDraining = false;
225
+ this._publishLastTime = 0;
226
+ this._publishMinIntervalMs = Number(process.env.TD_RUNNER_PUBLISH_INTERVAL_MS) || 50;
227
+
228
+ // Coalesced runner.log batching (debug mode only).
229
+ this._pendingLogs = [];
230
+ this._logFlushTimer = null;
209
231
  }
210
232
 
211
233
  /**
@@ -275,48 +297,25 @@ class AblyService extends EventEmitter {
275
297
  this.emit('log', `[stats] connection=${connState} | sandbox=${this._sandboxId} | channel=${chState}`);
276
298
  }, 10000);
277
299
 
278
- // Forward debug logs from automation to SDK (only when debug mode is enabled)
300
+ // Forward debug logs from automation to SDK (only when debug mode is enabled).
301
+ // Log lines are coalesced per level so a burst becomes one message.
279
302
  this._debugMode = false;
280
- this._automation.on('log', (message) => {
303
+ const forwardLog = (level) => (message) => {
281
304
  if (!this._debugMode) return;
282
- this._sendResponse({
283
- type: 'runner.log',
284
- level: 'info',
285
- message,
286
- timestamp: Date.now(),
287
- }).catch(() => { }); // best-effort
288
- });
289
- this._automation.on('warn', (message) => {
290
- if (!this._debugMode) return;
291
- this._sendResponse({
292
- type: 'runner.log',
293
- level: 'warn',
294
- message,
295
- timestamp: Date.now(),
296
- }).catch(() => { }); // best-effort
297
- });
298
- this._automation.on('error', (message) => {
299
- if (!this._debugMode) return;
300
- this._sendResponse({
301
- type: 'runner.log',
302
- level: 'error',
303
- message: typeof message === 'string' ? message : message.message || String(message),
304
- timestamp: Date.now(),
305
- }).catch(() => { }); // best-effort
306
- });
307
-
308
- // Forward exec streaming chunks to SDK with rate limiting.
309
- // Exec output can produce many chunks rapidly (e.g. verbose commands);
310
- // throttle to avoid hitting Ably's 50 msg/sec per-connection limit.
311
- this._execOutputLastTime = 0;
312
- this._execOutputMinIntervalMs = 50; // 20 msg/sec max for exec.output
313
- this._execOutputQueue = []; // queued chunks waiting to send
314
- this._execOutputDraining = false;
305
+ this._sendRunnerLog(level, message);
306
+ };
307
+ this._automation.on('log', forwardLog('info'));
308
+ this._automation.on('warn', forwardLog('warn'));
309
+ this._automation.on('error', forwardLog('error'));
315
310
 
316
311
  this._automation.on('exec.output', ({ requestId, chunk }) => {
317
312
  if (this._closed) return; // don't enqueue once we're tearing down
318
- this._execOutputQueue.push({ requestId, chunk });
319
- this._drainExecOutputQueue();
313
+ this._sendResponse({
314
+ type: 'exec.output',
315
+ requestId,
316
+ chunk,
317
+ timestamp: Date.now(),
318
+ }).catch(() => { }); // best-effort
320
319
  });
321
320
 
322
321
  // Subscribe to commands — save subscription ref for historyBeforeSubscribe()
@@ -560,7 +559,7 @@ class AblyService extends EventEmitter {
560
559
  remoteVersion: this._updateInfo.remoteVersion,
561
560
  };
562
561
  }
563
- await this._sessionChannel.publish('control', readyPayload);
562
+ await this._enqueuePublish('control', readyPayload);
564
563
  this.emit('log', 'Published runner.ready signal');
565
564
 
566
565
  // Re-publish runner.ready every 3s for up to 60s.
@@ -569,8 +568,7 @@ class AblyService extends EventEmitter {
569
568
  // the SDK catches at least one live runner.ready message.
570
569
  this._readyInterval = setInterval(async () => {
571
570
  try {
572
- readyPayload.timestamp = Date.now();
573
- await this._sessionChannel.publish('control', readyPayload);
571
+ await this._enqueuePublish('control', { ...readyPayload, timestamp: Date.now() });
574
572
  this.emit('log', 'Re-published runner.ready signal');
575
573
  } catch (err) {
576
574
  this.emit('log', `Failed to re-publish runner.ready: ${err.message}`);
@@ -598,62 +596,115 @@ class AblyService extends EventEmitter {
598
596
  }
599
597
 
600
598
  /**
601
- * Drain the exec.output queue, respecting the rate limit interval.
602
- * Coalesces queued chunks per-requestId into single messages to reduce
603
- * message count when output arrives faster than we can send.
599
+ * Queue a publish on the session channel. All runner publishes go through
600
+ * here so the runner never exceeds its slice of the shared channel rate.
601
+ *
602
+ * Adjacent exec.output chunks for the same requestId are coalesced while
603
+ * they sit in the queue, so a burst of output costs one message instead of
604
+ * many (bounded by Ably's 64KB message limit).
605
+ */
606
+ _enqueuePublish(eventName, message) {
607
+ if (this._closed) return Promise.resolve();
608
+
609
+ if (message.type === 'exec.output') {
610
+ const tail = this._publishQueue[this._publishQueue.length - 1];
611
+ if (
612
+ tail &&
613
+ tail.message.type === 'exec.output' &&
614
+ tail.message.requestId === message.requestId &&
615
+ tail.message.chunk.length + message.chunk.length <= EXEC_MESSAGE_MAX_BYTES
616
+ ) {
617
+ tail.message.chunk += message.chunk;
618
+ return Promise.resolve();
619
+ }
620
+ }
621
+
622
+ return new Promise((resolve) => {
623
+ this._publishQueue.push({ eventName, message, resolve });
624
+ this._drainPublishQueue();
625
+ });
626
+ }
627
+
628
+ /**
629
+ * Drain the publish queue at the configured interval.
604
630
  */
605
- async _drainExecOutputQueue() {
606
- if (this._execOutputDraining) return; // already draining
607
- this._execOutputDraining = true;
631
+ async _drainPublishQueue() {
632
+ if (this._publishDraining) return; // already draining
633
+ this._publishDraining = true;
608
634
 
609
635
  try {
610
- while (this._execOutputQueue.length > 0) {
636
+ while (this._publishQueue.length > 0) {
611
637
  // Stop draining once close() has started tearing down the channel —
612
638
  // a publish landing after detach() can make Ably silently
613
639
  // re-attach the channel, doubling channel-open/close lifecycle
614
640
  // events for what should be one clean session teardown.
615
- if (this._closed) {
616
- this._execOutputQueue.length = 0;
617
- break;
618
- }
619
-
620
- // Rate limit: wait if needed
621
- const now = Date.now();
622
- const elapsed = now - this._execOutputLastTime;
623
- if (elapsed < this._execOutputMinIntervalMs) {
624
- await new Promise((resolve) => setTimeout(resolve, this._execOutputMinIntervalMs - elapsed));
625
- }
626
-
627
- if (this._closed) {
628
- this._execOutputQueue.length = 0;
629
- break;
630
- }
641
+ if (this._closed) break;
631
642
 
632
- // Coalesce all queued chunks for the same requestId
633
- const batch = {};
634
- while (this._execOutputQueue.length > 0) {
635
- const { requestId, chunk } = this._execOutputQueue.shift();
636
- if (!batch[requestId]) batch[requestId] = '';
637
- batch[requestId] += chunk;
643
+ const elapsed = Date.now() - this._publishLastTime;
644
+ if (elapsed < this._publishMinIntervalMs) {
645
+ await new Promise((resolve) => setTimeout(resolve, this._publishMinIntervalMs - elapsed));
638
646
  }
647
+ if (this._closed) break;
639
648
 
640
- this._execOutputLastTime = Date.now();
649
+ const item = this._publishQueue.shift();
650
+ if (!item) break;
651
+ this._publishLastTime = Date.now();
641
652
 
642
- // Send one message per requestId
643
- for (const [requestId, chunk] of Object.entries(batch)) {
644
- this._sendResponse({
645
- type: 'exec.output',
646
- requestId,
647
- chunk,
648
- timestamp: Date.now(),
649
- }).catch(() => { }); // best-effort
653
+ try {
654
+ this.emit('log', `Publishing ${item.eventName}: type=${item.message.type || 'unknown'} (requestId=${item.message.requestId || 'none'})`);
655
+ await this._sessionChannel.publish(item.eventName, item.message);
656
+ } catch (err) {
657
+ this.emit('log', `Failed to publish ${item.eventName}: ${err.message}`);
650
658
  }
659
+ item.resolve();
651
660
  }
652
661
  } finally {
653
- this._execOutputDraining = false;
662
+ this._publishDraining = false;
663
+ if (this._closed) {
664
+ for (const item of this._publishQueue) item.resolve();
665
+ this._publishQueue.length = 0;
666
+ }
654
667
  }
655
668
  }
656
669
 
670
+ /**
671
+ * Queue a runner.log line. Lines are batched into a single message per
672
+ * flush interval so verbose debug output can't swamp the channel budget.
673
+ */
674
+ _sendRunnerLog(level, message) {
675
+ const text = typeof message === 'string' ? message : (message && message.message) || String(message);
676
+
677
+ if (this._pendingLogs.length >= RUNNER_LOG_MAX_PENDING) {
678
+ this._pendingLogs[this._pendingLogs.length - 1] = {
679
+ level: 'warn',
680
+ message: `[runner.log dropped — more than ${RUNNER_LOG_MAX_PENDING} lines queued]`,
681
+ timestamp: Date.now(),
682
+ };
683
+ return;
684
+ }
685
+
686
+ this._pendingLogs.push({ level, message: text, timestamp: Date.now() });
687
+
688
+ if (this._logFlushTimer) return;
689
+ this._logFlushTimer = setTimeout(() => {
690
+ this._logFlushTimer = null;
691
+ this._flushRunnerLogs();
692
+ }, RUNNER_LOG_FLUSH_MS);
693
+ if (this._logFlushTimer.unref) this._logFlushTimer.unref();
694
+ }
695
+
696
+ _flushRunnerLogs() {
697
+ if (this._closed || this._pendingLogs.length === 0) return;
698
+ const lines = this._pendingLogs;
699
+ this._pendingLogs = [];
700
+ this._sendResponse({
701
+ type: 'runner.log',
702
+ level: lines.some((l) => l.level === 'error') ? 'error' : lines[0].level,
703
+ message: lines.map((l) => l.message).join('\n'),
704
+ timestamp: Date.now(),
705
+ }).catch(() => { }); // best-effort
706
+ }
707
+
657
708
  /**
658
709
  * Recover missed messages after a channel discontinuity.
659
710
  * Uses historyBeforeSubscribe() on each subscription, which guarantees
@@ -707,13 +758,7 @@ class AblyService extends EventEmitter {
707
758
  this.emit('log', 'Warning: session channel not available');
708
759
  return;
709
760
  }
710
-
711
- try {
712
- this.emit('log', `Publishing response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
713
- await this._sessionChannel.publish('response', message);
714
- } catch (err) {
715
- this.emit('log', `Failed to publish response: ${err.message}`);
716
- }
761
+ await this._enqueuePublish('response', message);
717
762
  }
718
763
 
719
764
  /**
@@ -721,13 +766,7 @@ class AblyService extends EventEmitter {
721
766
  */
722
767
  async sendFile(message) {
723
768
  if (!this._sessionChannel) return;
724
-
725
- try {
726
- this.emit('log', `Publishing file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
727
- await this._sessionChannel.publish('file', message);
728
- } catch (err) {
729
- this.emit('log', `Failed to publish file: ${err.message}`);
730
- }
769
+ await this._enqueuePublish('file', message);
731
770
  }
732
771
 
733
772
  /**
@@ -735,13 +774,7 @@ class AblyService extends EventEmitter {
735
774
  */
736
775
  async sendControl(message) {
737
776
  if (!this._sessionChannel) return;
738
-
739
- try {
740
- this.emit('log', `Publishing control: type=${message.type || 'unknown'}`);
741
- await this._sessionChannel.publish('control', message);
742
- } catch (err) {
743
- this.emit('log', `Failed to publish control: ${err.message}`);
744
- }
777
+ await this._enqueuePublish('control', message);
745
778
  }
746
779
 
747
780
  /**
@@ -767,6 +800,14 @@ class AblyService extends EventEmitter {
767
800
  this._statsInterval = null;
768
801
  }
769
802
 
803
+ if (this._logFlushTimer) {
804
+ clearTimeout(this._logFlushTimer);
805
+ this._logFlushTimer = null;
806
+ }
807
+ this._pendingLogs = [];
808
+ for (const item of this._publishQueue) item.resolve();
809
+ this._publishQueue.length = 0;
810
+
770
811
  // Null out subscription refs so _recoverFromDiscontinuity won't replay
771
812
  // messages on a channel that is about to be torn down
772
813
  this._commandSubscription = null;
package/lib/automation.js CHANGED
@@ -37,6 +37,20 @@ if (IS_LINUX && !process.env.DISPLAY) {
37
37
  const API_ROOT = process.env.TD_API_ROOT || process.env.API_ROOT || 'https://api.testdriver.ai';
38
38
  const API_KEY = process.env.TD_API_KEY;
39
39
 
40
+ // ─── Exec output limits ──────────────────────────────────────────────────────
41
+ // Flush size keeps each streamed message well under Ably's 64KB hard limit
42
+ // (leaves room for the JSON envelope + string escaping). The total cap stops a
43
+ // runaway command (`tail -f`, an infinite loop) from streaming forever and
44
+ // starving the session channel's shared publish budget.
45
+ const EXEC_CHUNK_FLUSH_SIZE = 32 * 1024;
46
+ const EXEC_OUTPUT_MAX_BYTES = Number(process.env.TD_EXEC_MAX_OUTPUT_BYTES) || 5 * 1024 * 1024;
47
+ const EXEC_STDERR_MAX_BYTES = 256 * 1024;
48
+
49
+ function truncateText(text, maxBytes) {
50
+ if (typeof text !== 'string' || text.length <= maxBytes) return text;
51
+ return text.slice(0, maxBytes) + `\n[truncated — exceeded ${maxBytes} bytes]`;
52
+ }
53
+
40
54
  // ─── pyautogui via Python subprocess ─────────────────────────────────────────
41
55
  // Calls pyautogui functions via `python3 -c` (or `python -c` on Windows).
42
56
  // Each command spawns a short-lived Python process. The pyautogui library is
@@ -422,6 +436,7 @@ class ShellSession {
422
436
  constructor() {
423
437
  this._process = null;
424
438
  this._stderrLines = [];
439
+ this._stderrBytes = 0;
425
440
  this._lock = false;
426
441
  }
427
442
 
@@ -444,7 +459,10 @@ class ShellSession {
444
459
  });
445
460
 
446
461
  this._process.stderr.on('data', (chunk) => {
447
- this._stderrLines.push(chunk.toString());
462
+ if (this._stderrBytes >= EXEC_STDERR_MAX_BYTES) return;
463
+ const text = chunk.toString();
464
+ this._stderrLines.push(text);
465
+ this._stderrBytes += text.length;
448
466
  });
449
467
 
450
468
  this._process.on('error', (err) => {
@@ -484,6 +502,7 @@ class ShellSession {
484
502
 
485
503
  // Clear stderr buffer
486
504
  this._stderrLines = [];
505
+ this._stderrBytes = 0;
487
506
 
488
507
  // Capture the process reference now — the exit handler may set
489
508
  // this._process to null before the Promise resolves/times out.
@@ -514,6 +533,7 @@ class ShellSession {
514
533
  }, timeoutMs);
515
534
 
516
535
  const stdoutLines = [];
536
+ let stdoutBytes = 0;
517
537
 
518
538
  const onData = (chunk) => {
519
539
  const text = chunk.toString();
@@ -531,7 +551,12 @@ class ShellSession {
531
551
  });
532
552
  return;
533
553
  }
534
- stdoutLines.push(line);
554
+ // Keep the in-memory buffer bounded; a runaway command would
555
+ // otherwise grow it until the runner OOMs.
556
+ if (stdoutBytes < EXEC_OUTPUT_MAX_BYTES) {
557
+ stdoutLines.push(line);
558
+ stdoutBytes += line.length + 1;
559
+ }
535
560
  }
536
561
 
537
562
  // Stream chunk to caller if callback provided
@@ -891,15 +916,13 @@ class Automation extends EventEmitter {
891
916
  const timeout = Math.ceil((data.timeout || 300000) / 1000); // ms to seconds
892
917
  const requestId = data.requestId;
893
918
 
894
- // Buffer stdout chunks to ~32KB before emitting over Ably.
895
- // This reduces message count while keeping each message well under
896
- // Ably's 64KB limit. 32KB leaves headroom for the JSON envelope +
897
- // string escaping while halving the message count vs the previous
898
- // 16KB size, helping avoid Ably's per-channel rate limit on verbose
899
- // commands. The SDK accumulates these chunks and reconstructs the
900
- // full stdout — the final response only carries returncode + stderr.
901
- const CHUNK_FLUSH_SIZE = 32 * 1024; // 32KB
919
+ // Buffer stdout chunks before emitting over Ably. The SDK accumulates
920
+ // these chunks and reconstructs the full stdout — the final response
921
+ // only carries returncode + stderr.
902
922
  let chunkBuffer = '';
923
+ let streamedBytes = 0;
924
+ let truncated = false;
925
+
903
926
  const flushChunkBuffer = () => {
904
927
  if (chunkBuffer.length > 0) {
905
928
  this.emit('exec.output', { requestId, chunk: chunkBuffer });
@@ -907,15 +930,34 @@ class Automation extends EventEmitter {
907
930
  }
908
931
  };
909
932
 
933
+ // A runaway command (tail -f, a server log, an infinite loop) would
934
+ // otherwise stream forever and starve the session channel. Stop
935
+ // forwarding past the cap and tell the SDK once that it was cut off.
936
+ const onChunk = (text) => {
937
+ if (truncated) return;
938
+
939
+ const remaining = EXEC_OUTPUT_MAX_BYTES - streamedBytes;
940
+ if (text.length >= remaining) {
941
+ chunkBuffer += text.slice(0, Math.max(remaining, 0));
942
+ streamedBytes = EXEC_OUTPUT_MAX_BYTES;
943
+ truncated = true;
944
+ flushChunkBuffer();
945
+ this.emit('exec.output', {
946
+ requestId,
947
+ chunk: `\n[output truncated — exceeded ${EXEC_OUTPUT_MAX_BYTES} bytes]\n`,
948
+ });
949
+ return;
950
+ }
951
+
952
+ chunkBuffer += text;
953
+ streamedBytes += text.length;
954
+ if (chunkBuffer.length >= EXEC_CHUNK_FLUSH_SIZE) {
955
+ flushChunkBuffer();
956
+ }
957
+ };
958
+
910
959
  try {
911
- const result = await this._shell.run(data.command, timeout, {
912
- onChunk: (text) => {
913
- chunkBuffer += text;
914
- if (chunkBuffer.length >= CHUNK_FLUSH_SIZE) {
915
- flushChunkBuffer();
916
- }
917
- },
918
- });
960
+ const result = await this._shell.run(data.command, timeout, { onChunk });
919
961
 
920
962
  // Flush any remaining buffered output
921
963
  flushChunkBuffer();
@@ -928,7 +970,7 @@ class Automation extends EventEmitter {
928
970
  out: {
929
971
  returncode: result.returncode,
930
972
  stdout: '',
931
- stderr: result.stderr,
973
+ stderr: truncateText(result.stderr, EXEC_STDERR_MAX_BYTES),
932
974
  },
933
975
  };
934
976
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/runner",
3
- "version": "7.11.188-test",
3
+ "version": "7.11.189-canary",
4
4
  "description": "TestDriver Runner - Ably-based remote automation agent with Node.js automation",
5
5
  "main": "index.js",
6
6
  "bin": {