Skip to content

fix: prevent re-entrant event loop hang in main process (18s freeze)#388

Open
monotykamary wants to merge 3 commits into
SuperCmdLabs:mainfrom
monotykamary:fix/re-entrant-event-loop-hang
Open

fix: prevent re-entrant event loop hang in main process (18s freeze)#388
monotykamary wants to merge 3 commits into
SuperCmdLabs:mainfrom
monotykamary:fix/re-entrant-event-loop-hang

Conversation

@monotykamary
Copy link
Copy Markdown
Contributor

Problem

A macOS hang report (crash.dump) showed SuperCmd becoming unresponsive for 18.24 seconds. The Electron main thread entered a re-entrant event loop — V8 compilation → Node.js tick callback → nested libuv uv_run processed fs.stat completions, triggering JS rejection handlers that scheduled more compilation, blocking the Cocoa event loop indefinitely.

The critical stack chain from the dump:

NSApplication run
  → CFRunLoopDoSources0 (source0 event)
    → V8 TurboFan compilation (OptimizingCompileInputQueue::Prioritize)
      → tick_callback_function() (microtasks)
        → uv_run (nested libuv event loop!)
          → uv__work_done
            → AfterStat → FSReqCallback::Reject (ENOENT — file not found)
              → MakeCallback → JIT JavaScript execution
                → loop repeats, never returning to Cocoa event loop

This happens when:

  1. An extension command is invoked needing an on-demand esbuild bundle
  2. discoverInstalledExtensionCommands() does heavy sync fs.readdirSync/fs.statSync calls
  3. Failed fs.stat (ENOENT for missing source files or incomplete node_modules) re-enters JS
  4. The cycle of compilation → microtasks → I/O completions → more JS never yields

Fix (3 layers)

1. Re-entrancy Guard + 30s Timeout in getExtensionBundle (extension-runner.ts)

  • enterBundleGuard/exitBundleGuard — per-label depth counter prevents nested calls from creating a nested uv_run
  • yieldToEventLoop() on conflict — drains pending I/O callbacks before retrying
  • raceWithTimeout() — 30-second hard timeout prevents indefinite hangs

2. Yield Points in Command Discovery (commands.ts)

  • setImmediate yield at the start of discoverAndBuildCommands() — drains pending I/O before heavy sync work
  • setImmediate yield after every batch in discoverApplications() — breaks long app-bundle scanning loops
  • setImmediate yield after every batch in discoverSystemSettings() — breaks appex + prefPane scanning loops
  • setImmediate yield between discovery phases — before batched NSWorkspace icon extraction

3. Yield Before Extension Execution (main.ts)

  • setImmediate yield before buildLaunchBundle() in runCommandById — drains pending I/O before entering bundle loading

Testing

Added vitest (new dev dependency) with 15 tests across 4 suites in src/main/__tests__/re-entrancy-guard.test.ts:

Suite Tests What it verifies
Re-entrancy Guard 4 Guard blocks nesting, allows concurrent independent labels, re-entry after exit
Yield to Event Loop 2 setImmediate correctly yields control, microtasks drain between yields
Timeout Wrapper 3 Fast promises resolve, slow promises timeout, timers clean up
Re-entrant Loop Simulation 3 Sync work blocks async callbacks without yields; yields prevent blocking; guard prevents nested re-entry
Bundle Load Integration 3 Normal loads succeed, slow builds timeout with error, concurrent calls are handled

Run tests: npm test

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a6ef7ea68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/extension-runner.ts Outdated
The main process became unresponsive for ~18s when the Electron
main thread entered a re-entrant event loop: V8 compilation ->
tick_callback -> nested uv_run processed fs.stat completions ->
JS rejection handlers fired -> more compilation, blocking the
Cocoa event loop indefinitely.

Three-layer fix:

1. Re-entrancy guard in getExtensionBundle (extension-runner.ts):
   - enterBundleGuard/exitBundleGuard prevents nested uv_run
   - yieldToEventLoop on conflict drains pending I/O callbacks
   - 30s raceWithTimeout prevents indefinite hangs

2. Yield points in command discovery (commands.ts):
   - setImmediate yield before discoverAndBuildCommands
   - setImmediate yield after each batch in discoverApplications
   - setImmediate yield after each batch in discoverSystemSettings
   - setImmediate yield between discovery phases

3. Yield before extension execution (main.ts):
   - setImmediate yield before buildLaunchBundle in runCommandById

Added vitest test suite (15 tests) covering:
- Re-entrancy guard enter/exit/blocking semantics
- Event loop yielding behavior
- Timeout wrapper for slow operations
- Simulated re-entrant loop patterns
- Integration-level bundle load scenarios
@monotykamary monotykamary force-pushed the fix/re-entrant-event-loop-hang branch from 5a6ef7e to 4ee731a Compare May 14, 2026 19:22
The rejection timer in raceWithTimeout used setTimeout(..., 0), causing
every bundle load that didn't complete in one event-loop tick to be
reported as a timeout immediately instead of after BUNDLE_LOAD_GRACE_MS.
Removed the broken placeholder-cleanup timer pattern in favor of a
single setTimeout(..., ms) that correctly rejects at the right delay.
@monotykamary monotykamary force-pushed the fix/re-entrant-event-loop-hang branch from 8f5f5af to 08fa5a2 Compare May 14, 2026 19:27
@monotykamary monotykamary marked this pull request as draft May 14, 2026 19:30
Move esbuild builds (on-demand + full extension rebuild) to a forked
child process (build-worker.ts) so that V8 compilation, file resolution,
and plugin I/O cannot re-enter the main process's Cocoa event loop.

- build-worker.ts: receives build options over IPC from the main
  process, runs esbuild.build(), returns success/failure + missing
  bare imports. Follows the same pattern as window-manager-worker.
- Remove requireEsbuild() and extractMissingBareImports() from the
  main process — they live in the worker now.
- Rewrite runEsbuildBuild() to delegate to callBuildWorker().
  Retry logic (missing packages -> install -> rebuild) stays in main.
- Remove all 5 setImmediate yield points from commands.ts and main.ts
  — the worker replaces the band-aid fix with a proper architecture.
- Keep re-entrancy guard + 30s timeout as defense-in-depth.
@monotykamary monotykamary marked this pull request as ready for review May 14, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant