From a897e5160b9c9462373c40dfd40221390aee72f0 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Apr 2026 07:33:48 +0530 Subject: [PATCH] fix: prevent OOM crash from file indexing on large projects Add two safeguards to prevent Electron crashes when opening projects with many large or binary files: 1. Content-based binary detection: check first 8KB for null bytes (same heuristic as git/grep) to skip binary files that lack a recognized extension. Previously only extension-based detection was used, missing extensionless binary files entirely. 2. Configurable cache size limit (default 1GB via maxFileCacheSizeMB preference): stops indexing and shows a warning toast when the limit is reached, disabling Find in Files for that project. The V8 heap limit is ~4GB per process and the web worker has its own heap, so 1GB leaves safe headroom. --- docs/API-Reference/view/PanelView.md | 263 ++++++++++---------- docs/API-Reference/view/WorkspaceManager.md | 5 +- src/nls/root/strings.js | 3 + src/search/FindInFiles.js | 48 +++- src/search/FindUtils.js | 33 ++- src/worker/file-Indexing-Worker-thread.js | 51 +++- 6 files changed, 266 insertions(+), 137 deletions(-) diff --git a/docs/API-Reference/view/PanelView.md b/docs/API-Reference/view/PanelView.md index 0d92aa343..f2a78088f 100644 --- a/docs/API-Reference/view/PanelView.md +++ b/docs/API-Reference/view/PanelView.md @@ -3,13 +3,144 @@ const PanelView = brackets.getModule("view/PanelView") ``` + + +## \_panelMap : Object.<string, Panel> +Maps panel ID to Panel instance + +**Kind**: global variable + + +## \_$container : jQueryObject +The single container wrapping all bottom panels + +**Kind**: global variable + + +## \_$tabBar : jQueryObject +The tab bar inside the container + +**Kind**: global variable + + +## \_$tabsOverflow : jQueryObject +Scrollable area holding the tab elements + +**Kind**: global variable + + +## \_openIds : Array.<string> +Ordered list of currently open (tabbed) panel IDs + +**Kind**: global variable + + +## \_activeId : string \| null +The panel ID of the currently visible (active) tab + +**Kind**: global variable + + +## \_isMaximized : boolean +Whether the bottom panel is currently maximized + +**Kind**: global variable + + +## \_preMaximizeHeight : number \| null +The panel height before maximize, for restore + +**Kind**: global variable + + +## \_$editorHolder : jQueryObject +The editor holder element, passed from WorkspaceManager + +**Kind**: global variable + + +## \_recomputeLayout : function +recomputeLayout callback from WorkspaceManager + +**Kind**: global variable + + +## \_defaultPanelId : string \| null +The default/quick-access panel ID + +**Kind**: global variable + + +## \_$addBtn : jQueryObject +The "+" button inside the tab overflow area + +**Kind**: global variable + + +## \_$overflowBtn : jQueryObject +Overflow dropdown button + +**Kind**: global variable + + +## \_overflowDropdown : DropdownButton.DropdownButton +**Kind**: global variable + + +## EVENT\_PANEL\_HIDDEN : string +Event when panel is hidden + +**Kind**: global constant + + +## EVENT\_PANEL\_SHOWN : string +Event when panel is shown + +**Kind**: global constant + + +## PANEL\_TYPE\_BOTTOM\_PANEL : string +type for bottom panel + +**Kind**: global constant + + +## MAXIMIZE\_THRESHOLD : number +Pixel threshold for detecting near-maximize state during resize. +If the editor holder height is within this many pixels of zero, the +panel is treated as maximized. Keeps the maximize icon responsive +during drag without being overly sensitive. + +**Kind**: global constant + + +## MIN\_PANEL\_HEIGHT : number +Minimum panel height (matches Resizer minSize) used as a floor +when computing a sensible restore height. + +**Kind**: global constant + + +## PREF\_BOTTOM\_PANEL\_MAXIMIZED +Preference key for persisting the maximize state across reloads. + +**Kind**: global constant -## Panel -**Kind**: global class +## Panel($panel, id, [title], [options]) +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| $panel | jQueryObject | | +| id | string | | +| [title] | string | | +| [options] | Object | | +| [options.iconClass] | string | FontAwesome class string (e.g. "fa-solid fa-terminal"). | +| [options.iconSvg] | string | Path to an SVG icon (e.g. "styles/images/icon.svg"). | + -* [Panel](#Panel) - * [new Panel($panel, id, [title])](#new_Panel_new) +* [Panel($panel, id, [title], [options])](#Panel) * [.$panel](#Panel+$panel) : jQueryObject * [.isVisible()](#Panel+isVisible) ⇒ boolean * [.registerCanBeShownHandler(canShowHandlerFn)](#Panel+registerCanBeShownHandler) ⇒ boolean @@ -24,18 +155,6 @@ const PanelView = brackets.getModule("view/PanelView") * [.destroy()](#Panel+destroy) * [.getPanelType()](#Panel+getPanelType) ⇒ string - - -### new Panel($panel, id, [title]) -Represents a panel below the editor area (a child of ".content"). - - -| Param | Type | Description | -| --- | --- | --- | -| $panel | jQueryObject | The entire panel, including any chrome, already in the DOM. | -| id | string | Unique panel identifier. | -| [title] | string | Optional display title for the tab bar. | - ### panel.$panel : jQueryObject @@ -144,118 +263,6 @@ After calling this, the Panel instance should not be reused. gets the Panel's type **Kind**: instance method of [Panel](#Panel) - - -## \_panelMap : Object.<string, Panel> -Maps panel ID to Panel instance - -**Kind**: global variable - - -## \_$container : jQueryObject -The single container wrapping all bottom panels - -**Kind**: global variable - - -## \_$tabBar : jQueryObject -The tab bar inside the container - -**Kind**: global variable - - -## \_$tabsOverflow : jQueryObject -Scrollable area holding the tab elements - -**Kind**: global variable - - -## \_openIds : Array.<string> -Ordered list of currently open (tabbed) panel IDs - -**Kind**: global variable - - -## \_activeId : string \| null -The panel ID of the currently visible (active) tab - -**Kind**: global variable - - -## \_isMaximized : boolean -Whether the bottom panel is currently maximized - -**Kind**: global variable - - -## \_preMaximizeHeight : number \| null -The panel height before maximize, for restore - -**Kind**: global variable - - -## \_$editorHolder : jQueryObject -The editor holder element, passed from WorkspaceManager - -**Kind**: global variable - - -## \_recomputeLayout : function -recomputeLayout callback from WorkspaceManager - -**Kind**: global variable - - -## \_defaultPanelId : string \| null -The default/quick-access panel ID - -**Kind**: global variable - - -## \_$addBtn : jQueryObject -The "+" button inside the tab overflow area - -**Kind**: global variable - - -## EVENT\_PANEL\_HIDDEN : string -Event when panel is hidden - -**Kind**: global constant - - -## EVENT\_PANEL\_SHOWN : string -Event when panel is shown - -**Kind**: global constant - - -## PANEL\_TYPE\_BOTTOM\_PANEL : string -type for bottom panel - -**Kind**: global constant - - -## MAXIMIZE\_THRESHOLD : number -Pixel threshold for detecting near-maximize state during resize. -If the editor holder height is within this many pixels of zero, the -panel is treated as maximized. Keeps the maximize icon responsive -during drag without being overly sensitive. - -**Kind**: global constant - - -## MIN\_PANEL\_HEIGHT : number -Minimum panel height (matches Resizer minSize) used as a floor -when computing a sensible restore height. - -**Kind**: global constant - - -## PREF\_BOTTOM\_PANEL\_MAXIMIZED -Preference key for persisting the maximize state across reloads. - -**Kind**: global constant ## init($container, $tabBar, $tabsOverflow, $editorHolder, recomputeLayoutFn, defaultPanelId) diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md index f99f7f80a..539e7ec2c 100644 --- a/docs/API-Reference/view/WorkspaceManager.md +++ b/docs/API-Reference/view/WorkspaceManager.md @@ -28,7 +28,7 @@ Events: * [.EVENT_WORKSPACE_UPDATE_LAYOUT](#module_view/WorkspaceManager..EVENT_WORKSPACE_UPDATE_LAYOUT) * [.EVENT_WORKSPACE_PANEL_SHOWN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_SHOWN) * [.EVENT_WORKSPACE_PANEL_HIDDEN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_HIDDEN) - * [.createBottomPanel(id, $panel, [minSize], [title])](#module_view/WorkspaceManager..createBottomPanel) ⇒ Panel + * [.createBottomPanel(id, $panel, [minSize], [title], [options])](#module_view/WorkspaceManager..createBottomPanel) ⇒ Panel * [.destroyBottomPanel(id)](#module_view/WorkspaceManager..destroyBottomPanel) * [.createPluginPanel(id, $panel, [minSize], $toolbarIcon, [initialSize])](#module_view/WorkspaceManager..createPluginPanel) ⇒ Panel * [.getAllPanelIDs()](#module_view/WorkspaceManager..getAllPanelIDs) ⇒ Array @@ -89,7 +89,7 @@ Event triggered when a panel is hidden. **Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) -### view/WorkspaceManager.createBottomPanel(id, $panel, [minSize], [title]) ⇒ Panel +### view/WorkspaceManager.createBottomPanel(id, $panel, [minSize], [title], [options]) ⇒ Panel Creates a new resizable panel beneath the editor area and above the status bar footer. Panel is initially invisible. The panel's size & visibility are automatically saved & restored as a view-state preference. @@ -101,6 +101,7 @@ The panel's size & visibility are automatically saved & restored as a view-state | $panel | jQueryObject | DOM content to use as the panel. Need not be in the document yet. Must have an id attribute, for use as a preferences key. | | [minSize] | number | @deprecated No longer used. Pass `undefined`. | | [title] | string | Display title shown in the bottom panel tab bar. | +| [options] | Object | Optional settings: - {string} iconClass FontAwesome class string (e.g. "fa-solid fa-terminal"). - {string} iconSvg Path to an SVG icon (e.g. "styles/images/icon.svg"). | diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 0b8ac5a1b..4a75bee35 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -639,6 +639,9 @@ define({ "FIND_IN_FILES_SEARCHING": "Searching Files\u2026", "FIND_IN_FILES_SEARCHING_IN": "In {0}", "FIND_IN_FILES_INDEXING_PROGRESS": "Indexing {0} of {1} files for Instant Search\u2026", + "FIND_IN_FILES_CACHE_LIMIT_TITLE": "File Indexing Suspended", + "FIND_IN_FILES_CACHE_LIMIT_MSG": "The project file cache has reached {0} MB. Indexing has been suspended to prevent high memory usage. Find in Files is disabled for this project. To reduce indexing size, add folders with large or generated files to .gitignore.", + "DESCRIPTION_MAX_FILE_CACHE_SIZE_MB": "Maximum size in MB for the instant search file cache.", "REPLACE_IN_FILES_ERRORS_TITLE": "Replace Errors", "REPLACE_IN_FILES_ERRORS": "The following files weren't modified because they changed after the search or couldn't be written.", diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 987f71138..f659e57f1 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -43,10 +43,18 @@ define(function (require, exports, module) { PerfUtils = require("utils/PerfUtils"), FindUtils = require("search/FindUtils"), Metrics = require("utils/Metrics"), - IndexingWorker = require("worker/IndexingWorker"); + IndexingWorker = require("worker/IndexingWorker"), + PreferencesManager = require("preferences/PreferencesManager"), + NotificationUI = require("widgets/NotificationUI"), + Strings = require("strings"); let projectIndexingComplete = false; + var MAX_CACHE_SIZE_MB_PREF = "maxFileCacheSizeMB"; + PreferencesManager.definePreference(MAX_CACHE_SIZE_MB_PREF, "number", 1024, { + description: Strings.DESCRIPTION_MAX_FILE_CACHE_SIZE_MB + }); + IndexingWorker.loadScriptInWorker(`${Phoenix.baseURL}search/worker/search.js`); IndexingWorker.on(IndexingWorker.EVENT_CRAWL_COMPLETE, function (_evt, params) { @@ -125,13 +133,40 @@ define(function (require, exports, module) { let numFiles = data.numFilesCached, cacheSize = data.cacheSizeBytes, crawlTime = data.crawlTimeMs; - projectIndexingComplete = true; console.log(`file indexing worker cache complete: ${numFiles} files, size: ${cacheSize} B in ${crawlTime}ms`); if (/\/test\/SpecRunner\.html$/.test(window.location.pathname)) { // Ignore the event in the SpecRunner window return; } + if (data.aborted) { + // Cache limit reached — Find in Files is disabled for this project + projectIndexingComplete = false; + FindUtils.setIndexingSuspended(true); + var cacheSizeMB = Math.round(cacheSize / (1024 * 1024)); + var message = StringUtils.format(Strings.FIND_IN_FILES_CACHE_LIMIT_MSG, cacheSizeMB); + var $content = $('
' + message + + '
' + + '' + + '
'); + var notification = NotificationUI.createToastFromTemplate( + Strings.FIND_IN_FILES_CACHE_LIMIT_TITLE, + $content[0], + { + dismissOnClick: false, + toastStyle: NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.WARNING + } + ); + $content.find('.btn-cache-ok').on('click', function () { + notification.close(); + }); + FindUtils.notifyIndexingFinished(); + Metrics.valueEvent(Metrics.EVENT_TYPE.SEARCH, "indexing", "aborted", 1); + return; + } + + projectIndexingComplete = true; + var projectRoot = ProjectManager.getProjectRoot(), projectName = projectRoot ? projectRoot.name : null; @@ -509,6 +544,10 @@ define(function (require, exports, module) { * Will be null if the query is invalid. */ function _doSearch(queryInfo, candidateFilesPromise, filter, scope) { + if (FindUtils.isIndexingSuspended()) { + return new $.Deferred().resolve(ZERO_FILES_TO_SEARCH).promise(); + } + searchModel.filter = filter; var queryResult = searchModel.setQueryInfo(queryInfo); @@ -958,6 +997,7 @@ define(function (require, exports, module) { */ var _initCache = function () { projectIndexingComplete = false; + FindUtils.setIndexingSuspended(false); function filter(file) { return _isReadableFileType(file.fullPath); } @@ -977,6 +1017,10 @@ define(function (require, exports, module) { return entry.fullPath; }); IndexingWorker.execPeer("initCache", files); + var maxMB = PreferencesManager.get(MAX_CACHE_SIZE_MB_PREF); + IndexingWorker.execPeer("setCacheConfig", { + maxCacheSizeBytes: maxMB * 1024 * 1024 + }); }); _searchScopeChanged(); }; diff --git a/src/search/FindUtils.js b/src/search/FindUtils.js index 2cf5a2b70..32368f0ff 100644 --- a/src/search/FindUtils.js +++ b/src/search/FindUtils.js @@ -43,6 +43,14 @@ define(function (require, exports, module) { */ let instantSearchDisabled = false; + /** + * if indexing was suspended due to cache size limit + * + * @private + * @type {boolean} + */ + let indexingSuspended = false; + /** * if indexing in progress, defaults to false * @@ -422,6 +430,25 @@ define(function (require, exports, module) { return instantSearchDisabled; } + /** + * Set whether indexing has been suspended due to cache size limit + * + * @param {boolean} suspended true if indexing was suspended + */ + function setIndexingSuspended(suspended) { + indexingSuspended = suspended; + } + + /** + * Check if indexing was suspended due to cache size limit. + * When true, Find in Files should not perform searches. + * + * @return {boolean} + */ + function isIndexingSuspended() { + return indexingSuspended; + } + /** * check if a search is progressing in worker * @@ -477,8 +504,8 @@ define(function (require, exports, module) { /** * Notifies that a worker has started indexing the files */ - function notifyIndexingProgress(progress, total) { - exports.trigger(exports.SEARCH_INDEXING_PROGRESS, progress, total); + function notifyIndexingProgress(progress, total, cacheSizeBytes) { + exports.trigger(exports.SEARCH_INDEXING_PROGRESS, progress, total, cacheSizeBytes); } /** @@ -526,6 +553,8 @@ define(function (require, exports, module) { exports.getOpenFilePath = getOpenFilePath; exports.setInstantSearchDisabled = setInstantSearchDisabled; exports.isInstantSearchDisabled = isInstantSearchDisabled; + exports.setIndexingSuspended = setIndexingSuspended; + exports.isIndexingSuspended = isIndexingSuspended; exports.isWorkerSearchInProgress = isWorkerSearchInProgress; exports.isIndexingInProgress = isIndexingInProgress; exports.setCollapseResults = setCollapseResults; diff --git a/src/worker/file-Indexing-Worker-thread.js b/src/worker/file-Indexing-Worker-thread.js index 357d13c91..873b7212a 100644 --- a/src/worker/file-Indexing-Worker-thread.js +++ b/src/worker/file-Indexing-Worker-thread.js @@ -52,7 +52,8 @@ let currentCrawlIndex = 0, crawlComplete = false, crawlEventSent = false, cacheStartTime = Date.now(), - cacheSize = 0; + cacheSize = 0, + maxCacheSizeBytes = 1024 * 1024 * 1024; // 1 GB default /** * Clears the cached file contents of the project @@ -101,6 +102,21 @@ async function getFilesizeInBytes(fileName) { } } +/** + * Checks if file content appears to be binary by looking for null bytes + * in the first 8KB. This is the same heuristic used by git and grep. + * Null bytes (0x00) survive UTF-8 decoding as \u0000 characters. + * @param {string} content The file content read as utf8 + * @return {boolean} True if the content appears to be binary + */ +function _isBinaryContent(content) { + if (!content) { + return false; + } + const nullIndex = content.indexOf('\0'); + return nullIndex !== -1 && nullIndex < 8192; +} + /** * Get the contents of a file from cache given the path. Also adds the file contents to cache from disk if not cached. * Will not read/cache files greater than MAX_FILE_SIZE_TO_INDEX in size. @@ -114,7 +130,13 @@ async function getFileContentsForFile(filePath) { try { let fileSize = await getFilesizeInBytes(filePath); if ( fileSize <= MAX_FILE_SIZE_TO_INDEX) { - projectCache[filePath] = await _readFileAsync(filePath); + let contents = await _readFileAsync(filePath); + if (_isBinaryContent(contents)) { + console.log("file indexer: skipping binary file:", filePath); + projectCache[filePath] = ""; + } else { + projectCache[filePath] = contents; + } } else { projectCache[filePath] = ""; } @@ -159,6 +181,21 @@ async function fileCrawler(crawlerID) { // So stop the current crawler as another crawler will be scheduled by the initCache fn. return; } + if (maxCacheSizeBytes > 0 && cacheSize >= maxCacheSizeBytes) { + // Cache size limit reached — stop indexing to prevent OOM + crawlComplete = true; + if (!crawlEventSent) { + crawlEventSent = true; + let crawlTime = Date.now() - cacheStartTime; + WorkerComm.triggerPeer("crawlComplete", { + numFilesCached: currentCrawlIndex, + cacheSizeBytes: cacheSize, + crawlTimeMs: crawlTime, + aborted: true + }); + } + return; + } if (currentCrawlIndex < files.length) { crawlComplete = false; setTimeout(()=>fileCrawler(crawlerID)); @@ -181,7 +218,8 @@ function _crawlProgressMessenger() { if(!crawlComplete && files){ WorkerComm.triggerPeer("crawlProgress", { processed: currentCrawlIndex, - total: files.length + total: files.length, + cacheSizeBytes: cacheSize }); } } @@ -279,6 +317,13 @@ function setTauriWsFS(nodeWSURL) { fs.forceUseNodeWSEndpoint(true); } +function setCacheConfig(config) { + if (config.maxCacheSizeBytes !== undefined && config.maxCacheSizeBytes > 0) { + maxCacheSizeBytes = config.maxCacheSizeBytes; + } +} + +WorkerComm.setExecHandler("setCacheConfig", setCacheConfig); WorkerComm.setExecHandler("setTauriFSWS", setTauriWsFS); WorkerComm.setExecHandler("initCache", initCache); WorkerComm.setExecHandler("filesChanged", addFilesToCache);