Skip to content

fix(desktop): stop preset-command leak in useV2WorkspaceRun#4582

Merged
saddlepaddle merged 2 commits into
mainfrom
fix-workspace-run-leak
May 14, 2026
Merged

fix(desktop): stop preset-command leak in useV2WorkspaceRun#4582
saddlepaddle merged 2 commits into
mainfrom
fix-workspace-run-leak

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented May 14, 2026

Summary

  • Bisected renderer freezes on rapid workspace switches to commit ac2dd469f (Add preset-backed workspace run #4335, "Add preset-backed workspace run")
  • Root cause: useV2WorkspaceRun resolves preset commands through an async useEffect + useState pattern that produces a render cycle and an unbounded Promise/closure leak
  • Fix: replace with a useMemo that calls the already-synchronous resolvePresetCommands directly — same output, no state, no effect, no cycle

Root cause

In useV2WorkspaceRun.ts:

useEffect(() => {
  let cancelled = false;
  async function resolveCommands() {
    const entries = await Promise.all(matchedPresets.map(async (preset) => ({
      id: preset.id,
      commands: await resolvePresetCommands(preset),  // already sync, `await` is a no-op
    })));
    if (cancelled) return;
    const next: Record<string, string[]> = {};
    for (const entry of entries) next[entry.id] = entry.commands;
    setResolvedPresetCommandsById(next);  // fresh object every run → never equal → always re-renders
  }
  void resolveCommands();
  return () => { cancelled = true; };
}, [matchedPresets, resolvePresetCommands]);

In the parent (useV2PresetExecution):

  • matchedPresets = useMemo(..., [allPresets, projectId]) where allPresets comes from a useLiveQuery — every Electric sync emits a new array reference → new matchedPresets identity
  • resolvePresetCommands = useCallback(..., [agentCommandsById]) where agentCommandsById rebuilds on every useV2AgentConfigs refetch → new callback identity

Each tanstack-db update or agent-config refetch fires the effect; setResolvedPresetCommandsById(newObject) always re-renders; the re-render produces fresh upstream references; effect fires again. Each cycle allocates an async closure, a Promise.all chain, N preset-resolver promises, N PromiseReactions, and a state object. The cancelled flag suppresses stale setState but does not cancel in-flight promises — their captured closures live until GC.

The leak compounds on workspace switch because the WorkspaceTrpcProvider key remount forces every upstream query to refetch immediately.

Diagnostic data

Heap snapshot diff between idle baseline and mid-leak showed:

  • Promise instances: +513
  • PromiseReaction: +454
  • EventListener: +218 (retained, ~0.6/sec idle leak)
  • StackFrameInfo: +1249 (V8 trace info from accumulated closures)

CDP Performance.getMetrics showed JS heap and listener counts climbing steadily during idle (no user input), then spiking 5–10× during a workspace switch.

resolvePresetCommands in useV2PresetExecution.ts:76-84 is synchronous: it reads from an in-memory Map<string, string> keyed by agentId. There's no reason for the consumer to await it, store the resolved result, or rebuild that state via an effect — a single derived useMemo produces the same value with no side effects.

Why this matches "introduced since 1.8.8"

ac2dd469f landed on May 9 2026, between v1.8.8 and v1.8.9. v1.8.8 has no useV2WorkspaceRun, so workspace switching in v1.8.8 does not trigger this code path. Bisect (desktop-v1.8.8 good → figure-out-crashes bad) converged on this single commit.

Test plan

  • Open multiple v2 workspaces, switch rapidly between them — renderer stays responsive, no OOM
  • Open a project with agent-backed presets, edit the agent command in /settings/agents, confirm preset commands still pick up the new value (lazy useMemo recomputes when agentCommandsById identity changes)
  • Click the workspace Run button with no agent-backed preset → still runs preset.commands
  • Click the workspace Run button with an agent-backed preset whose live agent has a custom command → still runs the agent command

Summary by cubic

Fixes renderer freezes and a Promise/closure leak when switching v2 workspaces by making preset command resolution fully synchronous and removing the leaking async state in useV2WorkspaceRun. Commands now resolve via a memo using the useV2AgentConfigs cache, eliminating re-render loops.

  • Bug Fixes
    • Replaced async useEffect + useState in useV2WorkspaceRun with useMemo(resolvePresetCommands).
    • Made resolvePresetCommands synchronous and removed the live getHostServiceClientByUrl(...).settings.agentConfigs.list.query() fetch and no-op awaits.
    • Updated types/callsites to return string[]; behavior unchanged—presets still reflect agent command updates.

Written for commit 30296d7. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Preset command resolution streamlined to run synchronously and rely on in-memory agent data, reducing background work and eliminating redundant lookups.
  • Bug Fixes / Reliability
    • Fewer remote requests during preset execution, improving stability and making command resolution more consistent with fallback behavior.

Review Change Stack

The async useEffect + setResolvedPresetCommandsById state pattern in
useV2WorkspaceRun creates a render cycle: matchedPresets and
resolvePresetCommands get fresh references on every tanstack-db / tRPC
update, the effect fires, setResolvedPresetCommandsById always receives a
new object literal (no Object.is bail-out), state change re-renders the
hook, parent re-derives matchedPresets, effect fires again. Each cycle
allocates a fresh async closure, Promise.all chain, N preset-resolver
promises, and PromiseReactions; the `cancelled` flag suppresses setState
but does not cancel in-flight promises — their captured closures live
until GC.

Bisected to ac2dd46 as the first commit producing renderer freezes on
rapid workspace switches (heap and listener counts climb until the
renderer OOMs; freezes amplify on switch because remount forces all
upstream queries to refetch).

resolvePresetCommands is already synchronous (returns string[]), so the
async/state plumbing is unnecessary. Replace with a useMemo that calls
resolvePresetCommands directly — same output, no state, no effect, no
cycle.
@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 14, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34f3fc04-7046-469e-afee-cc8fb7020ecc

📥 Commits

Reviewing files that changed from the base of the PR and between f950a2b and 30296d7.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts

📝 Walkthrough

Walkthrough

Both hooks now resolve preset commands synchronously from in-memory state: the workspace hook removes effect/state-based async resolution and derives resolvedMatchedPresets via useMemo; the preset-execution hook uses the cached agents array to build launch commands and removes host-service requests.

Changes

Preset command resolution refactoring

Layer / File(s) Summary
Synchronous preset resolution via useMemo
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts
Removes resolvedPresetCommandsById state and the useEffect that asynchronously resolved preset commands with cancellation and per-preset fallback. Adds a useMemo that maps matchedPresets and assigns commands: resolvePresetCommands(preset). React imports updated to drop useEffect.
Preset execution synchronous resolver
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts
Changes resolvePresetCommands from async to sync, using the cached agents array to build linked-agent launch commands (fallback to preset.commands). Removes getHostServiceClientByUrl import and stops awaiting command resolution in executePreset.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 From awaiting hosts to memos inline,

I hopped through agents, trimmed async time.
Commands now return with a synchronous cheer,
No more effects — the path is clear! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing a preset-command leak in useV2WorkspaceRun by replacing an async effect pattern with synchronous memo resolution.
Description check ✅ Passed The description covers root cause analysis, diagnostic data, technical details, and test plan; however, it deviates significantly from the template structure (missing explicit sections like Related Issues and Type of Change checkboxes).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-workspace-run-leak

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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 and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 14, 2026

Greptile Summary

Replaces the leaking async useEffect + useState preset-resolution cycle in useV2WorkspaceRun with a synchronous useMemo, and makes resolvePresetCommands in useV2PresetExecution fully synchronous by removing the inline getHostServiceClientByUrl(…).settings.agentConfigs.list.query() fetch in favour of the already-cached useV2AgentConfigs result.

  • resolvePresetCommands is now (preset) => string[] — no Promise, no network call — resolving against the in-memory agents array whose cache is kept warm by useV2AgentConfigs (staleTime: Infinity, invalidated on every Settings → Agents mutation).
  • useV2WorkspaceRun drops useEffect, resolvedPresetCommandsById state, and the async closure chain; resolvedMatchedPresets is now a plain useMemo that calls resolvePresetCommands inline, eliminating the Promise/closure leak and the re-render cycle that caused renderer freezes on rapid workspace switches.

Confidence Score: 5/5

Safe to merge — the follow-up commit correctly makes resolvePresetCommands synchronous, closing the gap identified in the earlier review thread.

Both files are clean. resolvePresetCommands is now a straightforward in-memory lookup against the useV2AgentConfigs-cached agents array, returning string[] as declared. The leaking useEffect + setState cycle is fully removed, all imports are tidy, and the fallback path behaves identically to the old code.

No files require special attention.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts Made resolvePresetCommands synchronous by reading from the useV2AgentConfigs-cached agents array instead of issuing a live tRPC query. Removed the getHostServiceClientByUrl import and activeHostUrl dependency from the callback. executePreset drops the no-op await. Changes are correct and complete.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts Removed async useEffect + useState(Record<string,string[]>) preset-resolution machinery. resolvedMatchedPresets is now a useMemo that calls the now-synchronous resolvePresetCommands directly. Interface type updated to string[]. useEffect removed from imports. No residual dead imports or logic.

Reviews (2): Last reviewed commit: "Make resolvePresetCommands sync, trustin..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts (1)

70-75: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

UseV2WorkspaceRunArgs.resolvePresetCommands type signature and usage are misaligned—causing Promise to flow into sync operations.

At line 74, resolvePresetCommands is typed as (preset: V2TerminalPresetRow) => Promise<string[]>, but the new useMemo at line 114 calls it without await:

commands: resolvePresetCommands(preset),

This assigns a Promise<string[]> to the commands field instead of string[]. Since WorkspaceRunPresetLike.commands is defined as string[] and presetToWorkspaceRun (in workspace-run-definition.ts line 62) calls nonEmptyCommands(preset.commands) expecting an array, this will fail at runtime.

Tighten the type signature to match the synchronous usage:

-	resolvePresetCommands: (preset: V2TerminalPresetRow) => Promise<string[]>;
+	resolvePresetCommands: (preset: V2TerminalPresetRow) => string[];

The caller (useV2PresetExecution) must also be updated to return the resolved commands synchronously rather than async.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts
around lines 70 - 75, The resolvePresetCommands callback is typed async but used
synchronously; change UseV2WorkspaceRunArgs.resolvePresetCommands to return
string[] (not Promise<string[]>) and update its implementation in
useV2PresetExecution to return the resolved commands synchronously, so the
useMemo that sets commands: resolvePresetCommands(preset) provides a string[]
compatible with WorkspaceRunPresetLike.commands and presetToWorkspaceRun (which
calls nonEmptyCommands). Ensure all call sites expecting a Promise are updated
to handle a direct string[] return.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts:
- Around line 70-75: The resolvePresetCommands callback is typed async but used
synchronously; change UseV2WorkspaceRunArgs.resolvePresetCommands to return
string[] (not Promise<string[]>) and update its implementation in
useV2PresetExecution to return the resolved commands synchronously, so the
useMemo that sets commands: resolvePresetCommands(preset) provides a string[]
compatible with WorkspaceRunPresetLike.commands and presetToWorkspaceRun (which
calls nonEmptyCommands). Ensure all call sites expecting a Promise are updated
to handle a direct string[] return.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 16e8073a-3683-42d7-abf4-b51433816270

📥 Commits

Reviewing files that changed from the base of the PR and between f95b8f4 and f950a2b.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts

Greptile flagged that the previous fix unwittingly assigned a Promise to
`commands` because `resolvePresetCommands` had been made async (commit
1239d7a added an inline live `agentConfigs.list.query()` fetch). The
right structural fix is to remove that runtime live fetch entirely:

- `useV2AgentConfigs` is the cached source of truth for host agent
  configs. It uses `staleTime: Infinity` and is explicitly invalidated by
  every Settings → Agents mutation (see useV2AgentConfigs.ts comment).
- The inline `getHostServiceClientByUrl(...).settings.agentConfigs.list.query()`
  call in `resolvePresetCommands` was duplicating that exact tRPC call on
  every render, bypassing a cache that's already correct.
- That async-ness was what forced the consumer in `useV2WorkspaceRun`
  into the leaking useEffect+setState pattern.

Drop the inline live fetch, resolve against the in-memory `agents` array
synchronously. Update the `executePreset` callsite (drop the no-op
`await`) and the `UseV2WorkspaceRunArgs` type signature.

Combined with the previous commit (replacing the async effect with a
sync useMemo), this eliminates the render cycle and the Promise/closure
allocation that was driving renderer freezes on rapid workspace switch.
@saddlepaddle
Copy link
Copy Markdown
Collaborator Author

Greptile caught a real bug — the first commit on this branch assumed resolvePresetCommands was sync, but it had been rewritten as async (with an inline live agentConfigs.list.query() fetch) in #4232. The useMemo was assigning Promise<string[]> to commands, which would have silently broken agent-backed workspace runs.

I pushed a follow-up that drops the inline live fetch and makes resolvePresetCommands sync again. The reasoning:

useV2AgentConfigs (the source for agents) is a useQuery with staleTime: Infinity and is explicitly invalidated on every Settings → Agents mutation — its own docstring says so:

"Configs only change via Settings → Agents mutations that invalidate this key — staleTime: Infinity keeps the startup prefetch warm across navigation instead of every consumer refetching on mount."

The inline getHostServiceClientByUrl(...).settings.agentConfigs.list.query() inside resolvePresetCommands was calling the same exact tRPC procedure that hook already caches. It was duplicating the query on every consumer call, bypassing a cache that's already correct, and dragging the function into async-network-bound territory.

That async-ness is what forced the previous consumer (useV2WorkspaceRun) into the leaking effect+setState pattern in the first place — it was working around a problem that shouldn't have existed.

After this update:

  • resolvePresetCommands is sync (lookup in the cached agents map)
  • useV2WorkspaceRun.resolvedMatchedPresets is a plain useMemo — no state, no effect, no Promise
  • executePreset drops the no-op await
  • UseV2WorkspaceRunArgs.resolvePresetCommands type signature updated to return string[]

Re-trigger reviewers @greptile-apps when convenient.

@saddlepaddle saddlepaddle merged commit 3445061 into main May 14, 2026
10 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch

Thank you for your contribution! 🎉

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