@fanboynz/network-scanner 2.0.56 → 2.0.58

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.
@@ -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
  }
@@ -431,11 +438,12 @@ async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIM
431
438
  let closedCount = 0;
432
439
  for (const page of safePagesToClose) {
433
440
  try {
434
- // Cache both page state and URL for this iteration
435
441
  const isPageClosed = page.isClosed();
436
- const pageUrl = page.url();
437
-
438
- if (!isPageClosed) {
442
+
443
+ // Re-check processing state — may have changed since safety check
444
+ const usage = pageUsageTracker.get(page);
445
+ if (!isPageClosed && !(usage && usage.isProcessing)) {
446
+ const pageUrl = page.url();
439
447
  await page.close();
440
448
  pageCreationTracker.delete(page); // Remove from tracker
441
449
  pageUsageTracker.delete(page);
@@ -509,23 +517,35 @@ async function isPageFromPreviousScan(page, forceDebug) {
509
517
  return false; // Don't close blank pages here, handled separately
510
518
  }
511
519
 
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
520
+ // Use tracker timestamp instead of expensive page.title() CDP call
521
+ const now = Date.now();
522
+ const createdAt = pageCreationTracker.get(page);
523
+ if (createdAt && now - createdAt > 120000) {
524
+ // Page is older than 2 minutes -- likely from a previous scan
525
525
  return true;
526
526
  }
527
527
 
528
- // Default: consider most content pages as potentially old in conservative mode
528
+ // Check usage tracker -- idle pages are likely old
529
+ const usage = pageUsageTracker.get(page);
530
+ if (usage && !usage.isProcessing && now - usage.lastActivity > 60000) {
531
+ return true; // Idle for over 60 seconds
532
+ }
533
+
534
+ // Fallback: only use page.title() if trackers have no data
535
+ if (!createdAt && !usage) {
536
+ try {
537
+ const title = await page.title();
538
+ if (title.includes('404') ||
539
+ title.includes('Error') ||
540
+ title.includes('Not Found') ||
541
+ title === '') {
542
+ return true;
543
+ }
544
+ } catch (titleErr) {
545
+ return true; // Can't get title = bad state
546
+ }
547
+ }
548
+
529
549
  return false; // Conservative - don't close unless we're sure
530
550
  } catch (err) {
531
551
  if (forceDebug) {
@@ -550,6 +570,35 @@ function trackPageForRealtime(page) {
550
570
  updatePageUsage(page, false); // Initialize usage tracking
551
571
  }
552
572
 
573
+ /**
574
+ * Removes a page from all tracking Maps immediately.
575
+ * Call this before page.close() to prevent stale entries during concurrent execution.
576
+ * @param {import('puppeteer').Page} page - Page to untrack
577
+ */
578
+ function untrackPage(page) {
579
+ pageCreationTracker.delete(page);
580
+ pageUsageTracker.delete(page);
581
+ }
582
+
583
+ /**
584
+ * Purges stale entries from tracking Maps (pages that were closed without cleanup)
585
+ * Should be called periodically to prevent memory leaks
586
+ */
587
+ function purgeStaleTrackers() {
588
+ for (const [page] of pageCreationTracker) {
589
+ try {
590
+ if (page.isClosed()) {
591
+ pageCreationTracker.delete(page);
592
+ pageUsageTracker.delete(page);
593
+ }
594
+ } catch (e) {
595
+ // Page reference is invalid, remove it
596
+ pageCreationTracker.delete(page);
597
+ pageUsageTracker.delete(page);
598
+ }
599
+ }
600
+ }
601
+
553
602
  /**
554
603
  * Quick browser responsiveness test for use during page setup
555
604
  * Designed to catch browser degradation between operations
@@ -559,12 +608,11 @@ function trackPageForRealtime(page) {
559
608
  */
560
609
  async function isQuicklyResponsive(browserInstance, timeout = 3000) {
561
610
  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
- ]);
611
+ await raceWithTimeout(
612
+ browserInstance.version(),
613
+ timeout,
614
+ 'Quick responsiveness timeout'
615
+ );
568
616
  return true;
569
617
  } catch (error) {
570
618
  return false;
@@ -590,20 +638,18 @@ async function testNetworkCapability(browserInstance, timeout = 10000) {
590
638
 
591
639
  try {
592
640
  // Create test page
593
- testPage = await Promise.race([
641
+ testPage = await raceWithTimeout(
594
642
  browserInstance.newPage(),
595
- new Promise((_, reject) =>
596
- setTimeout(() => reject(new Error('Test page creation timeout')), timeout)
597
- )
598
- ]);
643
+ timeout,
644
+ 'Test page creation timeout'
645
+ );
599
646
 
600
647
  // Test network operations (the critical operation that's failing)
601
- await Promise.race([
648
+ await raceWithTimeout(
602
649
  testPage.setRequestInterception(true),
603
- new Promise((_, reject) =>
604
- setTimeout(() => reject(new Error('Network.enable test timeout')), timeout)
605
- )
606
- ]);
650
+ timeout,
651
+ 'Network.enable test timeout'
652
+ );
607
653
 
608
654
  // Turn off interception and close
609
655
  await testPage.setRequestInterception(false);
@@ -662,12 +708,11 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
662
708
  }
663
709
 
664
710
  // Test 2: Try to get pages list with timeout
665
- const pages = await Promise.race([
711
+ const pages = await raceWithTimeout(
666
712
  browserInstance.pages(),
667
- new Promise((_, reject) =>
668
- setTimeout(() => reject(new Error('Browser unresponsive - pages() timeout')), timeout)
669
- )
670
- ]);
713
+ timeout,
714
+ 'Browser unresponsive - pages() timeout'
715
+ );
671
716
 
672
717
  healthResult.pageCount = pages.length;
673
718
  healthResult.responseTime = Date.now() - startTime;
@@ -677,23 +722,38 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
677
722
  healthResult.recommendations.push('Too many open pages - consider browser restart');
678
723
  }
679
724
 
680
- // Test 4: Try to create a test page to verify browser functionality
725
+ // Test 4: Create a single test page to verify both browser functionality AND network capability
681
726
  let testPage = null;
682
727
  try {
683
- testPage = await Promise.race([
728
+ testPage = await raceWithTimeout(
684
729
  browserInstance.newPage(),
685
- new Promise((_, reject) =>
686
- setTimeout(() => reject(new Error('Page creation timeout')), timeout)
687
- )
688
- ]);
730
+ timeout,
731
+ 'Page creation timeout'
732
+ );
689
733
 
690
734
  // Quick test navigation to about:blank
691
- await Promise.race([
735
+ await raceWithTimeout(
692
736
  testPage.goto('about:blank'),
693
- new Promise((_, reject) =>
694
- setTimeout(() => reject(new Error('Navigation timeout')), timeout)
695
- )
696
- ]);
737
+ timeout,
738
+ 'Navigation timeout'
739
+ );
740
+
741
+ // Test 5: Network capability test on the same page (avoids creating a second test page)
742
+ try {
743
+ await raceWithTimeout(
744
+ testPage.setRequestInterception(true),
745
+ Math.min(timeout, 5000),
746
+ 'Network.enable test timeout'
747
+ );
748
+ await testPage.setRequestInterception(false);
749
+ healthResult.networkCapable = true;
750
+ } catch (networkErr) {
751
+ healthResult.networkCapable = false;
752
+ healthResult.recommendations.push(`Network operations failing: ${networkErr.message}`);
753
+ if (networkErr.message.includes('Network.enable')) {
754
+ healthResult.criticalError = true;
755
+ }
756
+ }
697
757
 
698
758
  await testPage.close();
699
759
 
@@ -711,24 +771,13 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
711
771
  return healthResult;
712
772
  }
713
773
 
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
774
  // Test 6: Check response time performance
726
775
  if (healthResult.responseTime > 5000) {
727
776
  healthResult.recommendations.push('Slow browser response - consider restart');
728
777
  }
729
778
 
730
779
  // If all tests pass (including network capability)
731
- healthResult.healthy = networkTest.capable; // Network capability is now critical for health
780
+ healthResult.healthy = healthResult.networkCapable; // Network capability is now critical for health
732
781
 
733
782
 
734
783
  } catch (error) {
@@ -782,7 +831,12 @@ async function checkBrowserMemory(browserInstance) {
782
831
 
783
832
  // Try to get process memory info (Linux/Unix)
784
833
  try {
785
- const memInfo = execSync(`ps -p ${browserProcess.pid} -o rss=`, { encoding: 'utf8', timeout: 2000 });
834
+ const memInfo = await new Promise((resolve, reject) => {
835
+ execFile('ps', ['-p', String(browserProcess.pid), '-o', 'rss='], { encoding: 'utf8', timeout: 2000 }, (err, stdout) => {
836
+ if (err) reject(err);
837
+ else resolve(stdout);
838
+ });
839
+ });
786
840
  const memoryKB = parseInt(memInfo.trim());
787
841
 
788
842
  if (!isNaN(memoryKB)) {
@@ -811,40 +865,22 @@ async function checkBrowserMemory(browserInstance) {
811
865
  return memoryResult;
812
866
  }
813
867
 
814
- /**
868
+ /**
869
+ * Precompiled regex for critical protocol error detection (avoids array allocation per call)
870
+ */
871
+ 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/;
872
+
873
+ /**
874
+ * Precompiled regex for restart recommendation detection
875
+ */
876
+ const RESTART_RECOMMENDATION_REGEX = /restart required|High memory usage/;
877
+
878
+ /**
815
879
  * Detects critical protocol errors that require immediate browser restart
816
880
  */
817
881
  function isCriticalProtocolError(error) {
818
882
  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
- );
883
+ return CRITICAL_ERROR_REGEX.test(error.message);
848
884
  }
849
885
 
850
886
  /**
@@ -871,12 +907,11 @@ async function testBrowserConnectivity(browserInstance, timeout = 2500) {
871
907
 
872
908
  // Test 2: CDP responsiveness with version check
873
909
  try {
874
- const version = await Promise.race([
910
+ const version = await raceWithTimeout(
875
911
  browserInstance.version(),
876
- new Promise((_, reject) =>
877
- setTimeout(() => reject(new Error('CDP version check timeout')), timeout)
878
- )
879
- ]);
912
+ timeout,
913
+ 'CDP version check timeout'
914
+ );
880
915
 
881
916
  connectivityResult.cdpResponsive = true;
882
917
  connectivityResult.websocketHealthy = true; // If version works, WebSocket is healthy
@@ -936,12 +971,16 @@ async function performHealthAssessment(browserInstance, options = {}) {
936
971
  assessment.memory = await checkBrowserMemory(browserInstance);
937
972
  }
938
973
 
939
- // Combine recommendations
940
- assessment.recommendations = [
941
- ...assessment.browser.recommendations,
942
- ...(assessment.connectivity.error ? [`Connectivity issue: ${assessment.connectivity.error}`] : []),
943
- ...(assessment.memory.recommendations || [])
944
- ];
974
+ // Combine recommendations (push avoids spread operator intermediate arrays)
975
+ assessment.recommendations = assessment.browser.recommendations.slice();
976
+ if (assessment.connectivity.error) {
977
+ assessment.recommendations.push(`Connectivity issue: ${assessment.connectivity.error}`);
978
+ }
979
+ if (assessment.memory.recommendations) {
980
+ for (let i = 0; i < assessment.memory.recommendations.length; i++) {
981
+ assessment.recommendations.push(assessment.memory.recommendations[i]);
982
+ }
983
+ }
945
984
 
946
985
  // Determine overall health and restart necessity
947
986
  if (!assessment.browser.healthy) {
@@ -955,10 +994,7 @@ async function performHealthAssessment(browserInstance, options = {}) {
955
994
  assessment.needsRestart = true;
956
995
  } else if (assessment.recommendations.length > 0) {
957
996
  assessment.overall = 'degraded';
958
- assessment.needsRestart = assessment.recommendations.some(rec =>
959
- rec.includes('restart required') ||
960
- rec.includes('High memory usage')
961
- );
997
+ assessment.needsRestart = RESTART_RECOMMENDATION_REGEX.test(assessment.recommendations.join('|'));
962
998
  } else {
963
999
  assessment.overall = 'healthy';
964
1000
  assessment.needsRestart = false;
@@ -1132,17 +1168,19 @@ async function cleanupPageBeforeReload(page, forceDebug = false) {
1132
1168
  } catch(e) {}
1133
1169
  });
1134
1170
 
1135
- // Clear all timers and intervals
1171
+ // Clear recent timers and intervals (cap to last 1000 to avoid massive loops)
1136
1172
  const highestId = setTimeout(() => {}, 0);
1137
- for (let i = highestId; i >= 0; i--) {
1173
+ const clearFrom = Math.max(0, highestId - 1000);
1174
+ for (let i = highestId; i >= clearFrom; i--) {
1138
1175
  clearTimeout(i);
1139
1176
  clearInterval(i);
1140
1177
  }
1141
1178
 
1142
- // Stop all animations
1179
+ // Stop recent animations
1143
1180
  if (typeof cancelAnimationFrame !== 'undefined') {
1144
1181
  const highestRAF = requestAnimationFrame(() => {});
1145
- for (let i = highestRAF; i >= 0; i--) {
1182
+ const clearRAFFrom = Math.max(0, highestRAF - 200);
1183
+ for (let i = highestRAF; i >= clearRAFFrom; i--) {
1146
1184
  cancelAnimationFrame(i);
1147
1185
  }
1148
1186
  }
@@ -1198,5 +1236,7 @@ module.exports = {
1198
1236
  isBrowserHealthy,
1199
1237
  isCriticalProtocolError,
1200
1238
  updatePageUsage,
1201
- cleanupPageBeforeReload
1239
+ untrackPage,
1240
+ cleanupPageBeforeReload,
1241
+ purgeStaleTrackers
1202
1242
  };
package/lib/compare.js CHANGED
@@ -9,10 +9,6 @@ const path = require('path');
9
9
  */
10
10
  function loadComparisonRules(compareFilePath, forceDebug = false) {
11
11
  try {
12
- if (!fs.existsSync(compareFilePath)) {
13
- throw new Error(`Comparison file not found: ${compareFilePath}`);
14
- }
15
-
16
12
  const content = fs.readFileSync(compareFilePath, 'utf8');
17
13
  const lines = content.split('\n')
18
14
  .map(line => line.trim())