@fanboynz/network-scanner 2.0.56 → 2.0.57

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.
@@ -1,13 +1,12 @@
1
1
  name: Publish to NPM
2
2
  on:
3
- push:
4
- branches: [ main, master ]
3
+ workflow_dispatch:
5
4
 
6
5
  jobs:
7
6
  publish:
8
7
  runs-on: ubuntu-latest
9
8
  permissions:
10
- contents: write # This is key!
9
+ contents: write
11
10
 
12
11
  steps:
13
12
  - uses: actions/checkout@v4
@@ -39,4 +38,4 @@ jobs:
39
38
  - name: Push changes
40
39
  run: git push --follow-tags
41
40
  env:
42
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const { formatLogMessage, messageColors } = require('./colorize');
7
- const { execSync } = require('child_process');
7
+ const { execSync, execFile } = require('child_process');
8
8
 
9
9
  // Window cleanup delay constant
10
10
  const WINDOW_CLEANUP_DELAY_MS = 15000;
@@ -20,15 +20,35 @@ const pageCreationTracker = new Map(); // Maps page -> creation timestamp
20
20
  const pageUsageTracker = new Map(); // Maps page -> { lastActivity: timestamp, isProcessing: boolean }
21
21
  const PAGE_IDLE_THRESHOLD = 25000; // 25 seconds of inactivity before considering page safe to clean
22
22
 
23
+ /**
24
+ * Race a promise against a timeout, clearing the timer when the promise resolves/rejects.
25
+ * Prevents leaked setTimeout handles that hold closure references until they fire.
26
+ * @param {Promise} promise - The operation to race
27
+ * @param {number} ms - Timeout in milliseconds
28
+ * @param {string} msg - Error message on timeout
29
+ * @returns {Promise} Resolves/rejects with the operation result, or rejects on timeout
30
+ */
31
+ function raceWithTimeout(promise, ms, msg) {
32
+ let timeoutId;
33
+ const timeoutPromise = new Promise((_, reject) => {
34
+ timeoutId = setTimeout(() => reject(new Error(msg)), ms);
35
+ });
36
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
37
+ }
38
+
39
+ const BYTES_GB = 1073741824; // 1024^3
40
+ const BYTES_MB = 1048576; // 1024^2
41
+ const BYTES_KB = 1024;
42
+
23
43
  /**
24
44
  * Format bytes to human readable string
25
45
  * @param {number} bytes
26
46
  * @returns {string}
27
47
  */
28
48
  function formatMemory(bytes) {
29
- if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
30
- if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
31
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
49
+ if (bytes >= BYTES_GB) return `${(bytes / BYTES_GB).toFixed(1)}GB`;
50
+ if (bytes >= BYTES_MB) return `${(bytes / BYTES_MB).toFixed(1)}MB`;
51
+ if (bytes >= BYTES_KB) return `${(bytes / BYTES_KB).toFixed(1)}KB`;
32
52
  return `${bytes}B`;
33
53
  }
34
54
 
@@ -80,9 +100,6 @@ async function performGroupWindowCleanup(browserInstance, groupDescription, forc
80
100
  }
