Cursor 3.20.21: search cleanup ReferenceError blocks extension-host exit, leaving multi-GB orphan processes

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Observed behavior and impact

Both stale extension hosts logged ReferenceError: Cannot access 'g' before initialization in Cursor’s file-search cleanup during process.exit(), yet remained alive. Their individual process footprints were approximately 2,642 MiB and 2,765 MiB. One had remained since the previous day.

After verifying the current windows and their extension hosts, I terminated only the two stale hosts and six dedicated child processes. All eight exited with SIGTERM; no SIGKILL was necessary. The two current windows and their extension hosts remained alive. Aggregate Cursor process footprint decreased from approximately 13.5 GiB to 7.3 GiB. Footprint includes compressed/swapped memory; this is not a claim that 6.2 GiB of physical RAM was immediately freed, or that the cleanup callbacks themselves retained that entire amount.

Concrete failure path in the installed build

In out/vs/workbench/api/node/extensionHostProcess.js, minified line 173, cmdTraversal registers a cleanup callback in the global Sy Set before initializing g:

const cleanup = () => child && child.kill();
pendingCleanups.add(cleanup);
const search = startRipgrepSearch(); // synchronous spawn failure is possible
const child = search.cmd;

These are descriptive names for the installed minified code. The actual startup helper is EDe; it checks the search directory and calls spawn for ripgrep. The actual child-process variable is g.

If startup throws synchronously, the callback remains registered while g remains uninitialized. The global exit listener iterates Sy. Invoking that callback then throws the ReferenceError from the temporal dead zone. That exception propagates through process.exit() and can interrupt termination. Repeated failed starts also accumulate cleanup callbacks.

Real log evidence, with local paths removed

One host logged:

2026-09-17 14:11:02.257 [info] Extension host ... exiting with code 0
2026-09-17 14:11:03.260 [error] ReferenceError: Cannot access 'g' before initialization

The other host logged the same exception on 2026-09-16 at 17:23:11.187 and was still alive the following day. Relevant stack frames:

at l (extensionHostProcess.js:173:3334)
at extensionHostProcess.js:173:1328
at Set.forEach (<anonymous>)
at process.<anonymous> (extensionHostProcess.js:173:1317)
at process.emit (node:events:521:24)
at SignalExit.processEmit_fn (zenclabs.previewjs-1.30.1/dist/index.js:1690:20)
at __privateGet.emit (zenclabs.previewjs-1.30.1/dist/index.js:1642:75)
at process.exit (node:internal/process/per_thread:236:15)
at Object.exit (extensionHostProcess.js:728:6864)
at extensionHostProcess.js:152:18591

Both hosts’ accumulated logs also contained many spawn EBADF errors. The initial cause of EBADF remains unknown. Preview.js appears as an event wrapper in the real stack; the isolated reproduction below requires no extensions, so its presence alone does not establish blame.

Steps to Reproduce

Isolated reproduction using the installed method

Run this in a disposable Node process, passing the installed extensionHostProcess.js path as its first argument. It extracts the exact method and stubs only its startup helper to throw EBADF. It does not run a real search or touch a live Cursor process.

const fs = require('node:fs');
const source = fs.readFileSync(process.argv[2], 'utf8');
const start = source.indexOf('cmdTraversal(e,n,t,a,s){');
const end = source.indexOf('spawnFindCmd(e){', start);
if (start < 0 || end < 0) throw new Error('Method not found');
const pending = new Set();
const failSpawn = () => {
  throw Object.assign(new Error('spawn EBADF'), { code: 'EBADF' });
};
const host = new Function('Sy', 'ht', 'EDe',
  'return {' + source.slice(start, end) + '}')(pending, false, failSpawn);
host.config = {};
host.folderExcludePatterns = new Map([['/diagnostic-only', { expression: {} }]]);
host.initDirectoryTree = () => ({});
for (let i = 0; i < 10; i++) {
  try {
    host.cmdTraversal({ folder: { fsPath: '/diagnostic-only' } },
      undefined, undefined, () => {}, () => {});
  } catch (e) { if (e.code !== 'EBADF') throw e; }
}
const result = { retainedCleanupCallbacks: pending.size };
const onExit = () => pending.forEach(fn => fn());
process.on('exit', onExit);
try { process.exit(0); }
catch (e) {
  result.exitException = e.name + ': ' + e.message;
  result.continuedAfterExitAttempt = true;
}
process.removeListener('exit', onExit);
fs.writeSync(1, JSON.stringify(result, null, 2) + '\n');
process.exit(0);

