Skip to content

fix(main): move freeze-prone sync fs off the main thread - #12015

Open
nwparker wants to merge 1 commit into
mainfrom
nwparker/main-thread-sync-fs-sweep
Open

nwparker wants to merge 1 commit into
mainfrom
nwparker/main-thread-sync-fs-sweep

Conversation

@nwparker

@nwparker nwparker commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

A synchronous fs syscall on the Electron main thread parks the entire app when its target is on a stalled mount (SMB/NFS, VPN drop, sleeping NAS, unmounted external drive). The app stops repainting and stops answering Force Quit. This converts 28 such paths to async, selected from a reproduction pass over 43 documented freezes.

Highest-value ones, because they need no user action to fire:

Path Why it matters
orca.yaml hook config Read behind the polled git:status handler — a stalled repo froze the app on a timer
Credential status (Codex / Grok / MiniMax / speech) Also called off-IPC by RateLimitService on a refresh timer — froze a fully idle app
Agent hook status/install (14 agents) 10 funnel through one shared reader; installManagedAgentHooks was a serial loop with sync fs in every body
orcaProfiles:list On the app-startup chain
External path authorization, agent trust Drag-drop a file, cmd-click a path, create a worktree

The measurement that shaped the fix

I instrumented this before writing any of it, because it determines whether the whole approach is sound. FIFOs with no writer stand in for a hung mount (open(2) blocks uninterruptibly, same shape as an SMB stat in D-state):

stuck ops pool unrelated async fs event loop
3 4 completes (1ms) alive, 3ms gap
4 4 never completes alive, 4ms gap
8 4 never completes alive, 3ms gap
8 16 completes (1ms) alive, 3ms gap

Two conclusions, both load-bearing:

  1. Async conversion does not bound a hung mount. It relocates the block into libuv's threadpool, and at exactly poolSize concurrent stalled ops all other async fs app-wide stops. UV_THREADPOOL_SIZE is raised to 16 as headroom — explicitly not as a bound.
  2. It still fixes the reported symptom. The event loop stayed alive in every configuration, including the starved ones. Sync fs parks the main thread (no repaint, Force Quit dead); a starved pool leaves the app responsive and killable with only further fs work hanging.

No timeout/deadline machinery was added. A main-thread setTimeout cannot bound a main-thread block — the timer lives on the thread it would need to bound.

The probe is included as config/scripts/libuv-threadpool-starvation-probe.mjs so this is re-runnable rather than folklore.

Atomicity

This is where the risk actually lives. Every tmp→rename and read-modify-write in these files was serialized for free by the single thread; async breaks that. A per-path promise chain is not sufficient when a sync twin is retained — the sync writer walks straight through it. Two approaches used:

  • Preferred — move only the slow part off-thread. What stalls is the realpath on the user's workspace; ~/.codex/config.toml and ~/.copilot/config.json are small files in HOME. Await the canonicalization, keep the read-modify-write synchronous. Fixes the freeze and keeps the single thread as the serialization mechanism.
  • Where both twins must coexist — the async writer publishes its temp path, the sync writer unlinks it, turning the parked rename into a swallowed ENOENT. A generation guard alone cannot work: it is already spent once the writer parks on await rename. Same pattern as fix(quit): stop durable state writes from parking the main thread on quit #11931.

observability/local-file-sink.ts is deliberately untouched — its writeSync is a crash-durability contract that needs a process boundary, not an await.

Verification

  • Suite: 1,744 files / 21,746 tests passed, 0 failures. Typecheck (tsc --noEmit) exit 0. oxlint + oxfmt clean.
  • 32 new files, mostly regression tests. Each was validated by reverting its fix and confirming failure.
  • The work went through an adversarial review pass that raised 26 defects (1 critical lost-update on a trust file, 3 dropped tmp+rename, 1 guard-spent-by-the-await, plus a module-top-level promisify that broke two unrelated suites at collection). All were fixed and re-verified; the fixes are in this branch.
  • One re-verification claim — a double-append in the profile-index retry — I could not reproduce. profile-index-veto-retry-integrity.test.ts constructs the interleaving and asserts no duplicate; it includes a guard assertion so it fails rather than silently passing if the race doesn't occur.

Risk notes

  • src/main/ipc/pty.test.ts and provider-dispatch.test.ts broke mid-development from a module-load-time promisify(realpath.native) in a new module. Fixed (bound lazily), and both suites pass — but it's a reminder that new main-process modules must not touch fs at import time.
  • Some sync twins are now dead in production but still covered by the older behavioral tests, while the async twins the app actually runs have thinner coverage. Called out rather than papered over; worth a follow-up to delete the dead twins.
  • Not included, deliberately: the sendSync beforeunload handshake and pty:spawn change synchronous IPC contracts and need their own PR rather than a mechanical sweep.

A synchronous fs syscall on the Electron main thread parks the whole app
when its target is on a stalled mount (SMB/NFS, VPN drop, sleeping NAS,
unmounted drive). The app stops repainting and stops answering Force Quit.
This converts 28 such paths, covering the ones users actually hit:

- orca.yaml hook config, read behind the POLLED git:status path, so a
  stalled repo froze the app on a timer with no user action
- agent hook status/install for 14 agents, incl. the shared reader that
  10 of them funnel through and a serial install loop with sync fs per body
- credential status for Codex/Grok/MiniMax/speech, also called off-IPC by
  RateLimitService on a refresh timer -- froze a fully idle app
- external path authorization and agent trust setup
- profile index, keybindings, diagnostics discovery

Measured first, since it shapes the fix: libuv's threadpool defaults to 4
threads, and at exactly poolSize concurrent stalled ops every other async
fs call app-wide stops progressing. But the event loop stayed alive in all
configurations (max timer gap 4ms). So async conversion does not bound a
hung mount -- it keeps the main thread responsive, which is the reported
symptom. UV_THREADPOOL_SIZE is raised to 16 as headroom, not as a bound.
No timeout machinery was added; it cannot bound a block on its own thread.

Where a sync twin had to stay (CLI processes, pre-window startup, callers
that cannot await), the write path keeps single-thread serialization rather
than relying on a promise chain a sync writer walks straight through:
canonicalization moves off-thread while the read-modify-write stays sync.
Where both twins must coexist, the async writer publishes its temp path and
the sync writer unlinks it, turning the parked rename into a swallowed
ENOENT -- a generation guard alone is spent once the writer parks.

observability/local-file-sink.ts is deliberately untouched; its writeSync
is a crash-durability contract that needs a process boundary, not an await.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change moves main-process filesystem work to asynchronous APIs across agent hooks, trust presets, account stores, secure files, keybindings, diagnostics, worktrees, and profile indexes. Atomic writes now use temporary files, renames, cleanup, and per-path serialization. IPC handlers await asynchronous service operations. Hook services retain status and mutation behavior while adding asynchronous workflows. New tests cover event-loop responsiveness, stalled operations, race handling, atomicity, and cache consistency. A libuv threadpool probe and startup sizing module were also added.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the change and verification, but it omits the required template sections and explicit security and cross-platform audit confirmations. Add Summary, Screenshots with “No visual change” if applicable, checklist results, explicit macOS/Linux/Windows review confirmation, Security Audit, and Notes sections.
Docstring Coverage ⚠️ Warning Docstring coverage is 34.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes moving freeze-prone synchronous filesystem work off the Electron main thread.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch nwparker/main-thread-sync-fs-sweep

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 16

