Skip to content

Fix: packaged app hangs on "Restoring session..." (bundle fsevents)#140

Open
matanelcohen wants to merge 2 commits into
InbarR:mainfrom
matanelcohen:fix/packaged-fsevents-emfile-stuck-loading
Open

Fix: packaged app hangs on "Restoring session..." (bundle fsevents)#140
matanelcohen wants to merge 2 commits into
InbarR:mainfrom
matanelcohen:fix/packaged-fsevents-emfile-stuck-loading

Conversation

@matanelcohen

Copy link
Copy Markdown

Fixes #139

Problem

Packaged (dist) builds hang forever on the "Restoring session..." screen while npm start works. The session watchers use chokidar.watch(basePath, { usePolling: false, depth: 2 }); chokidar only uses the efficient single FSEvents watcher on macOS when the native fsevents module loads. fsevents is present in dev but was not bundled into the packaged app, so chokidar fell back to one fs.watch fd per directory. On accounts with thousands of ~/.copilot session dirs (repro: 4,972) this exhausts the fd limit:

Error: EMFILE: too many open files, watch

The flood saturates the main event loop, the renderer's startup IPC stalls, and isRestoring never clears. Finder/Dock launches inherit a 256 fd soft limit, compounding it.

Changes

  • forge.config.ts — bundle fsevents in postPackage (same mechanism as node-pty / better-sqlite3).
  • vite.main.config.ts — externalize fsevents for the runtime probe.
  • src/main/utils/fsevents.ts (new) — canUseNativeRecursiveWatch().
  • copilot/claude-code session watchers — fall back to bounded usePolling: true when fsevents is unavailable (defense-in-depth: degrade instead of hang).

Verification (real package, macOS arm64)

  • With fsevents: usePolling=false, 0 EMFILE, both watchers reach ready, app restores the saved 4-pane session past the loading screen.
  • Without fsevents (simulated by removing it from the package): graceful usePolling=true fallback, no EMFILE flood, no hang.
  • npm run test:unit115/115 pass.

…on..."

Packaged (dist) builds hung forever on the SessionLoading screen while
`npm start` worked. chokidar's macOS FSEvents backend needs the native
`fsevents` optionalDependency, which was present in dev node_modules but
never bundled into the packaged app. Without it, chokidar (usePolling:false)
falls back to one fs.watch fd per directory; on accounts with thousands of
~/.copilot session dirs that exhausts the fd limit (EMFILE: too many open
files, watch), saturating the main event loop so renderer startup IPC stalls
and isRestoring never clears. Finder/Dock launches inherit a 256 fd soft
limit, compounding it.

- forge.config.ts: copy fsevents in postPackage (like node-pty/better-sqlite3)
- vite.main.config.ts: externalize fsevents for the runtime probe
- src/main/utils/fsevents.ts: canUseNativeRecursiveWatch() probe
- copilot/claude-code session watchers: fall back to bounded polling when
  fsevents is unavailable (defense-in-depth so a missing native module
  degrades instead of hanging)

Verified on a real package: usePolling=false + 0 EMFILE with fsevents,
graceful usePolling=true fallback without it; app restores its multi-pane
session past the loading screen. 115 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… quit

Bundling fsevents (the EMFILE startup fix) introduced an FSEvents watcher
whose N-API threadsafe function must be released while the libuv loop is
alive. The watchers were only closed in window-all-closed, so on Cmd-Q /
SIGTERM the Node env tore down with the FSEvents watcher still active and the
app aborted (SIGABRT) on exit:

  fsevents.node :: fse_instance_destroy
  -> napi_release_threadsafe_function -> uv_mutex_lock -> abort
  (CrBrowserMain, during node::Stop / FreeEnvironment)

fsevents' Native.stop (invoked by chokidar close()/stop()) releases the TSFN,
so closing the watchers before teardown fixes it.

- Extract shutdownResources(): idempotent teardown that closes all
  chokidar/fsevents watchers, kills PTYs, disposes monitors.
- Gate before-quit: preventDefault, run shutdownResources (2s timeout guard),
  then re-issue app.quit() so normal teardown (will-quit + renderer
  beforeunload session-save) still runs — now abort-free.
- window-all-closed routes through app.quit() (into the gated cleanup).
- Handle SIGINT/SIGTERM/SIGHUP via app.quit() so external termination also
  cleans up instead of aborting.

Verified on a real package: SIGTERM and Apple-Event (Cmd-Q) quits both exit
cleanly with zero new crash reports. 115 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@matanelcohen

Copy link
Copy Markdown
Author

Follow-up commit 9da06e6: fix a SIGABRT crash on quit that bundling fsevents exposed.

With fsevents now bundled, the FSEvents watcher's N-API threadsafe function was being released during Node env teardown (node::Stop) instead of while the libuv loop was alive, aborting on exit:

fsevents.node :: fse_instance_destroy -> napi_release_threadsafe_function -> uv_mutex_lock -> abort
(CrBrowserMain, during node::Stop / FreeEnvironment)

The watchers were only closed in window-all-closed, so Cmd-Q / SIGTERM tore down with the watcher still active. Fix:

  • shutdownResources() closes all chokidar/fsevents watchers (fsevents Native.stop releases the TSFN) before teardown.
  • Gated before-quit runs it (2s timeout guard) then re-issues app.quit() so will-quit + renderer beforeunload session-save still run.
  • window-all-closed routes through app.quit(); SIGINT/SIGTERM/SIGHUP handlers do too.

Verified on a real package: SIGTERM and Apple-Event (Cmd-Q) quits both exit cleanly with zero new crash reports. 115 unit tests pass.

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.

Packaged app hangs on "Restoring session..." (fsevents not bundled → chokidar EMFILE fd exhaustion)

1 participant