From 5501ec299bc81b5c0ddc48d3aec47daf76b3855b Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 10:57:44 +0530 Subject: [PATCH 01/13] fix: always apply loopProtect in EmbedFrame --- client/modules/Preview/EmbedFrame.jsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 2b6ac16720..72ed1d5572 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React, { useRef, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import loopProtect from 'loop-protect'; -import { JSHINT } from 'jshint'; import decomment from 'decomment'; import { resolvePathToFile } from '../../../server/utils/filePath'; import { getConfig } from '../../utils/getConfig'; @@ -58,16 +57,32 @@ function resolveCSSLinksInString(content, files) { function jsPreprocess(jsText) { let newContent = jsText; - // check the code for js errors before sending it to strip comments - // or loops. - JSHINT(newContent); - if (JSHINT.errors.length === 0) { + // Skip loop protection if the user explicitly opts out with // noprotect + if (/\/\/\s*noprotect/.test(newContent)) { + return newContent; + } + + // Always apply loop protection to prevent infinite loops from crashing + // the browser tab. Previously, loop protection was skipped when JSHINT + // found errors, but this left users vulnerable to infinite loops in + // syntactically imperfect code (common while typing). See #3891. + try { newContent = decomment(newContent, { ignore: /\/\/\s*noprotect/g, space: true }); newContent = loopProtect(newContent); + } catch (e) { + // If decomment or loopProtect fails (e.g. due to syntax issues), + // still try to apply loop protection on the original code. + try { + newContent = loopProtect(jsText); + } catch (err) { + // If loop protection can't be applied at all, return original code. + // The sketch will still run, but without loop protection. + return jsText; + } } return newContent; } From 7a4810e62092f1a243bbaaae92d642f61af310ae Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 10:58:03 +0530 Subject: [PATCH 02/13] fix: display infinite loop errors in workspace console --- client/utils/previewEntry.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 4292e5a83b..ff8508df50 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -14,8 +14,18 @@ const htmlOffset = 12; window.objectUrls[window.location.href] = '/index.html'; const blobPath = window.location.href.split('/').pop(); window.objectPaths[blobPath] = 'index.html'; - +// Monkey-patch loopProtect to send infinite loop warnings to the in-app console window.loopProtect = loopProtect; +if (window.loopProtect && typeof window.loopProtect.hit === 'function') { + const origHit = window.loopProtect.hit; + window.loopProtect.hit = function (line) { + // Show warning in browser console and in-app console + const msg = `Exiting potential infinite loop at line ${line}. To disable loop protection: add "// noprotect" to your code`; + // This will be picked up by console-feed and sent to the parent as an error (red) + console.error(msg); + return origHit.call(this, line); + }; +} const consoleBuffer = []; const LOGWAIT = 500; From c627d35bfe8ccf27f77174f68c734aa088fe303d Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 10:58:23 +0530 Subject: [PATCH 03/13] feat: add localStorage backup for crash recovery --- client/modules/IDE/utils/localBackup.js | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 client/modules/IDE/utils/localBackup.js diff --git a/client/modules/IDE/utils/localBackup.js b/client/modules/IDE/utils/localBackup.js new file mode 100644 index 0000000000..39eecffcb2 --- /dev/null +++ b/client/modules/IDE/utils/localBackup.js @@ -0,0 +1,93 @@ +/** + * Local backup utility for crash recovery. + * + * Persists sketch file contents to localStorage on every code change, + * so users can recover their work if the browser tab crashes + * (e.g. due to an infinite loop). See issue #3891. + */ + +const BACKUP_KEY_PREFIX = 'p5js-backup-'; +const BACKUP_TIMESTAMP_SUFFIX = '-timestamp'; + +/** + * Save file contents to localStorage for a given project. + * @param {string} projectId - The project ID (or 'unsaved' for new sketches) + * @param {Array} files - Array of file objects with id, name, and content + */ +export function saveLocalBackup(projectId, files) { + if (!projectId || !files) return; + + try { + const backupData = files + .filter((f) => f.content !== undefined) + .map((f) => ({ + id: f.id, + name: f.name, + content: f.content + })); + + const key = `${BACKUP_KEY_PREFIX}${projectId}`; + localStorage.setItem(key, JSON.stringify(backupData)); + localStorage.setItem( + `${key}${BACKUP_TIMESTAMP_SUFFIX}`, + Date.now().toString() + ); + } catch (e) { + // localStorage may be full or unavailable — silently fail + // since this is a best-effort recovery mechanism + } +} + +/** + * Retrieve a local backup for a given project. + * @param {string} projectId + * @returns {{ files: Array, timestamp: number } | null} + */ +export function getLocalBackup(projectId) { + if (!projectId) return null; + + try { + const key = `${BACKUP_KEY_PREFIX}${projectId}`; + const data = localStorage.getItem(key); + const timestamp = localStorage.getItem(`${key}${BACKUP_TIMESTAMP_SUFFIX}`); + + if (!data) return null; + + return { + files: JSON.parse(data), + timestamp: parseInt(timestamp, 10) || 0 + }; + } catch (e) { + return null; + } +} + +/** + * Remove a local backup after a successful server save. + * @param {string} projectId + */ +export function clearLocalBackup(projectId) { + if (!projectId) return; + + try { + const key = `${BACKUP_KEY_PREFIX}${projectId}`; + localStorage.removeItem(key); + localStorage.removeItem(`${key}${BACKUP_TIMESTAMP_SUFFIX}`); + } catch (e) { + // ignore + } +} + +/** + * Check if a local backup exists and is newer than the given timestamp. + * @param {string} projectId + * @param {string|Date} lastServerSave - ISO string or Date of last server save + * @returns {boolean} + */ +export function hasNewerLocalBackup(projectId, lastServerSave) { + const backup = getLocalBackup(projectId); + if (!backup) return false; + + const serverTime = lastServerSave ? new Date(lastServerSave).getTime() : 0; + return backup.timestamp > serverTime; +} From 96151a444fb544f48c3f8ff74a34b585c72fbe62 Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 10:58:40 +0530 Subject: [PATCH 04/13] feat: restore localStorage backup on project load --- client/modules/IDE/pages/IDEView.jsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 81a4387b35..82afbd90a3 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -15,6 +15,12 @@ import { clearPersistedState, getProject } from '../actions/project'; +import { setUnsavedChanges } from '../actions/ide'; +import { + getLocalBackup, + clearLocalBackup, + hasNewerLocalBackup +} from '../utils/localBackup'; import { getIsUserOwner } from '../selectors/users'; import { RootPage } from '../../../components/RootPage'; import Header from '../components/Header'; @@ -135,6 +141,27 @@ const IDEView = () => { } }, [dispatch, params, project.id]); + // Check for local backup on project load (crash recovery, #3891) + useEffect(() => { + if (!project.id || !project.updatedAt) return; + + if (hasNewerLocalBackup(project.id, project.updatedAt)) { + const backup = getLocalBackup(project.id); + if (backup && backup.files) { + // Restore each file's content from the local backup + backup.files.forEach((backupFile) => { + dispatch(updateFileContent(backupFile.id, backupFile.content)); + }); + dispatch(setUnsavedChanges(true)); + // Auto-trigger a server save so the recovered content is persisted + dispatch(autosaveProject()); + } + } + // Clear the backup once the project is loaded — it has either been + // recovered or is no longer needed. + clearLocalBackup(project.id); + }, [project.id]); // eslint-disable-line + const autosaveAllowed = isUserOwner && project.id && preferences.autosave; const shouldAutosave = autosaveAllowed && ide.unsavedChanges; From 8ef525fd52683a6037f3e5f3274d171d55f0fd1b Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 10:59:03 +0530 Subject: [PATCH 05/13] fix: clear localStorage backup after successful server save --- client/modules/IDE/actions/project.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index c031881548..53bbcc0953 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -13,6 +13,7 @@ import { showErrorModal, setPreviousPath } from './ide'; +import { clearLocalBackup } from '../utils/localBackup'; import { clearState, saveState } from '../../../persistState'; const ROOT_URL = getConfig('API_URL'); @@ -164,6 +165,8 @@ export function saveProject( .then((response) => { dispatch(endSavingProject()); dispatch(setUnsavedChanges(false)); + // Clear the localStorage backup after successful server save (#3891) + clearLocalBackup(state.project.id); const { hasChanges, synchedProject } = getSynchedProject( getState(), response.data From f88d3e254c881a791d32923ad96202f932114821 Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 11:00:28 +0530 Subject: [PATCH 06/13] feat: add localStorage backup on code changes in Editor --- client/modules/IDE/components/Editor/index.jsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 474808da1c..ad4d2654de 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -72,6 +72,7 @@ import UnsavedChangesIndicator from '../UnsavedChangesIndicator'; import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; import { IconButton } from '../../../../common/IconButton'; +import { saveLocalBackup } from '../../utils/localBackup'; import contextAwareHinter from '../../../../utils/contextAwareHinter'; import showRenameDialog from '../../../../utils/showRenameDialog'; @@ -217,6 +218,13 @@ class Editor extends React.Component { this.props.setUnsavedChanges(true); this.props.hideRuntimeErrorWarning(); this.props.updateFileContent(this.props.file.id, this._cm.getValue()); + + // Save a local backup to localStorage for crash recovery (#3891). + // This ensures work is recoverable even if the tab crashes + // (e.g. from an infinite loop) before the server autosave fires. + const projectId = this.props.project?.id || 'unsaved'; + saveLocalBackup(projectId, this.props.files); + if (this.props.autorefresh && this.props.isPlaying) { this.props.clearConsole(); this.props.startSketch(); @@ -756,11 +764,15 @@ Editor.propTypes = { provideController: PropTypes.func.isRequired, t: PropTypes.func.isRequired, setSelectedFile: PropTypes.func.isRequired, - expandConsole: PropTypes.func.isRequired + expandConsole: PropTypes.func.isRequired, + project: PropTypes.shape({ + id: PropTypes.string + }) }; Editor.defaultProps = { - htmlFile: null + htmlFile: null, + project: {} }; function mapStateToProps(state) { From 5bc8071ff78e1c81736c1917a74a1a7ef9f9a8e3 Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 11:00:50 +0530 Subject: [PATCH 07/13] fix: remove isPlaying check from auto-refresh condition --- client/modules/IDE/components/Editor/index.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index ad4d2654de..8fc4284a13 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -225,7 +225,7 @@ class Editor extends React.Component { const projectId = this.props.project?.id || 'unsaved'; saveLocalBackup(projectId, this.props.files); - if (this.props.autorefresh && this.props.isPlaying) { + if (this.props.autorefresh) { this.props.clearConsole(); this.props.startSketch(); } @@ -741,7 +741,6 @@ Editor.propTypes = { setUnsavedChanges: PropTypes.func.isRequired, startSketch: PropTypes.func.isRequired, autorefresh: PropTypes.bool.isRequired, - isPlaying: PropTypes.bool.isRequired, theme: PropTypes.string.isRequired, unsavedChanges: PropTypes.bool.isRequired, files: PropTypes.arrayOf( From f36e1fae687b744e923af0dac6966f22768c6603 Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 11:01:12 +0530 Subject: [PATCH 08/13] fix: dispatch infinite loop errors to workspace console in useHandleMessageEvent --- client/modules/IDE/hooks/useHandleMessageEvent.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/modules/IDE/hooks/useHandleMessageEvent.js b/client/modules/IDE/hooks/useHandleMessageEvent.js index 5603c1d697..62c1e969d3 100644 --- a/client/modules/IDE/hooks/useHandleMessageEvent.js +++ b/client/modules/IDE/hooks/useHandleMessageEvent.js @@ -59,6 +59,7 @@ export default function useHandleMessageEvent() { if (hasInfiniteLoop) { dispatch(stopSketch()); dispatch(expandConsole()); + dispatch(dispatchConsoleEvent(decodedMessages)); return; } From d9588ad951804094758146a0995669161330b4b4 Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 11:05:46 +0530 Subject: [PATCH 09/13] fix: display infinite loop errors in workspace console --- client/utils/previewEntry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index ff8508df50..55977e4769 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -20,7 +20,7 @@ if (window.loopProtect && typeof window.loopProtect.hit === 'function') { const origHit = window.loopProtect.hit; window.loopProtect.hit = function (line) { // Show warning in browser console and in-app console - const msg = `Exiting potential infinite loop at line ${line}. To disable loop protection: add "// noprotect" to your code`; + const msg = `Exiting potential infinite loop at line ${line}.`; // This will be picked up by console-feed and sent to the parent as an error (red) console.error(msg); return origHit.call(this, line); From de446f62cac4e6178ea637788505d22893cf2ebc Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Sun, 15 Feb 2026 11:14:19 +0530 Subject: [PATCH 10/13] fix: prevent duplicate infinite loop error messages --- client/utils/previewEntry.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 55977e4769..7de47bc45c 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -23,7 +23,9 @@ if (window.loopProtect && typeof window.loopProtect.hit === 'function') { const msg = `Exiting potential infinite loop at line ${line}.`; // This will be picked up by console-feed and sent to the parent as an error (red) console.error(msg); - return origHit.call(this, line); + // Don't call origHit to prevent duplicate messages + // The loop protection still works, we just handle the messaging ourselves + return true; // Return true to indicate loop was detected }; } From cf88fc20aa33bf754754aceb16cbdb7ab5933c2f Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Mon, 16 Feb 2026 15:16:54 +0530 Subject: [PATCH 11/13] fix: detect multiple loops on same line --- client/modules/Preview/EmbedFrame.jsx | 9 +++++++++ client/utils/previewEntry.js | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 72ed1d5572..50cdcdab6c 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -63,6 +63,15 @@ function jsPreprocess(jsText) { return newContent; } + // Detect and fix multiple consecutive loops on the same line (e.g. "for(){}for(){}") + // which can bypass loop protection. Add semicolons between them so each loop + // is properly wrapped by loopProtect. See #3891. + // Match: for/while/do-while loops followed immediately by another loop + newContent = newContent.replace( + /((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})/g, + '$1; $2' + ); + // Always apply loop protection to prevent infinite loops from crashing // the browser tab. Previously, loop protection was skipped when JSHINT // found errors, but this left users vulnerable to infinite loops in diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 7de47bc45c..17d245333a 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -18,11 +18,32 @@ window.objectPaths[blobPath] = 'index.html'; window.loopProtect = loopProtect; if (window.loopProtect && typeof window.loopProtect.hit === 'function') { const origHit = window.loopProtect.hit; + let hitCount = 0; + let lastHitTime = 0; window.loopProtect.hit = function (line) { + const now = Date.now(); + // Reset counter if more than 1 second has passed + if (now - lastHitTime > 1000) { + hitCount = 0; + } + hitCount++; + lastHitTime = now; + // Show warning in browser console and in-app console const msg = `Exiting potential infinite loop at line ${line}.`; // This will be picked up by console-feed and sent to the parent as an error (red) console.error(msg); + + // If multiple loops detected quickly (e.g. two loops on same line), + // stop execution immediately to prevent hanging + if (hitCount > 1) { + console.error( + `Multiple infinite loops detected (${hitCount}). Stopping execution.` + ); + // Force stop by throwing an error that will be caught + throw new Error('Multiple infinite loops detected. Execution stopped.'); + } + // Don't call origHit to prevent duplicate messages // The loop protection still works, we just handle the messaging ourselves return true; // Return true to indicate loop was detected From 5b2594f426f4f73ddf31369cd53ad3fed2609e0d Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Mon, 16 Feb 2026 15:37:37 +0530 Subject: [PATCH 12/13] fix: improve console messages for infinite loops --- client/utils/previewEntry.js | 44 ++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 17d245333a..113532c817 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -20,28 +20,48 @@ if (window.loopProtect && typeof window.loopProtect.hit === 'function') { const origHit = window.loopProtect.hit; let hitCount = 0; let lastHitTime = 0; + let detectedLines = {}; // Track lines and their loop counts + let summaryShown = false; window.loopProtect.hit = function (line) { const now = Date.now(); - // Reset counter if more than 1 second has passed + // Reset counters if more than 1 second has passed if (now - lastHitTime > 1000) { hitCount = 0; + detectedLines = {}; + summaryShown = false; } hitCount++; lastHitTime = now; - // Show warning in browser console and in-app console - const msg = `Exiting potential infinite loop at line ${line}.`; - // This will be picked up by console-feed and sent to the parent as an error (red) - console.error(msg); + // Track loops per line + if (!detectedLines[line]) { + detectedLines[line] = 0; + } + detectedLines[line]++; - // If multiple loops detected quickly (e.g. two loops on same line), - // stop execution immediately to prevent hanging - if (hitCount > 1) { - console.error( - `Multiple infinite loops detected (${hitCount}). Stopping execution.` - ); + // Show individual message for each loop + if (detectedLines[line] === 1) { + const msg = `Exiting potential infinite loop at line ${line}.`; + console.error(msg); + } else { + // Multiple loops on same line + const msg = `Multiple infinite loops detected at line ${line} (${detectedLines[line]} loops).`; + console.error(msg); + } + + // If multiple loops detected, show summary and stop + if (hitCount > 1 && !summaryShown) { + summaryShown = true; + const linesList = Object.keys(detectedLines) + .map((l) => { + const count = detectedLines[l]; + return count > 1 ? `line ${l} (${count} loops)` : `line ${l}`; + }) + .join(', '); + const summaryMsg = `Multiple infinite loops detected (${hitCount} total) at ${linesList}. Stopping execution.`; + console.error(summaryMsg); // Force stop by throwing an error that will be caught - throw new Error('Multiple infinite loops detected. Execution stopped.'); + throw new Error(summaryMsg); } // Don't call origHit to prevent duplicate messages From 48e27608f9dbb419b8997e19f556f7bd1bf9070a Mon Sep 17 00:00:00 2001 From: Nixxx19 Date: Mon, 16 Feb 2026 16:08:46 +0530 Subject: [PATCH 13/13] fix: simplify infinite loop console messages --- client/utils/previewEntry.js | 63 +++++++++++++++++------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 113532c817..75d54e7055 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -17,51 +17,48 @@ window.objectPaths[blobPath] = 'index.html'; // Monkey-patch loopProtect to send infinite loop warnings to the in-app console window.loopProtect = loopProtect; if (window.loopProtect && typeof window.loopProtect.hit === 'function') { - const origHit = window.loopProtect.hit; let hitCount = 0; let lastHitTime = 0; - let detectedLines = {}; // Track lines and their loop counts - let summaryShown = false; - window.loopProtect.hit = function (line) { + let firstLine = null; + let stopTimeout = null; + window.loopProtect.hit = function handleLoopHit(line) { const now = Date.now(); // Reset counters if more than 1 second has passed if (now - lastHitTime > 1000) { hitCount = 0; - detectedLines = {}; - summaryShown = false; + firstLine = null; + if (stopTimeout) { + clearTimeout(stopTimeout); + stopTimeout = null; + } } hitCount++; lastHitTime = now; - // Track loops per line - if (!detectedLines[line]) { - detectedLines[line] = 0; - } - detectedLines[line]++; - - // Show individual message for each loop - if (detectedLines[line] === 1) { - const msg = `Exiting potential infinite loop at line ${line}.`; - console.error(msg); - } else { - // Multiple loops on same line - const msg = `Multiple infinite loops detected at line ${line} (${detectedLines[line]} loops).`; - console.error(msg); + // Track first line for single loop case + if (hitCount === 1) { + firstLine = line; + // Wait briefly to see if more loops are detected (minimal delay) + stopTimeout = setTimeout(() => { + if (hitCount === 1) { + // Only one loop detected - show line number + const msg = `Infinite loop detected at line ${firstLine}. Stopping execution.`; + throw new Error(msg); + } + // If hitCount > 1, another loop already threw the error + }, 30); } - // If multiple loops detected, show summary and stop - if (hitCount > 1 && !summaryShown) { - summaryShown = true; - const linesList = Object.keys(detectedLines) - .map((l) => { - const count = detectedLines[l]; - return count > 1 ? `line ${l} (${count} loops)` : `line ${l}`; - }) - .join(', '); - const summaryMsg = `Multiple infinite loops detected (${hitCount} total) at ${linesList}. Stopping execution.`; - console.error(summaryMsg); - // Force stop by throwing an error that will be caught - throw new Error(summaryMsg); + // If multiple loops detected, stop immediately without waiting + if (hitCount > 1) { + // Clear single loop timeout since we have multiple + if (stopTimeout) { + clearTimeout(stopTimeout); + stopTimeout = null; + } + // Stop immediately - multiple loops exist + const msg = 'Multiple infinite loops detected. Stopping execution.'; + throw new Error(msg); } // Don't call origHit to prevent duplicate messages