🧹 Nitpick comments (17)
src/main/orca-profiles/profile-index-async-store.ts (1)

195-213: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

copyIfPresentAsync hides real migration failures.

The catch block treats every error the same. A missing source is expected. EACCES, ENOSPC, or a stalled-mount error on copyFile is not. Both outcomes leave no trace, so a partial legacy migration is silent.

Log non-ENOENT errors so the failure is diagnosable.

♻️ Proposed change
-  } catch {
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+      console.warn('[orca-profiles] legacy migration copy failed:', target, error)
+    }
     await unlink(tmpTarget).catch(() => {})
   }
src/main/ipc/orca-profiles-main-thread-fs.test.ts (2)

64-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Patch the default export in both fs mock factories.

Both factories return { ...actual } with the named functions replaced. Neither replaces actual.default. A module that uses import fs from 'node:fs' and calls fs.mkdirSync(...) therefore reaches the real function and records nothing. The expect(syncFsCalls).toEqual([]) assertions would then pass without proving anything.

The current production code uses named imports, so the guard holds today. Patch default so a future default import cannot silently disable this regression test. profile-index-veto-retry-integrity.test.ts on Line 27 already does this.

♻️ Proposed change
   for (const name of RECORDED_SYNC) {
     const real = actual[name] as (...args: unknown[]) => unknown
     patched[name] = (...args: unknown[]) => {
       syncFsCalls.push(`${name} ${String(args[0])}`)
       return real(...args)
     }
   }
-  return patched
+  return { ...patched, default: patched }
 })

Apply the same change to the node:fs/promises factory.


256-279: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The parked promise never settles and has no rejection handler.

gate.block is a promise that never resolves. parked therefore stays pending for the rest of the run. void parked.then(...) attaches no rejection handler. If the operation later rejects, the unhandled rejection surfaces in an unrelated test.

Attach a no-op rejection handler.

♻️ Proposed change
-    void parked.then(() => {
-      settled = true
-    })
+    void parked.then(
+      () => {
+        settled = true
+      },
+      () => {
+        settled = true
+      }
+    )
src/main/copilot/hook-service.ts (1)

310-349: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Async twins were added by copying the sync method body instead of extracting the pure mutation. In both files the new async method repeats the config-mutation logic of its sync counterpart, and only the read and write calls differ. Any future change to the event sweep must be applied twice, so the CLI process and the main process can diverge. src/main/gemini/hook-service.ts shows the target pattern in this same PR: applyManagedHooks and stripManagedHooks hold the pure logic, and the sync and async methods differ by three lines.

  • src/main/copilot/hook-service.ts#L310-L349: extract the 30-line hook-rebuild block shared with install into a buildInstalledCopilotConfig(config, scriptPath) helper, and call it from both methods.
  • src/main/droid/hook-service.ts#L291-L315: extract the managed-command sweep shared with remove into a stripManagedDroidHooks(config) helper, matching the existing buildInstalledDroidConfig precedent in the same file.
src/main/agent-hooks/managed-agent-hook-controls.ts (1)

176-177: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the threadpool size stated in the comment.

The comment states the libuv threadpool has 4 threads. This PR sets UV_THREADPOOL_SIZE to 16 at startup, so the stated number is stale. The serial choice still holds; only the justification number is wrong.

♻️ Proposed comment update
-// Why: serial, not Promise.all — libuv's threadpool is 4 threads, so fanning 14
-// agent configs out at once just starves every other async fs caller.
+// Why: serial, not Promise.all — the libuv threadpool is bounded, so fanning 14
+// agent configs out at once starves every other async fs caller.
src/main/cursor/hook-service.ts (1)

176-231: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared merge and sweep logic like the sibling services do.

install() (Lines 119-174) and installAsync() (Lines 176-231) hold the same 45 lines of stale-hook sweeping, dual-shape stripping, managed-entry replacement, and version pinning. remove() and removeAsync() duplicate the strip loop. Only the read and write calls differ. A future rule change must be applied twice, so the CLI sync path and the app async path can diverge silently.

src/main/command-code/hook-service.ts and src/main/antigravity/hook-service.ts already solve this with shared buildInstalledConfig and stripManagedHooks helpers. Apply the same shape here.

♻️ Proposed extraction
+function buildInstalledConfig(config: HooksConfig, scriptPath: string): Record<string, unknown> {
+  const command = getManagedCommand(scriptPath)
+  const nextHooks = { ...config.hooks }
+  const managedEvents = new Set<string>(CURSOR_EVENTS)
+  const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
+
+  for (const [eventName, definitions] of Object.entries(nextHooks)) {
+    if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
+      continue
+    }
+    const cleaned = removeManagedCommands(definitions, isManagedCommand).filter(
+      (definition) => !isManagedCommand(definition.command as string | undefined)
+    )
+    if (cleaned.length === 0) {
+      delete nextHooks[eventName]
+    } else {
+      nextHooks[eventName] = cleaned
+    }
+  }
+
+  for (const eventName of CURSOR_EVENTS) {
+    const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
+    const cleaned = removeManagedCommands(current, isManagedCommand).filter(
+      (definition) => !isManagedCommand(definition.command as string | undefined)
+    )
+    nextHooks[eventName] = [...cleaned, buildManagedCommandDefinition(command)]
+  }
+
+  const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
+  if (nextConfig.version === undefined) {
+    nextConfig.version = 1
+  }
+  return nextConfig
+}

Then both twins reduce to the read, the helper call, the writes, and the status read:

   async installAsync(): Promise<AgentHookInstallStatus> {
     const configPath = getConfigPath()
     const scriptPath = getManagedScriptPath()
     const config = await readHooksJsonAsync(configPath)
     if (!config) {
       return parseErrorStatus(configPath)
     }
-    // ...45 duplicated lines...
+    const nextConfig = buildInstalledConfig(config, scriptPath)
     await writeManagedScriptAsync(scriptPath, getManagedScript())
     await writeHooksJsonAsync(configPath, nextConfig)
     return this.getStatusAsync()
   }

Also applies to: 318-343

src/main/grok/managed-hook-script.ts (1)

11-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the string replacement against silent drift.

WINDOWS_HOOK_PAYLOAD_FORM_LINE duplicates the final line produced by buildWindowsAgentHookPostCommand in src/main/agent-hooks/installer-utils.ts. If that shared builder changes the payload line (spacing, redirection, or the ^ continuation), String.prototype.replace matches nothing and returns the command unchanged. The generated Windows script then posts without grokHome, and no error is raised.

Add a module-load assertion so the mismatch fails fast instead of silently degrading the hook payload.

♻️ Proposed guard
-const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace(
-  WINDOWS_HOOK_PAYLOAD_FORM_LINE,
-  `  --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}`
-)
+const WINDOWS_GROK_HOOK_POST_COMMAND = ((): string => {
+  const base = buildWindowsAgentHookPostCommand('grok')
+  if (!base.includes(WINDOWS_HOOK_PAYLOAD_FORM_LINE)) {
+    throw new Error('Grok Windows hook command no longer contains the expected payload line')
+  }
+  return base.replace(
+    WINDOWS_HOOK_PAYLOAD_FORM_LINE,
+    `  --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}`
+  )
+})()
src/main/orca-yaml-hooks-main-thread-block.test.ts (1)