81
101
  } else {
82
102
  // Any page with actual content should be evaluated for closure
83
- // Cache page state checks to avoid multiple browser calls
84
- const isPageClosed = page.isClosed();
85
-
86
103
  if (cleanupMode === "all") {
87
104
  // Aggressive mode: close all content pages
88
105
  pagesToClose.push(page);
@@ -119,49 +136,31 @@ async function performGroupWindowCleanup(browserInstance, groupDescription, forc
119
136
  return result;
120
137
  }
121
138
 
122
- // Estimate memory usage before closing
139
+ // Estimate memory usage before closing (parallel for performance)
123
140
  let totalEstimatedMemory = 0;
124
- const pageMemoryEstimates = [];
141
+ const DEFAULT_PAGE_MEMORY = 8 * 1024 * 1024; // 8MB default estimate
125
142
 
126
- for (let i = 0; i < pagesToClose.length; i++) {
127
- const page = pagesToClose[i];
128
- let pageMemoryEstimate = 0;
129
-
143
+ const pageMemoryEstimates = await Promise.all(pagesToClose.map(async (page) => {
130
144
  try {
131
- // Cache page.isClosed() check to avoid repeated browser calls
132
- const isPageClosed = page.isClosed();
133
- if (!isPageClosed) {
134
- // Get page metrics if available
135
- const metrics = await Promise.race([
136
- page.metrics(),
137
- new Promise((_, reject) => setTimeout(() => reject(new Error('metrics timeout')), 1000))
138
- ]);
139
-
140
- // Calculate memory estimate based on page metrics
141
- if (metrics) {
142
- // Puppeteer metrics provide various memory-related values
143
- pageMemoryEstimate = (
144
- (metrics.JSHeapUsedSize || 0) + // JavaScript heap
145
- (metrics.JSHeapTotalSize || 0) * 0.1 + // Estimated overhead
146
- (metrics.Nodes || 0) * 100 + // DOM nodes (rough estimate)
147
- (metrics.JSEventListeners || 0) * 50 // Event listeners
148
- );
149
- } else {
150
- // Fallback: rough estimate based on page complexity
151
- pageMemoryEstimate = 8 * 1024 * 1024; // 8MB default estimate per page
152
- }
145
+ if (page.isClosed()) return 0;
146
+ const metrics = await raceWithTimeout(
147
+ page.metrics(), 1000, 'metrics timeout'
148
+ );
149
+ if (metrics) {
150
+ return (
151
+ (metrics.JSHeapUsedSize || 0) +
152
+ (metrics.JSHeapTotalSize || 0) * 0.1 +
153
+ (metrics.Nodes || 0) * 100 +
154
+ (metrics.JSEventListeners || 0) * 50
155
+ );
153
156
  }
157
+ return DEFAULT_PAGE_MEMORY;
154
158
  } catch (metricsErr) {
155
- // Fallback estimate if metrics fail
156
- pageMemoryEstimate = 8 * 1024 * 1024; // 8MB default
157
- if (forceDebug) {
158
- console.log(formatLogMessage('debug', `[group_window_cleanup] Could not get metrics for page ${i + 1}, using default estimate: ${metricsErr.message}`));
159
- }
159
+ return DEFAULT_PAGE_MEMORY;
160
160
  }
161
-
162
- pageMemoryEstimates.push(pageMemoryEstimate);
163
- totalEstimatedMemory += pageMemoryEstimate;
164
- }
161
+ }));
162
+
163
+ totalEstimatedMemory = pageMemoryEstimates.reduce((sum, mem) => sum + mem, 0);
165
164
 
166
165
  // Close identified old/unused pages
167
166
  const closePromises = pagesToClose.map(async (page, index) => {
@@ -188,10 +187,15 @@ async function performGroupWindowCleanup(browserInstance, groupDescription, forc
188
187
  });
189
188
 
190
189
  const closeResults = await Promise.all(closePromises);
191
- const successfulCloses = closeResults.filter(result => result.success === true).length;
192
- const actualMemoryFreed = closeResults
193
- .filter(result => result.success === true)
194
- .reduce((sum, result) => sum + (result.estimatedMemory || 0), 0);
190
+ // Single-pass count + sum (avoids 2 intermediate array allocations from .filter())
191
+ let successfulCloses = 0;
192
+ let actualMemoryFreed = 0;
193
+ for (let i = 0; i < closeResults.length; i++) {
194
+ if (closeResults[i].success === true) {
195
+ successfulCloses++;
196
+ actualMemoryFreed += closeResults[i].estimatedMemory || 0;
197
+ }
198
+ }
195
199
 
196
200
  if (forceDebug) {
197
201
  console.log(formatLogMessage('debug', `[group_window_cleanup] Closed ${successfulCloses}/${pagesToClose.length} old windows for completed group: ${groupDescription} after ${WINDOW_CLEANUP_DELAY_MS}ms delay`));
@@ -241,39 +245,33 @@ async function performGroupWindowCleanup(browserInstance, groupDescription, forc
241
245
  */
242
246
  async function isPageSafeToClose(page, forceDebug) {
243
247
  try {
244
- // Cache page.isClosed() to avoid repeated browser communication
245
- const isPageClosed = page.isClosed();
246
- if (isPageClosed) {
248
+ if (page.isClosed()) {
247
249
  return true; // Already closed
248
250
  }
249
251
 
250
252
  // EXTRA SAFETY: Never close pages that might be in injection process
253
+ const now = Date.now();
251
254
  try {
252
- // Cache page.url() for safety checks
253
255
  const pageUrl = page.url();
254
- if (pageUrl && pageUrl !== 'about:blank' && Date.now() - (pageCreationTracker.get(page) || 0) < 30000) {
256
+ if (pageUrl && pageUrl !== 'about:blank' && now - (pageCreationTracker.get(page) || 0) < 30000) {
255
257
  return false; // Don't close recently created pages (within 30 seconds)
256
258
  }
257
259
  } catch (err) { /* ignore */ }
258
260
 
259
261
  const usage = pageUsageTracker.get(page);
260
262
  if (!usage) {
261
- // No usage data - assume safe if page exists for a while
262
263
  return true;
263
264
  }
264
265
 
265
- // Check if page is actively processing
266
266
  if (usage.isProcessing) {
267
267
  if (forceDebug) {
268
- // Cache URL for debug logging
269
268
  const pageUrl = page.url();
270
269
  console.log(formatLogMessage('debug', `[realtime_cleanup] Page still processing: ${pageUrl.substring(0, 50)}...`));
271
270
  }
272
271
  return false;
273
272
  }
274
273
 
275
- // Check if page has been idle long enough
276
- const idleTime = Date.now() - usage.lastActivity;
274
+ const idleTime = now - usage.lastActivity;
277
275
  const isSafe = idleTime >= PAGE_IDLE_THRESHOLD;
278
276
 
279
277
  if (!isSafe && forceDebug) {
@@ -296,13 +294,15 @@ async function isPageSafeToClose(page, forceDebug) {
296
294
  */
297
295
  function updatePageUsage(page, isProcessing = false) {
298
296
  try {
299
- // Cache page.isClosed() to avoid repeated calls
300
- const isPageClosed = page.isClosed();
301
- if (!isPageClosed) {
302
- pageUsageTracker.set(page, {
303
- lastActivity: Date.now(),
304
- isProcessing: isProcessing
305
- });
297
+ if (!page.isClosed()) {
298
+ const existing = pageUsageTracker.get(page);
299
+ if (existing) {
300
+ // Mutate in place -- same hidden class, no GC pressure
301
+ existing.lastActivity = Date.now();
302
+ existing.isProcessing = isProcessing;
303
+ } else {
304
+ pageUsageTracker.set(page, { lastActivity: Date.now(), isProcessing: isProcessing });
305
+ }
306
306
  }
307
307
  } catch (err) {
308
308
  // Ignore errors in usage tracking
@@ -320,8 +320,6 @@ function updatePageUsage(page, isProcessing = false) {
320
320
  */
321
321
  async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIME_CLEANUP_THRESHOLD, forceDebug, totalDelay = 19000) {
322
322
  try {
323
- const allPages = await browserInstance.pages();
324
-
325
323
  // Initialize result object with consistent shape
326
324
  const result = {
327
325
  success: false,
@@ -334,13 +332,22 @@ async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIM
334
332
  error: null
335
333
  };
336
334
 
335
+ // Quick count check before waiting (avoid the expensive delay if unnecessary)
336
+ let quickPages;
337
+ try {
338
+ quickPages = await browserInstance.pages();
339
+ } catch (e) {
340
+ result.error = e.message;
341
+ return result;
342
+ }
343
+
337
344
  // Skip cleanup if we don't have enough pages to warrant it
338
- if (allPages.length <= Math.max(threshold, REALTIME_CLEANUP_MIN_PAGES)) {
345
+ if (quickPages.length <= Math.max(threshold, REALTIME_CLEANUP_MIN_PAGES)) {
339
346
  if (forceDebug) {
340
- console.log(formatLogMessage('debug', `[realtime_cleanup] Only ${allPages.length} pages open, threshold is ${threshold} - no cleanup needed`));
347
+ console.log(formatLogMessage('debug', `[realtime_cleanup] Only ${quickPages.length} pages open, threshold is ${threshold} - no cleanup needed`));
341
348
  }
342
349
  result.success = true;
343
- result.totalPages = allPages.length;
350
+ result.totalPages = quickPages.length;
344
351
  result.reason = 'below_threshold';
345
352
  return result;
346
353
  }
@@ -509,23 +516,35 @@ async function isPageFromPreviousScan(page, forceDebug) {
509
516
  return false; // Don't close blank pages here, handled separately
510
517
  }
511
518
 
512
- // Check if page has been idle (no recent navigation)
513
- // This is a heuristic - pages from previous scans are likely to be idle
514
- try {
515
- const title = await page.title();
516
- // Pages with generic titles or error states are likely old
517
- if (title.includes('404') ||
518
- title.includes('Error') ||
519
- title.includes('Not Found') ||
520
- title === '') {
521
- return true;
522
- }
523
- } catch (titleErr) {
524
- // If we can't get title, page might be in bad state
519
+ // Use tracker timestamp instead of expensive page.title() CDP call
520
+ const now = Date.now();
521
+ const createdAt = pageCreationTracker.get(page);
522
+ if (createdAt && now - createdAt > 120000) {
523
+ // Page is older than 2 minutes -- likely from a previous scan
525
524
  return true;
526
525
  }
527
526
 
528
- // Default: consider most content pages as potentially old in conservative mode
527
+ // Check usage tracker -- idle pages are likely old
528
+ const usage = pageUsageTracker.get(page);
529
+ if (usage && !usage.isProcessing && now - usage.lastActivity > 60000) {
530
+ return true; // Idle for over 60 seconds
531
+ }
532
+
533
+ // Fallback: only use page.title() if trackers have no data
534
+ if (!createdAt && !usage) {
535
+ try {
536
+ const title = await page.title();
537
+ if (title.includes('404') ||
538
+ title.includes('Error') ||
539
+ title.includes('Not Found') ||
540
+ title === '') {
541
+ return true;
542
+ }
543
+ } catch (titleErr) {
544
+ return true; // Can't get title = bad state
545
+ }
546
+ }
547
+
529
548
  return false; // Conservative - don't close unless we're sure
530
549
  } catch (err) {
531
550
  if (forceDebug) {
@@ -550,6 +569,25 @@ function trackPageForRealtime(page) {
550
569
  updatePageUsage(page, false); // Initialize usage tracking
551
570
  }
552
571
 
572
+ /**
573
+ * Purges stale entries from tracking Maps (pages that were closed without cleanup)
574
+ * Should be called periodically to prevent memory leaks
575
+ */
576
+ function purgeStaleTrackers() {
577
+ for (const [page] of pageCreationTracker) {
578
+ try {
579
+ if (page.isClosed()) {
580
+ pageCreationTracker.delete(page);
581
+ pageUsageTracker.delete(page);
582
+ }
583
+ } catch (e) {
584
+ // Page reference is invalid, remove it
585
+ pageCreationTracker.delete(page);
586
+ pageUsageTracker.delete(page);
587
+ }
588
+ }
589
+ }
590
+
553
591
  /**
554
592
  * Quick browser responsiveness test for use during page setup
555
593
  * Designed to catch browser degradation between operations
@@ -559,12 +597,11 @@ function trackPageForRealtime(page) {
559
597
  */
560
598
  async function isQuicklyResponsive(browserInstance, timeout = 3000) {
561
599
  try {
562
- await Promise.race([
563
- browserInstance.version(), // Quick responsiveness test
564
- new Promise((_, reject) =>
565
- setTimeout(() => reject(new Error('Quick responsiveness timeout')), timeout)
566
- )
567
- ]);
600
+ await raceWithTimeout(
601
+ browserInstance.version(),
602
+ timeout,
603
+ 'Quick responsiveness timeout'
604
+ );
568
605
  return true;
569
606
  } catch (error) {
570
607
  return false;
@@ -590,20 +627,18 @@ async function testNetworkCapability(browserInstance, timeout = 10000) {
590
627
 
591
628
  try {
592
629
  // Create test page
593
- testPage = await Promise.race([
630
+ testPage = await raceWithTimeout(
594
631
  browserInstance.newPage(),
595
- new Promise((_, reject) =>
596
- setTimeout(() => reject(new Error('Test page creation timeout')), timeout)
597
- )
598
- ]);
632
+ timeout,
633
+ 'Test page creation timeout'
634
+ );
599
635
 
600
636
  // Test network operations (the critical operation that's failing)
601
- await Promise.race([
637
+ await raceWithTimeout(
602
638
  testPage.setRequestInterception(true),
603
- new Promise((_, reject) =>
604
- setTimeout(() => reject(new Error('Network.enable test timeout')), timeout)
605
- )
606
- ]);
639
+ timeout,
640
+ 'Network.enable test timeout'
641
+ );
607
642
 
608
643
  // Turn off interception and close
609
644
  await testPage.setRequestInterception(false);
@@ -662,12 +697,11 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
662
697
  }
663
698
 
664
699
  // Test 2: Try to get pages list with timeout
665
- const pages = await Promise.race([
700
+ const pages = await raceWithTimeout(
666
701
  browserInstance.pages(),
667
- new Promise((_, reject) =>
668
- setTimeout(() => reject(new Error('Browser unresponsive - pages() timeout')), timeout)
669
- )
670
- ]);
702
+ timeout,
703
+ 'Browser unresponsive - pages() timeout'
704
+ );
671
705
 
672
706
  healthResult.pageCount = pages.length;
673
707
  healthResult.responseTime = Date.now() - startTime;
@@ -677,23 +711,38 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
677
711
  healthResult.recommendations.push('Too many open pages - consider browser restart');
678
712
  }
679
713
 
680
- // Test 4: Try to create a test page to verify browser functionality
714
+ // Test 4: Create a single test page to verify both browser functionality AND network capability
681
715
  let testPage = null;
682
716
  try {
683
- testPage = await Promise.race([
717
+ testPage = await raceWithTimeout(
684
718
  browserInstance.newPage(),
685
- new Promise((_, reject) =>
686
- setTimeout(() => reject(new Error('Page creation timeout')), timeout)
687
- )
688
- ]);
719
+ timeout,
720
+ 'Page creation timeout'
721
+ );
689
722
 
690
723
  // Quick test navigation to about:blank
691
- await Promise.race([
724
+ await raceWithTimeout(
692
725
  testPage.goto('about:blank'),
693
- new Promise((_, reject) =>
694
- setTimeout(() => reject(new Error('Navigation timeout')), timeout)
695
- )
696
- ]);
726
+ timeout,
727
+ 'Navigation timeout'
728
+ );
729
+
730
+ // Test 5: Network capability test on the same page (avoids creating a second test page)
731
+ try {
732
+ await raceWithTimeout(
733
+ testPage.setRequestInterception(true),
734
+ Math.min(timeout, 5000),
735
+ 'Network.enable test timeout'
736
+ );
737
+ await testPage.setRequestInterception(false);
738
+ healthResult.networkCapable = true;
739
+ } catch (networkErr) {
740
+ healthResult.networkCapable = false;
741
+ healthResult.recommendations.push(`Network operations failing: ${networkErr.message}`);
742
+ if (networkErr.message.includes('Network.enable')) {
743
+ healthResult.criticalError = true;
744
+ }
745
+ }
697
746
 
698
747
  await testPage.close();
699
748
 
@@ -711,24 +760,13 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
711
760
  return healthResult;
712
761
  }
713
762
 
714
- // Test 5: Network capability test (critical for Network.enable issues)
715
- const networkTest = await testNetworkCapability(browserInstance, Math.min(timeout, 5000));
716
- healthResult.networkCapable = networkTest.capable;
717
-
718
- if (!networkTest.capable) {
719
- healthResult.recommendations.push(`Network operations failing: ${networkTest.error}`);
720
- if (networkTest.error && networkTest.error.includes('Network.enable')) {
721
- healthResult.criticalError = true;
722
- }
723
- }
724
-
725
763
  // Test 6: Check response time performance
726
764
  if (healthResult.responseTime > 5000) {
727
765
  healthResult.recommendations.push('Slow browser response - consider restart');
728
766
  }
729
767
 
730
768
  // If all tests pass (including network capability)
731
- healthResult.healthy = networkTest.capable; // Network capability is now critical for health
769
+ healthResult.healthy = healthResult.networkCapable; // Network capability is now critical for health
732
770
 
733
771
 
734
772
  } catch (error) {
@@ -782,7 +820,12 @@ async function checkBrowserMemory(browserInstance) {
782
820
 
783
821
  // Try to get process memory info (Linux/Unix)
784
822
  try {
785
- const memInfo = execSync(`ps -p ${browserProcess.pid} -o rss=`, { encoding: 'utf8', timeout: 2000 });
823
+ const memInfo = await new Promise((resolve, reject) => {
824
+ execFile('ps', ['-p', String(browserProcess.pid), '-o', 'rss='], { encoding: 'utf8', timeout: 2000 }, (err, stdout) => {
825
+ if (err) reject(err);
826
+ else resolve(stdout);
827
+ });
828
+ });
786
829
  const memoryKB = parseInt(memInfo.trim());
787
830
 
788
831
  if (!isNaN(memoryKB)) {
@@ -811,40 +854,22 @@ async function checkBrowserMemory(browserInstance) {
811
854
  return memoryResult;
812
855
  }
813
856
 
814
- /**
857
+ /**
858
+ * Precompiled regex for critical protocol error detection (avoids array allocation per call)
859
+ */
860
+ const CRITICAL_ERROR_REGEX = /Runtime\.callFunctionOn timed out|Protocol error|Target closed|Session closed|Connection closed|Browser has been closed|Runtime\.evaluate timed out|WebSocket is not open|WebSocket connection lost|Connection terminated|Network service crashed|Browser disconnected|CDP session invalid|Browser process exited|Navigation timeout of|Page crashed|Renderer process crashed|Network\.enable timed out|Network\.disable timed out|Network service not available/;
861
+
862
+ /**
863
+ * Precompiled regex for restart recommendation detection
864
+ */
865
+ const RESTART_RECOMMENDATION_REGEX = /restart required|High memory usage/;
866
+
867
+ /**
815
868
  * Detects critical protocol errors that require immediate browser restart
816
869
  */
817
870
  function isCriticalProtocolError(error) {
818
871
  if (!error || !error.message) return false;
819
-
820
- const criticalErrors = [
821
- 'Runtime.callFunctionOn timed out',
822
- 'Protocol error',
823
- 'Target closed',
824
- 'Session closed',
825
- 'Connection closed',
826
- 'Browser has been closed',
827
- 'Runtime.evaluate timed out',
828
- // New Puppeteer 23.x critical errors
829
- 'WebSocket is not open',
830
- 'WebSocket connection lost',
831
- 'Connection terminated',
832
- 'Network service crashed',
833
- 'Browser disconnected',
834
- 'CDP session invalid',
835
- 'Browser process exited',
836
- 'Navigation timeout of',
837
- 'Page crashed',
838
- 'Renderer process crashed',
839
- // Network-specific critical errors
840
- 'Network.enable timed out',
841
- 'Network.disable timed out',
842
- 'Network service not available'
843
- ];
844
-
845
- return criticalErrors.some(criticalError =>
846
- error.message.includes(criticalError)
847
- );
872
+ return CRITICAL_ERROR_REGEX.test(error.message);
848
873
  }
849
874
 
850
875
  /**
@@ -871,12 +896,11 @@ async function testBrowserConnectivity(browserInstance, timeout = 2500) {
871
896
 
872
897
  // Test 2: CDP responsiveness with version check
873
898
  try {
874
- const version = await Promise.race([
899
+ const version = await raceWithTimeout(
875
900
  browserInstance.version(),
876
- new Promise((_, reject) =>
877
- setTimeout(() => reject(new Error('CDP version check timeout')), timeout)
878
- )
879
- ]);
901
+ timeout,
902
+ 'CDP version check timeout'
903
+ );
880
904
 
881
905
  connectivityResult.cdpResponsive = true;
882
906
  connectivityResult.websocketHealthy = true; // If version works, WebSocket is healthy
@@ -936,12 +960,16 @@ async function performHealthAssessment(browserInstance, options = {}) {
936
960
  assessment.memory = await checkBrowserMemory(browserInstance);
937
961
  }
938
962
 
939
- // Combine recommendations
940
- assessment.recommendations = [
941
- ...assessment.browser.recommendations,
942
- ...(assessment.connectivity.error ? [`Connectivity issue: ${assessment.connectivity.error}`] : []),
943
- ...(assessment.memory.recommendations || [])
944
- ];
963
+ // Combine recommendations (push avoids spread operator intermediate arrays)
964
+ assessment.recommendations = assessment.browser.recommendations.slice();
965
+ if (assessment.connectivity.error) {
966
+ assessment.recommendations.push(`Connectivity issue: ${assessment.connectivity.error}`);
967
+ }
968
+ if (assessment.memory.recommendations) {
969
+ for (let i = 0; i < assessment.memory.recommendations.length; i++) {
970
+ assessment.recommendations.push(assessment.memory.recommendations[i]);
971
+ }
972
+ }
945
973
 
946
974
  // Determine overall health and restart necessity
947
975
  if (!assessment.browser.healthy) {
@@ -955,10 +983,7 @@ async function performHealthAssessment(browserInstance, options = {}) {
955
983
  assessment.needsRestart = true;
956
984
  } else if (assessment.recommendations.length > 0) {
957
985
  assessment.overall = 'degraded';
958
- assessment.needsRestart = assessment.recommendations.some(rec =>
959
- rec.includes('restart required') ||
960
- rec.includes('High memory usage')
961
- );
986
+ assessment.needsRestart = RESTART_RECOMMENDATION_REGEX.test(assessment.recommendations.join('|'));
962
987
  } else {
963
988
  assessment.overall = 'healthy';
964
989
  assessment.needsRestart = false;
@@ -1132,17 +1157,19 @@ async function cleanupPageBeforeReload(page, forceDebug = false) {
1132
1157
  } catch(e) {}
1133
1158
  });
1134
1159
 
1135
- // Clear all timers and intervals
1160
+ // Clear recent timers and intervals (cap to last 1000 to avoid massive loops)
1136
1161
  const highestId = setTimeout(() => {}, 0);
1137
- for (let i = highestId; i >= 0; i--) {
1162
+ const clearFrom = Math.max(0, highestId - 1000);
1163
+ for (let i = highestId; i >= clearFrom; i--) {
1138
1164
  clearTimeout(i);
1139
1165
  clearInterval(i);
1140
1166
  }
1141
1167
 
1142
- // Stop all animations
1168
+ // Stop recent animations
1143
1169
  if (typeof cancelAnimationFrame !== 'undefined') {
1144
1170
  const highestRAF = requestAnimationFrame(() => {});
1145
- for (let i = highestRAF; i >= 0; i--) {
1171
+ const clearRAFFrom = Math.max(0, highestRAF - 200);
1172
+ for (let i = highestRAF; i >= clearRAFFrom; i--) {
1146
1173
  cancelAnimationFrame(i);
1147
1174
  }
1148
1175
  }
@@ -1198,5 +1225,6 @@ module.exports = {
1198
1225
  isBrowserHealthy,
1199
1226
  isCriticalProtocolError,
1200
1227
  updatePageUsage,
1201
- cleanupPageBeforeReload
1228
+ cleanupPageBeforeReload,
1229
+ purgeStaleTrackers
1202
1230
  };