Observed result with the installed method:

{
  "retainedCleanupCallbacks": 10,
  "exitException": "ReferenceError: Cannot access 'g' before initialization",
  "continuedAfterExitAttempt": true
}

This proves the cleanup/exit failure path, not the original trigger for EBADF or a complete UI-level reproduction.

Expected Behavior

Search startup failure should unregister cleanup and leave no closure referring to an uninitialized binding. Initialize the child-process binding before registering cleanup, remove the callback on synchronous startup failure, and isolate exceptions from individual cleanup callbacks so that one failed search cannot block extension-host termination.

Operating System

MacOS

Version Information

Cursor 3.20.21
Commit: f09fca384ceca23f7bf21f9c23655b162641d740
macOS 26.5.2 (25F84)
Apple M1 Pro / arm64, 16 GiB RAM
Two project windows open; two older extension hosts remained after their windows were gone.

Additional Information

Possibly related: Xtension-host processes orphaned after window close/reuse — SIGTERM ignored, requires SIGKILL
Unlike that report, SIGTERM successfully stopped the stale processes in this case.
The underlying trigger for spawn EBADF is unknown; the supplied reproduction isolates the cleanup and exit failure.

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey @jaejun-st, thanks for the detailed write-up and the repro. This isn’t intended behavior, and it’s not anything on your end. We’ve let the team know, and it’s an issue we’re tracking.

To reclaim the memory now: those leftover hosts are idle, so either fully quit Cursor (Cmd+Q) to reap them, or SIGTERM just the stale “extension-host” processes as you did, which leaves your open windows untouched.

One quick thing that would help us pin the underlying spawn EBADF trigger: had Cursor been updated in place (without a full quit and relaunch) before the searches started failing, and roughly how large were those workspaces (rough file count)?

Thanks, Mohit. I checked the update logs and counted the two affected workspace folders.

Update/restart timeline: the logs indicate that the update went through a quit-and-relaunch, rather than an in-place update with no relaunch. All times below are local time (UTC+9):

  • Sep 15, 09:04:54: the previous session logged onUpdateDownloaded(), then ready.
  • 09:43:26: it logged UpdateService doQuitAndInstall() and the update supervisor arming after semantic shutdown for target version 3.20.21.
  • 09:43:48: the current main Cursor process started, according to its OS process start time.
  • 09:43:54: the new session logged Restart acknowledged (... success=true, currentVersion=3.20.21).
  • The earliest spawn EBADF entries still retained for the two affected extension hosts are Sep 15 at 12:24:09 and 18:00:44, respectively. The logs have rotated, so these are not necessarily the very first failures.

In that new main-process session, I found 57 onUpdateNotAvailable() entries and no onUpdateAvailable() entry. This points away from an update being applied without a relaunch during the affected session. I cannot establish from these logs that every child from the previous session was reaped, or what originally caused EBADF.

Workspace size: each affected window had a local TypeScript/Node backend folder open. Current counts, with project names omitted:

Count method Workspace A Workspace B
Git-tracked paths (git ls-files) 533 732
Files listed by default rg --files (normal ignore/hidden-file rules) 528 727
Files on disk, including dependencies/build outputs, excluding .git and symlink files 53,545 60,289
Of those, under node_modules 51,915 57,933
Of those, under the top-level dist directory 1,030 1,424

For comparison, the cursor --status snapshot taken immediately before cleanup reported approximately 1,814 and 2,544 files for the two open workspace folders. These counters use different inclusion rules; the physical totals above are not a claim that ripgrep actually searched all of those files. The counts were collected after the incident, so they are approximate context rather than an exact snapshot at the first failure.

The orphan cleanup has already been completed using SIGTERM, leaving the two active windows running. The isolated cleanup/exit reproduction in the original report still stands independently of the unknown EBADF trigger.