141-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the exact paths passed to readFileMock.

The mock returns the shared YAML body for any path other than ISSUE_COMMAND. A regression that reads the wrong shared path would still satisfy this test. Pin both read targets so the test detects a path change.

♻️ Proposed assertion
     expect(syncCalls).toEqual([])
+    expect(readFileMock.mock.calls.map((call) => call[0])).toEqual([ISSUE_COMMAND, YAML])
   })
src/main/grok/hook-service.ts (1)

310-322: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the managed-hook sweep shared by remove and removeAsync.

Lines 310-322 duplicate Lines 284-296 exactly. Only the read and the write differ between the two methods. buildInstalledConfig already follows the shared-mutation pattern for the install path; apply the same pattern here so a future change to the sweep cannot diverge between the synchronous and asynchronous methods.

♻️ Proposed extraction

Add the shared helper next to buildInstalledConfig:

function buildRemovedConfig(config: HooksConfig): void {
  const nextHooks = { ...config.hooks }
  const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
  for (const [eventName, definitions] of Object.entries(nextHooks)) {
    if (!Array.isArray(definitions)) {
      continue
    }
    const cleaned = removeManagedCommands(definitions, isManagedCommand)
    if (cleaned.length === 0) {
      delete nextHooks[eventName]
    } else {
      nextHooks[eventName] = cleaned
    }
  }
  config.hooks = nextHooks
}

Then reduce removeAsync:

-    const nextHooks = { ...config.hooks }
-    const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
-    for (const [eventName, definitions] of Object.entries(nextHooks)) {
-      if (!Array.isArray(definitions)) {
-        continue
-      }
-      const cleaned = removeManagedCommands(definitions, isManagedCommand)
-      if (cleaned.length === 0) {
-        delete nextHooks[eventName]
-      } else {
-        nextHooks[eventName] = cleaned
-      }
-    }
-
-    config.hooks = nextHooks
+    buildRemovedConfig(config)
     await writeHooksJsonAsync(configPath, config)

Apply the same replacement inside remove at Lines 284-298.

src/main/agent-trust-presets.ts (1)

204-237: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the Codex trust-root traversal between the sync and async twins.

resolveCodexProjectTrustRootAsync duplicates every branch of resolveCodexProjectTrustRoot, including the reciprocal-link validation that stops workspace-controlled .git metadata from widening trust. Two copies of a security check can diverge. A later fix applied to one copy only would leave the other path permissive.

You can parameterize the traversal by its reader and canonicalizer, then keep both entry points as thin wrappers.

♻️ Sketch of a shared traversal
+type TrustRootIo = {
+  readText: (path: string) => Promise<string> | string
+  canonical: (path: string) => Promise<string> | string
+}
+
+async function resolveTrustRootWith(workspacePath: string, io: TrustRootIo): Promise<string> {
+  // single implementation of the .git / worktrees / backlink checks
+}
src/main/minimax/minimax-cookie-store.test.ts (1)

134-154: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add coverage for a read that resolves after a clear.

This test covers a queued save landing after a clear. It does not cover the symmetric case: readMiniMaxSessionCookieAsync starting before a clear and resolving after it. That path republishes the cache today. See the issue raised on src/main/minimax/minimax-cookie-store.ts Lines 162-200.

Add a test that holds readFileMock pending, runs clearMiniMaxSessionCookieAsync() to completion, releases the read, and then asserts readMiniMaxSessionCookie() returns null.

src/main/speech/openai-api-key-store-off-main-thread.test.ts (1)

63-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset the store module state between tests.

The store keeps cachedOpenAiSpeechApiKey and its generation counter at module scope. The static import at Lines 53-58 loads the module once for the whole file, so state carries across tests. Line 102 asserts readOpenAiSpeechApiKey() throws only because the preceding test happens to clear the key.

Reset the module registry in beforeEach and re-import the store, or add an exported test-only reset. This removes the ordering dependency.

src/shared/secure-file-async-write.test.ts (1)

166-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering a chained mutation after a failed one.

chainSecureMutation in src/shared/secure-file.ts Line 145 uses previous.then(run, run) so a rejected mutation still runs the next queued mutation. No test asserts this. Add a case that fails the first write and then queues a second write on the same path, and assert the second write publishes.

src/main/devin/hook-service.ts (1)

81-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the mutation serializer into a shared concrete module.

serializeConfigMutation is duplicated in src/main/devin/hook-service.ts, src/main/kimi/hook-service.ts, and src/main/hermes/hook-service.ts. src/main/amp/hook-service.ts uses the identical serializer under serializePluginMutation. Move it to a concretely named module such as src/main/agent-hooks/hook-config-mutation-queue.ts, then import it from each hook service.

src/main/hermes/hook-service.ts (1)

146-184: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Extract the shared async filesystem primitives into one module. This PR adds the same three primitives to several hook services: isMissingPath, an atomic temp-write-plus-rename helper, and a module-level pendingMutation serializer. Each copy is byte-similar, so a later fix to one copy will not reach the others. Move them to a concrete domain module, for example src/main/agent-hooks/agent-config-file-io.ts, and export a createMutationSerializer() factory so each service keeps its own queue.

  • src/main/hermes/hook-service.ts#L146-L184: replace isMissingPath, writeConfigFile, and serializeConfigMutation with imports from the shared module; keep the .bak behavior as an option.
  • src/main/amp/hook-service.ts#L112-L141: replace writeTextFileAtomic and serializePluginMutation with the shared helper and createMutationSerializer().
  • src/main/kimi/hook-service.ts#L106-L142: replace writeConfigToml and serializeConfigMutation with the shared helper and createMutationSerializer().
  • src/main/devin/hook-config-json.ts#L13-L17: use the shared isMissingPath instead of the inline ENOENT/ENOTDIR check.

Do not name the new module utils, helpers, common, or shared, as per coding guidelines: "Do not use vague names such as helpers, utils, common, misc, or shared-stuff for files, folders, or modules; use concrete domain names".

Source: Coding guidelines

config/scripts/libuv-threadpool-starvation-probe.mjs (1)

17-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the probe against Windows and non-numeric input.

mkfifo does not exist on Windows, so execFileSync throws ENOENT with no explanation. A non-numeric first argument makes stuckCount NaN, Array.from({ length: NaN }) returns an empty array, and the probe reports a healthy verdict without parking any thread.

♻️ Proposed fix
-const stuckCount = Number(process.argv[2] ?? 4)
+if (process.platform === 'win32') {
+  console.error('This probe needs mkfifo; run it on macOS or Linux.')
+  process.exit(1)
+}
+const parsedStuckCount = Number(process.argv[2] ?? 4)
+if (!Number.isInteger(parsedStuckCount) || parsedStuckCount < 1) {
+  console.error('stuckCount must be a positive integer')
+  process.exit(1)
+}
+const stuckCount = parsedStuckCount
src/main/ipc/filesystem-auth.ts (1)

60-76: 🩺 Stability & Availability | 🔵 Trivial

Consider bounding the gate wait, and consider evicting entries for permanently stalled paths.

A realpath on a dead mount never settles. Its entry then stays in pendingExternalPathCanonicalizations for the process lifetime, and awaitPendingCanonicalizationsFor blocks every later read of that path or any descendant of it, with no path to recovery other than a restart. The event loop stays responsive, so the symptom is a permanently pending IPC reply rather than a freeze.

The PR states that no timeout mechanism is added, so this is scope guidance, not a blocker. A bounded race (for example, Promise.race with a timer that resolves to false) would let the read fall through to the remaining checks and fail with the normal access-denied path instead of hanging.

Also applies to: 422-430


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd5e9787-a64f-4e3e-bf21-cd34be0e1186

📥 Commits

Reviewing files that changed from the base of the PR and between c5c10e1 and 3b67c2a.

📒 Files selected for processing (97)
  • config/scripts/libuv-threadpool-starvation-probe.mjs
  • src/cli/handlers/agent-hooks.ts
  • src/main/agent-hooks/agent-hook-install-off-main-thread.test.ts
  • src/main/agent-hooks/hooks-json-async-write-serialization.test.ts
  • src/main/agent-hooks/hooks-json-async-write.ts
  • src/main/agent-hooks/hooks-json-read.ts
  • src/main/agent-hooks/installer-utils.ts
  • src/main/agent-hooks/managed-agent-hook-controls.ts
  • src/main/agent-hooks/managed-agent-hook-mutation-serialization.test.ts
  • src/main/agent-hooks/managed-agent-hook-registry.ts
  • src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts
  • src/main/agent-trust-presets-off-main-thread.test.ts
  • src/main/agent-trust-presets.ts
  • src/main/amp/hook-service-main-thread-sync-fs.test.ts
  • src/main/amp/hook-service.test.ts
  • src/main/amp/hook-service.ts
  • src/main/antigravity/hook-service.ts
  • src/main/claude/hook-service.ts
  • src/main/claude/managed-hook-script.ts
  • src/main/codex-accounts/fs-utils.ts
  • src/main/codex-accounts/service.ts
  • src/main/codex-accounts/system-default-identity-off-main-thread.test.ts
  • src/main/codex/config-toml-trust.ts
  • src/main/command-code/hook-service.ts
  • src/main/copilot/hook-service.ts
  • src/main/cursor/hook-service.ts
  • src/main/cursor/managed-hook-script.ts
  • src/main/devin/hook-config-json-main-thread-sync-fs.test.ts
  • src/main/devin/hook-config-json.test.ts
  • src/main/devin/hook-config-json.ts
  • src/main/devin/hook-service.test.ts
  • src/main/devin/hook-service.ts
  • src/main/droid/hook-service.ts
  • src/main/droid/managed-hook-script.ts
  • src/main/gemini/hook-service.ts
  • src/main/git/worktree-shared-directories-poll-block.test.ts
  • src/main/git/worktree-shared-directories.ts
  • src/main/grok-accounts/status.test.ts
  • src/main/grok-accounts/status.ts
  • src/main/grok/hook-service.ts
  • src/main/grok/managed-hook-script.ts
  • src/main/hermes/hook-service-main-thread-sync-fs.test.ts
  • src/main/hermes/hook-service.test.ts
  • src/main/hermes/hook-service.ts
  • src/main/hooks.ts
  • src/main/index.ts
  • src/main/ipc/agent-hooks.test.ts
  • src/main/ipc/agent-hooks.ts
  • src/main/ipc/agent-trust.ts
  • src/main/ipc/codex-accounts.ts
  • src/main/ipc/diagnostics-preview-main-thread-fs.test.ts
  • src/main/ipc/diagnostics.test.ts
  • src/main/ipc/diagnostics.ts
  • src/main/ipc/filesystem-auth-external-path-async.test.ts
  • src/main/ipc/filesystem-auth.ts
  • src/main/ipc/filesystem.ts
  • src/main/ipc/grok-accounts.ts
  • src/main/ipc/hosted-review.ts
  • src/main/ipc/keybindings-main-thread-fs.test.ts
  • src/main/ipc/keybindings.test.ts
  • src/main/ipc/keybindings.ts
  • src/main/ipc/minimax-credentials.test.ts
  • src/main/ipc/minimax-credentials.ts
  • src/main/ipc/orca-profile-auth-handlers.test.ts
  • src/main/ipc/orca-profile-request-args.ts
  • src/main/ipc/orca-profiles-main-thread-fs.test.ts
  • src/main/ipc/orca-profiles.test.ts
  • src/main/ipc/orca-profiles.ts
  • src/main/ipc/speech.ts
  • src/main/ipc/worktrees.test.ts
  • src/main/ipc/worktrees.ts
  • src/main/keybindings/keybinding-file.ts
  • src/main/keybindings/keybinding-service.ts
  • src/main/kimi/hook-service-main-thread-sync-fs.test.ts
  • src/main/kimi/hook-service.test.ts
  • src/main/kimi/hook-service.ts
  • src/main/libuv-threadpool-size.test.ts
  • src/main/libuv-threadpool-size.ts
  • src/main/minimax/minimax-cookie-store-off-main-thread.test.ts
  • src/main/minimax/minimax-cookie-store.test.ts
  • src/main/minimax/minimax-cookie-store.ts
  • src/main/orca-profiles/profile-index-async-store.ts
  • src/main/orca-profiles/profile-index-document.ts
  • src/main/orca-profiles/profile-index-store.ts
  • src/main/orca-profiles/profile-index-veto-retry-integrity.test.ts
  • src/main/orca-yaml-hooks-main-thread-block.test.ts
  • src/main/rate-limits/grok-auth-off-main-thread.test.ts
  • src/main/rate-limits/grok-auth.test.ts
  • src/main/rate-limits/grok-auth.ts
  • src/main/runtime/orca-runtime-git.ts
  • src/main/speech/openai-api-key-store-off-main-thread.test.ts
  • src/main/speech/openai-api-key-store.test.ts
  • src/main/speech/openai-api-key-store.ts
  • src/shared/secure-file-async-write.test.ts
  • src/shared/secure-file.test.ts
  • src/shared/secure-file.ts
  • src/shared/secure-path-hardening-snapshot.ts
💤 Files with no reviewable changes (2)
  • src/main/grok-accounts/status.test.ts
  • src/main/grok-accounts/status.ts

Comment on lines +133 to +145
it('follows a symlinked config to its real path instead of replacing the link', async () => {
const { symlink } = await import('node:fs/promises')
const realPath = join(state.home, 'dotfiles-settings.json')
const linkPath = join(state.home, 'settings.json')
await writeFile(realPath, '{}\n', 'utf-8')
await symlink(realPath, linkPath)

await writeHooksJsonAsync(linkPath, { hooks: { Stop: [] } })

const { lstat } = await import('node:fs/promises')
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
expect(JSON.parse(await readFile(realPath, 'utf-8')).hooks).toEqual({ Stop: [] })
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Skip the symlink test on Windows.

This test calls symlink. Ordinary Windows CI agents lack the elevation or Developer Mode permission required to create symlinks, so symlink fails with EPERM and the test fails for an environment reason. The neighbouring mode-preservation test already guards platform-specific behaviour with process.platform !== 'win32'.

🛡️ Proposed guard
-  it('follows a symlinked config to its real path instead of replacing the link', async () => {
+  it.skipIf(process.platform === 'win32')(
+    'follows a symlinked config to its real path instead of replacing the link',
+    async () => {
     const { symlink } = await import('node:fs/promises')
     const realPath = join(state.home, 'dotfiles-settings.json')
     const linkPath = join(state.home, 'settings.json')
     await writeFile(realPath, '{}\n', 'utf-8')
     await symlink(realPath, linkPath)
 
     await writeHooksJsonAsync(linkPath, { hooks: { Stop: [] } })
 
     const { lstat } = await import('node:fs/promises')
     expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
     expect(JSON.parse(await readFile(realPath, 'utf-8')).hooks).toEqual({ Stop: [] })
-  })
+    }
+  )

Based on learnings: in test files under src/**/*.test.ts, any test that creates filesystem symlinks should be guarded with it.skipIf(process.platform === 'win32'). As per coding guidelines: "code, commands, and scripts must work on macOS, Linux, and Windows".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('follows a symlinked config to its real path instead of replacing the link', async () => {
const { symlink } = await import('node:fs/promises')
const realPath = join(state.home, 'dotfiles-settings.json')
const linkPath = join(state.home, 'settings.json')
await writeFile(realPath, '{}\n', 'utf-8')
await symlink(realPath, linkPath)
await writeHooksJsonAsync(linkPath, { hooks: { Stop: [] } })
const { lstat } = await import('node:fs/promises')
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
expect(JSON.parse(await readFile(realPath, 'utf-8')).hooks).toEqual({ Stop: [] })
})
it.skipIf(process.platform === 'win32')(
'follows a symlinked config to its real path instead of replacing the link',
async () => {
const { symlink } = await import('node:fs/promises')
const realPath = join(state.home, 'dotfiles-settings.json')
const linkPath = join(state.home, 'settings.json')
await writeFile(realPath, '{}\n', 'utf-8')
await symlink(realPath, linkPath)
await writeHooksJsonAsync(linkPath, { hooks: { Stop: [] } })
const { lstat } = await import('node:fs/promises')
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
expect(JSON.parse(await readFile(realPath, 'utf-8')).hooks).toEqual({ Stop: [] })
}
)

Sources: Coding guidelines, Learnings

Comment on lines +103 to +111
if (isPermissionError(error) && process.platform === 'win32') {
try {
grantDirAcl(dirname(path))
await writeFile(path, content, { encoding: 'utf-8', mode })
return
} catch {
// icacls failure is not actionable; re-throw the original EPERM
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate grantDirAcl and check for synchronous child-process or fs usage.
fd -i 'win32-utils*' src | xargs -r rg -n -C 10 'function grantDirAcl|execFileSync|spawnSync|execSync'

Repository: stablyai/orca

Length of output: 12418


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation and call sites without executing repository code.
printf '--- locate file ---\n'
fd -i 'hooks-json-async-write.ts' src

printf '\n--- outline ---\n'
ast-grep outline src/main/agent-hooks/hooks-json-async-write.ts --view compact || true

printf '\n--- relevant source lines ---\n'
sed -n '1,150p' src/main/agent-hooks/hooks-json-async-write.ts

printf '\n--- call sites of grantDirAcl and execution-mode related async path imports ---\n'
rg -n -C 3 'grantDirAclAsync|grantDirAcl\([' src/main src/test src/e2e 2>/dev/null || true

Repository: stablyai/orca

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation and call sites without executing repository code.
echo '--- locate file ---'
fd -i 'hooks-json-async-write.ts' src || true

echo
echo '--- outline ---'
ast-grep outline src/main/agent-hooks/hooks-json-async-write.ts --view compact || true

echo
echo '--- relevant source lines ---'
sed -n '1,160p' src/main/agent-hooks/hooks-json-async-write.ts

echo
echo '--- call sites of grantDirAcl and async ACL helper imports ---'
rg -n -C 4 'grantDirAclAsync|grantDirAcl\(|execFileWithoutBlocking|writeWithAclRetry' src/main src/test src/e2e 2>/dev/null || true

Repository: stablyai/orca

Length of output: 24803


Use the async ACL retry path or make ACL grants async.

writeWithAclRetry imports and calls synchronous grantDirAcl, which runs execFileSync; Windows identity resolution also uses execFileSync. A recursive ACL grant can wait up to 60s, so the EPERM fallback blocks the async main-thread write path. Route this path to the non-blocking ACL implementation instead.

Comment on lines +145 to +153
export function writeHooksJsonAsync(
configPath: string,
config: HooksConfig,
options?: { preserveMode?: boolean }
): Promise<void> {
// Why: key on the caller's path, not the resolved one — the symlink probe is
// itself part of the critical section.
return serializeAtomicFileWrite(configPath, () => writeHooksJsonNow(configPath, config, options))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List call sites of the sync and async hooks-json writers to find paths written by both.
rg -nP --type=ts '\b(writeHooksJson|writeHooksJsonAsync|writeManagedScript|writeManagedScriptAsync)\s*\(' -g '!**/*.test.ts' -C 2
# Inspect the shared serialization map implementation.
fd -i 'fs-utils*' src/main/codex-accounts | xargs -r rg -n -C 8 'serializeAtomicFileWrite'

Repository: stablyai/orca

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -u

echo "== repo files matching hooks/json/fs utils =="
git ls-files | rg -i 'hooks-json|installer-utils|fs-utils|agent-hooks' | sed -n '1,120p'

echo
echo "== writer references (all tracked files, excluding tests if present) =="
rg -nP -e '\b(writeHooksJson|writeHooksJsonAsync|writeManagedScript|writeManagedScriptAsync)\s*\(' . -g '!**/*.test.ts' -g '!**/*.spec.ts' -C 3 || true

echo
echo "== serializeAtomicFileWrite references =="
rg -nP -e 'serializeAtomicFileWrite' . -g '!**/*.test.ts' -g '!**/*.spec.ts' -C 6 || true

Repository: stablyai/orca

Length of output: 37294


🏁 Script executed:

#!/bin/bash
set -u

echo "== hook config path constants/helpers =="
rg -n -C 5 'hooksWritePath|hooksJsonPath|getManagedJsonPath|configPath|hookConfigWritePath|writeHooksJsonPath|HooksWritePath' src/src | sed -n '1,220p' || true
rg -n -C 5 'hooksWritePath|hooksJsonPath|getManagedJsonPath|configPath|hookConfigWritePath|writeHooksJsonPath|writeHooksJsonAsync|writeHooksJson\(' src/main | sed -n '1,260p' || true

echo
echo "== hook-service configPath definitions =="
for f in src/main/**/hook-service.ts; do
  name=$(basename "$f")
  echo "--- $name ---"
  rg -n -C 8 'const configPath|let configPath|function .*config|this\.(status|getConfig)|writeHooksJson(Async?)\(' "$f" | sed -n '1,220p' || true
done

echo
echo "== target callers and resolved path setup =="
for f in src/main/command-code/hook-service.ts src/main/droid/hook-service.ts src/main/copilot/hook-service.ts src/main/cursor/hook-service.ts src/main/gemini/hook-service.ts src/main/antigravity/hook-service.ts src/main/grok/hook-service.ts src/main/command-code/hook-service.ts src/main/droid/hook-service.ts src/main/copilot/hook-service.ts; do
  if git ls-files -s "$f" >/dev/null; then
    echo "--- $f ---"
    rg -n -C 6 'const configPath|let configPath|statusPath|get.*Path|install|remove|writeHooksJson' "$f" | sed -n '1,160p' || true
  fi
done

echo
echo "== codex hooks path generation definitions =="
rg -n -C 8 'hooksWritePath|hooksJsonPath|writeCodexHooksJson|writeHooksJson\(' src/main/codex src/main/codex-accounts src/main/codex-real-home-hook-install.ts | sed -n '1,240p' || true

Repository: stablyai/orca

Length of output: 50370


Include the synchronous hooks-JSON writers in atomic-file serialization.

writeHooksJsonAsync chains on config paths with serializeAtomicFileWrite, but writeHooksJson bypasses that chain in the managed hook services and Codex install paths. A synchronous write can occur while the async chain is reading and renaming, so the earlier writer can publish stale or lost content. Add writeHooksJson to the same per-path critical section, or replace the sync writer if all current consumers require the async path.

Comment on lines +113 to +117
async function waitForDeferredRealpath(): Promise<void> {
for (let attempt = 0; attempt < 50 && deferredNativeRealpaths.resume.length === 0; attempt++) {
await new Promise((fulfill) => setTimeout(fulfill, 0))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Determine which realpath API the trust-preset writers use.
rg -n -C3 'realpath' src/main/agent-trust-presets.ts src/main/codex/config-toml-trust.ts src/shared/secure-file.ts

Repository: stablyai/orca

Length of output: 8874


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg 'agent-trust-presets-off-main-thread\.test\.ts|codex-accounts/fs-utils\.ts|agent-trust-presets\.ts|config-toml-trust\.ts$'

echo
echo "== test outline =="
ast-grep outline src/main/agent-trust-presets-off-main-thread.test.ts --view expanded || true

echo
echo "== relevant test sections =="
sed -n '80,135p' src/main/agent-trust-presets-off-main-thread.test.ts
sed -n '280,335p' src/main/agent-trust-presets-off-main-thread.test.ts

echo
echo "== fs-utils realpathNativeAsync =="
sed -n '1,140p' src/main/codex-accounts/fs-utils.ts

Repository: stablyai/orca

Length of output: 9435


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== test mock setup =="
sed -n '1,105p' src/main/agent-trust-presets-off-main-thread.test.ts

echo
echo "== agent-trust-presets async paths =="
rg -n -C3 'realpathNativeAsync|realpath' src/main/agent-trust-presets.ts

echo
echo "== fs-utils realpathNativeAsync full section =="
rg -n -C10 'export function realpathNativeAsync|function realpathNativeAsync|realpathNativeAsync' src/main/codex-accounts/fs-utils.ts

echo
echo "== test mock lines with realpath =="
rg -n -C2 "realpathSync|realpathCallback|realpath\\(|node:fs|vi\\.mock" src/main/agent-trust-presets-off-main-thread.test.ts

Repository: stablyai/orca

Length of output: 10074


Fail fast when deferred realpaths are not parked.

waitForDeferredRealpath() can return after 50 zero-timeout cycles with deferredNativeRealpaths.resume still empty. Add an assertion after the loop so a broken realpath.native mock cannot make the interleaving/hang tests pass without exercising the real code path.

Comment on lines +386 to 396
remove(): Promise<AgentHookInstallStatus> {
return serializePluginMutation(async () => {
const pluginPath = getPluginPath()
const state = await readLocalPluginState(pluginPath)
if (state.kind === 'managed') {
await unlink(pluginPath)
return this.getStatus()
}
return statusFromState(pluginPath, state)
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Tolerate a concurrent delete in remove().

readLocalPluginState and unlink are separate operations. If another process deletes the plugin file between the two calls, unlink rejects with ENOENT and the rejection escapes remove(). The caller then reports an error status for a file that is already absent.

Ignore missing-path errors from unlink.

🛡️ Proposed fix
       const state = await readLocalPluginState(pluginPath)
       if (state.kind === 'managed') {
-        await unlink(pluginPath)
+        await unlink(pluginPath).catch((error: unknown) => {
+          if (!isMissingPath(error)) {
+            throw error
+          }
+        })
         return this.getStatus()
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
remove(): Promise<AgentHookInstallStatus> {
return serializePluginMutation(async () => {
const pluginPath = getPluginPath()
const state = await readLocalPluginState(pluginPath)
if (state.kind === 'managed') {
await unlink(pluginPath)
return this.getStatus()
}
return statusFromState(pluginPath, state)
})
}
remove(): Promise<AgentHookInstallStatus> {
return serializePluginMutation(async () => {
const pluginPath = getPluginPath()
const state = await readLocalPluginState(pluginPath)
if (state.kind === 'managed') {
await unlink(pluginPath).catch((error: unknown) => {
if (!isMissingPath(error)) {
throw error
}
})
return this.getStatus()
}
return statusFromState(pluginPath, state)
})
}

Comment on lines +55 to +64
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-kimi-sync-fs-'))
kimiHome = join(home, '.kimi-code')
originalHome = process.env.HOME
originalKimiHome = process.env.KIMI_CODE_HOME
process.env.HOME = home
process.env.KIMI_CODE_HOME = kimiHome
hangReads.value = false
stalledRoot.value = home
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Setting HOME does not redirect homedir() on Windows.

os.homedir() reads USERPROFILE on Windows, not HOME. KIMI_CODE_HOME covers the config path, but the managed script path resolves under ~/.orca. On Windows, install() then writes into the real user profile and stalledRoot.value = home never matches those paths, so the syncFsCalls assertions no longer cover the script write.

src/main/minimax/minimax-cookie-store-off-main-thread.test.ts mocks node:os homedir() for the same reason. Use the same approach here, or set USERPROFILE as well.

🔧 Minimal fix
     originalHome = process.env.HOME
+    originalUserProfile = process.env.USERPROFILE
     originalKimiHome = process.env.KIMI_CODE_HOME
     process.env.HOME = home
+    process.env.USERPROFILE = home
     process.env.KIMI_CODE_HOME = kimiHome

Restore USERPROFILE in afterEach in the same way as HOME.

As per coding guidelines: "code, commands, and scripts must work on macOS, Linux, and Windows".

Source: Coding guidelines

Comment on lines +106 to +132
async function writeConfigToml(configPath: string, text: string): Promise<void> {
const dir = dirname(configPath)
mkdirSync(dir, { recursive: true })
if (existsSync(configPath)) {
try {
if (readFileSync(configPath, 'utf-8') === text) {
return
}
} catch {
// Fall through to the atomic write path.
await mkdir(dir, { recursive: true })
try {
if ((await readFile(configPath, 'utf-8')) === text) {
return
}
} catch {
// Absent or unreadable: fall through to the atomic write path.
}
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, text, 'utf-8')
if (existsSync(configPath)) {
copyFileSync(configPath, `${configPath}.bak`)
}
renameSync(tmpPath, configPath)
} finally {
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// best effort
await writeFile(tmpPath, text, 'utf-8')
try {
await copyFile(configPath, `${configPath}.bak`)
} catch (error) {
// No prior config to back up; any other failure still aborts the write.
if (!isMissingPath(error)) {
throw error
}
}
await rename(tmpPath, configPath)
} finally {
// ENOENT after a successful rename is the normal case.
await unlink(tmpPath).catch(() => {})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict permissions on the temporary file and the .bak copy.

config.toml holds user credentials; src/main/kimi/hook-service.test.ts line 79 asserts api_key = "sk-secret" survives an install. writeFile creates the temporary file with the default mode (0o666 minus umask), and rename keeps that mode on config.toml. copyFile also creates config.toml.bak with default permissions, and the backup is never removed. On a multi-user host, other accounts can read the key.

Create the temporary file with mode 0o600, and apply the same mode to the backup.

🔒️ Proposed fix
   const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
   try {
-    await writeFile(tmpPath, text, 'utf-8')
+    await writeFile(tmpPath, text, { encoding: 'utf-8', mode: 0o600 })
     try {
       await copyFile(configPath, `${configPath}.bak`)
+      await chmod(`${configPath}.bak`, 0o600)
     } catch (error) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function writeConfigToml(configPath: string, text: string): Promise<void> {
const dir = dirname(configPath)
mkdirSync(dir, { recursive: true })
if (existsSync(configPath)) {
try {
if (readFileSync(configPath, 'utf-8') === text) {
return
}
} catch {
// Fall through to the atomic write path.
await mkdir(dir, { recursive: true })
try {
if ((await readFile(configPath, 'utf-8')) === text) {
return
}
} catch {
// Absent or unreadable: fall through to the atomic write path.
}
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, text, 'utf-8')
if (existsSync(configPath)) {
copyFileSync(configPath, `${configPath}.bak`)
}
renameSync(tmpPath, configPath)
} finally {
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// best effort
await writeFile(tmpPath, text, 'utf-8')
try {
await copyFile(configPath, `${configPath}.bak`)
} catch (error) {
// No prior config to back up; any other failure still aborts the write.
if (!isMissingPath(error)) {
throw error
}
}
await rename(tmpPath, configPath)
} finally {
// ENOENT after a successful rename is the normal case.
await unlink(tmpPath).catch(() => {})
}
}
async function writeConfigToml(configPath: string, text: string): Promise<void> {
const dir = dirname(configPath)
await mkdir(dir, { recursive: true })
try {
if ((await readFile(configPath, 'utf-8')) === text) {
return
}
} catch {
// Absent or unreadable: fall through to the atomic write path.
}
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
await writeFile(tmpPath, text, { encoding: 'utf-8', mode: 0o600 })
try {
await copyFile(configPath, `${configPath}.bak`)
await chmod(`${configPath}.bak`, 0o600)
} catch (error) {
// No prior config to back up; any other failure still aborts the write.
if (!isMissingPath(error)) {
throw error
}
}
await rename(tmpPath, configPath)
} finally {
// ENOENT after a successful rename is the normal case.
await unlink(tmpPath).catch(() => {})
}
}

Comment on lines +162 to 200
/** Sync twin kept for the MiniMax config resolver, which RateLimitService calls synchronously. */
export function readMiniMaxSessionCookie(): string | null {
if (cachedMiniMaxCookie !== null) {
return cachedMiniMaxCookie
}
const keyPath = getMiniMaxCookiePath()
if (!existsSync(keyPath)) {
return null
}
// Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
// failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
let raw: Buffer
try {
hardenExistingSecureFile(keyPath)
// Why: a single read replaces existsSync+readFileSync; ENOENT already means "not configured".
raw = readFileSync(keyPath)
} catch (error) {
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
if (isMissingFileError(error)) {
return null
}
throw new Error('MiniMax session cookie could not be decrypted')
}
try {
const raw = readFileSync(keyPath)
const envelope = decodeCookieEnvelope(raw)
cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
scheduleCookieRehardening(keyPath)
cachedMiniMaxCookie = decodeStoredCookie(raw)
return cachedMiniMaxCookie
}

export async function readMiniMaxSessionCookieAsync(): Promise<string | null> {
if (cachedMiniMaxCookie !== null) {
return cachedMiniMaxCookie
}
const keyPath = getMiniMaxCookiePath()
let raw: Buffer
try {
raw = await readFile(keyPath)
} catch (error) {
console.error('[minimax] failed to decode/decrypt session cookie', error)
if (isMissingFileError(error)) {
return null
}
throw new Error('MiniMax session cookie could not be decrypted')
}
scheduleCookieRehardening(keyPath)
cachedMiniMaxCookie = decodeStoredCookie(raw)
return cachedMiniMaxCookie
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cleared MiniMax cookie can return to the in-memory cache through the read path. Both sites trace to one root cause: readMiniMaxSessionCookieAsync publishes cachedMiniMaxCookie without consulting miniMaxCookieCacheGeneration, so a read that overlaps a clear restores the cleared credential, and no test exercises that ordering.

  • src/main/minimax/minimax-cookie-store.ts#L162-L200: capture miniMaxCookieCacheGeneration before the readFile await and assign cachedMiniMaxCookie at Line 198 only when the generation is unchanged.
  • src/main/minimax/minimax-cookie-store.test.ts#L134-L154: add a test that holds readFileMock pending, completes clearMiniMaxSessionCookieAsync(), releases the read, and asserts readMiniMaxSessionCookie() returns null.
📍 Affects 2 files
  • src/main/minimax/minimax-cookie-store.ts#L162-L200 (this comment)
  • src/main/minimax/minimax-cookie-store.test.ts#L134-L154

Comment thread src/shared/secure-file.ts
Comment on lines +175 to +178
} catch (error) {
await rm(tmpFile, { force: true })
throw error
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not let the temp-file cleanup replace the original error.

rm at Line 176 is awaited inside the catch. force: true suppresses ENOENT only. An EACCES, EPERM, or EBUSY rejection replaces the original write error and skips the throw error at Line 177. The caller then sees a cleanup failure instead of the real cause.

Wrap the cleanup so the original error always propagates.

🐛 Proposed fix
   } catch (error) {
-    await rm(tmpFile, { force: true })
+    await rm(tmpFile, { force: true }).catch(() => undefined)
     throw error
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
await rm(tmpFile, { force: true })
throw error
}
} catch (error) {
await rm(tmpFile, { force: true }).catch(() => undefined)
throw error
}

Comment thread src/shared/secure-file.ts
Comment on lines +221 to +233
async function applySecurePathRestrictionAsync(
targetPath: string,
isDirectory: boolean,
platform: NodeJS.Platform,
sync: boolean
): Promise<boolean> {
// Why: Windows hardening is a PowerShell spawn, not an fs syscall — out of the stalled-mount blast radius, so reuse it verbatim.
if (platform === 'win32') {
return applySecurePathRestriction(targetPath, isDirectory, platform, sync)
}
await chmod(targetPath, isDirectory ? 0o700 : 0o600)
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Align the async restriction contract with the sync twin: return false instead of rejecting.

applySecurePathRestrictionAsync returns Promise<boolean>, but the POSIX branch at Line 231 cannot return false. If chmod fails, the promise rejects.

The sync twin applySecurePathRestriction returns a boolean, and writeSecureFile Line 102 branches on it: a failed restriction skips the cache update and the write still succeeds. The async path behaves differently. At Line 172 the rename at Line 170 has already published the credential. A chmod rejection there jumps to the catch at Line 175, removes a temp file that no longer exists, and rethrows. The caller reports a failed save while the credential file is on disk, possibly with inherited permissions.

saveMiniMaxSessionCookieAsync in src/main/minimax/minimax-cookie-store.ts Lines 136-143 then skips its cache update and propagates the error to the IPC handler, so the UI reports failure for a save that persisted.

Catch the chmod failure and return false, matching the sync twin.

🔒 Proposed fix
 async function applySecurePathRestrictionAsync(
   targetPath: string,
   isDirectory: boolean,
   platform: NodeJS.Platform,
   sync: boolean
 ): Promise<boolean> {
   // Why: Windows hardening is a PowerShell spawn, not an fs syscall — out of the stalled-mount blast radius, so reuse it verbatim.
   if (platform === 'win32') {
     return applySecurePathRestriction(targetPath, isDirectory, platform, sync)
   }
-  await chmod(targetPath, isDirectory ? 0o700 : 0o600)
-  return true
+  try {
+    await chmod(targetPath, isDirectory ? 0o700 : 0o600)
+    return true
+  } catch {
+    return false
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function applySecurePathRestrictionAsync(
targetPath: string,
isDirectory: boolean,
platform: NodeJS.Platform,
sync: boolean
): Promise<boolean> {
// Why: Windows hardening is a PowerShell spawn, not an fs syscall — out of the stalled-mount blast radius, so reuse it verbatim.
if (platform === 'win32') {
return applySecurePathRestriction(targetPath, isDirectory, platform, sync)
}
await chmod(targetPath, isDirectory ? 0o700 : 0o600)
return true
}
async function applySecurePathRestrictionAsync(
targetPath: string,
isDirectory: boolean,
platform: NodeJS.Platform,
sync: boolean
): Promise<boolean> {
// Why: Windows hardening is a PowerShell spawn, not an fs syscall — out of the stalled-mount blast radius, so reuse it verbatim.
if (platform === 'win32') {
return applySecurePathRestriction(targetPath, isDirectory, platform, sync)
}
try {
await chmod(targetPath, isDirectory ? 0o700 : 0o600)
return true
} catch {
return false
}
}

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR converts 28 synchronous filesystem calls on the Electron main thread to async equivalents, moving them onto libuv's threadpool where a stalled SMB/NFS mount parks a threadpool slot instead of freezing the entire app. UV_THREADPOOL_SIZE is raised to 16 as headroom, and the probe script is included as reproducible evidence.

  • Serialization infrastructure: serializeAtomicFileWrite (global per-path chain), runExclusive (profile-index), chainSecureMutation, and module-level pendingMutation queues replace the single-thread ordering guarantee; each async twin is keyed to the same global map its sync sibling uses.
  • Sync/async coexistence: Profile-index writes use an epoch counter + inFlightAsyncTmpFiles veto; Copilot/Cursor/Codex trust writes move only the realpath off-thread while keeping the read-modify-write synchronous.
  • New async twins across 14 agents: readHooksJsonAsync, writeHooksJsonAsync, writeManagedScriptAsync, and per-service installAsync/removeAsync/getStatusAsync methods added.

Confidence Score: 4/5

Safe to merge with one fix: the async hooks-json reader classifies ENOTDIR as unreadable rather than absent, causing agents to report an error state instead of not-installed when a config path parent component is a non-directory.

The async-conversion work is thorough and mechanically sound: serialization chains, veto mechanisms, and generation counters are all correctly wired. One behavioral gap in readHooksJsonWithRawAsync maps only ENOENT to missingConfig(), diverging from the sync twin's existsSync which treats ENOTDIR identically. Every other async absence-check in the PR already handles both codes — this is a targeted one-line fix.

Files Needing Attention: src/main/agent-hooks/hooks-json-read.ts needs the ENOTDIR case added to the async twin's error mapping before merge.

Important Files Changed

Filename Overview
src/main/agent-hooks/hooks-json-read.ts Added async twin readHooksJsonWithRawAsync; only maps ENOENT to missingConfig, diverging from sync twin's existsSync which treats ENOTDIR/EACCES the same way
src/main/rate-limits/grok-auth.ts Added readGrokAuthSessionAsync and toGrokAccountStatus; async twin uses a two-step access+readFile pattern with minor TOCTOU window
src/main/codex-accounts/fs-utils.ts Added writeFileAtomicallyAsync and the core serializeAtomicFileWrite chain; Windows retry paths mirror the sync twin correctly
src/shared/secure-file.ts Added writeSecureFileAsync, removeSecureFileAsync, hardenExistingSecureFileAsync; per-path promise chain correctly serializes mutations
src/main/agent-hooks/hooks-json-async-write.ts New async twin of the hooks.json writer; uses serializeAtomicFileWrite and correctly handles backup-before-rename atomicity
src/main/orca-profiles/profile-index-async-store.ts New async store with epoch+veto mechanism to handle sync/async writer races; serializes per-path with runExclusive
src/main/git/worktree-shared-directories.ts Added async cache with single-flight refresh serving stale values when a refresh is in flight; handles sync-twin revision bumps correctly
src/main/agent-hooks/managed-agent-hook-controls.ts install/remove now async; module-level serializeMutation chain prevents concurrent operations from interleaving
src/main/libuv-threadpool-size.ts Raises UV_THREADPOOL_SIZE to 16 at import time; imported first in index.ts to fix pool size before it is lazily created
src/main/ipc/filesystem-auth.ts authorizeExternalPath now async; adds awaitPendingCanonicalizationsFor so reads are never denied while canonicalization is in flight
src/main/orca-profiles/profile-index-store.ts Sync bootstrap kept; calls claimInFlightAsyncIndexTmpPath+unlinkSync before every write to veto async renames in flight

Reviews (1): Last reviewed commit: "fix(main): move freeze-prone sync fs off..." | Re-trigger Greptile

try {
raw = await readFile(configPath, 'utf-8')
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? missingConfig() : unreadableConfig()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Only ENOENT is mapped to missingConfig(), but the sync twin's existsSync returns false for ENOTDIR and EACCES too, routing them all to missingConfig(). Every other async absence-check added in this PR (e.g., isMissingFileError in minimax, grokAuthReadErrorResult in grok-auth) checks both codes. Without the ENOTDIR case, a hooks.json path whose parent component is a regular file returns null config (error) instead of {} (absent), making agents report an error state rather than "not installed".

Suggested change
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? missingConfig() : unreadableConfig()
const code = (error as NodeJS.ErrnoException).code
return code === 'ENOENT' || code === 'ENOTDIR' ? missingConfig() : unreadableConfig()

Comment on lines +107 to +121
// RateLimitService snapshot can never disagree about being signed in.
if (!(await pathIsReachable(path))) {
return { status: 'missing' }
}
let contents: string
try {
contents = await readFile(path, 'utf-8')
} catch (err) {
return grokAuthReadErrorResult(err)
}
return parseGrokAuthFile(contents)
}

async function pathIsReachable(path: string): Promise<boolean> {
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Two-syscall existence check introduces a TOCTOU window and an EACCES divergence

readGrokAuthSessionAsync does access(path) then readFile(path). grokAuthReadErrorResult already returns { status: 'missing' } for ENOENT from readFile, so the access probe is redundant in the common path. If access succeeds but readFile then fails with EACCES (file became unreadable between the two calls), grokAuthReadErrorResult returns { status: 'error' }, while the sync twin's existsSync would have returned false (missing). A single readFile with both ENOENT and EACCES mapped to missing would close the window and match the sync twin.

This branch has not been deployed

No deployments
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