Skip to content

fix: bound clear-done cleanup concurrency to avoid runtime freeze on large boards - #564

Merged
saoudrizwan merged 1 commit into
mainfrom
saoudrizwan/fix-clear-trash-unbounded-fanout
Jul 12, 2026
Merged

fix: bound clear-done cleanup concurrency to avoid runtime freeze on large boards#564
saoudrizwan merged 1 commit into
mainfrom
saoudrizwan/fix-clear-trash-unbounded-fanout

Conversation

@saoudrizwan

Copy link
Copy Markdown
Contributor

The problem

Clearing the Done column on a board with many tasks crashes/freezes the app. This happened on a real board with 127 done tasks: confirming "Clear done items permanently?" killed the runtime, and because the app died before the emptied board was persisted, all 127 tasks were still there on restart.

Root cause

handleConfirmClearTrash (web-ui/src/hooks/use-board-interactions.ts) fanned out cleanup for every trashed task in one unbounded Promise.all:

void (async () => {
  await Promise.all(
    taskIds.map(async (taskId) => {
      await stopTaskSession(taskId);      // tRPC runtime.stopTaskSession
      await cleanupTaskWorkspace(taskId); // tRPC workspace.deleteWorktree
    }),
  );
})();

The non-obvious part is why this is so much worse than "127 HTTP requests": the runtime tRPC client (web-ui/src/runtime/trpc-client.ts) uses httpBatchLink, which coalesces all mutations fired in the same tick into a single batched HTTP request — and the tRPC server resolves every procedure in a batch concurrently. So browser per-origin connection limits never throttle anything. One click produced:

  • 127 concurrent stopTaskSession procedures — each going through the scoped Cline task-session service / terminal manager
  • followed by waves of concurrent deleteTaskWorktree calls, each running git subprocesses (captureTaskPatch, git worktree remove, git worktree prune) against the same shared repo, contending on .git locks

This is exactly the "many subprocess spawns per task at once freezes the runtime" failure mode already documented in AGENTS.md for task-agent startup — this was the teardown-side twin of it.

The fix

Bound the fan-out with p-limit (new web-ui dependency): at most 4 per-task cleanup chains (stop → cleanup) run at a time. Since httpBatchLink batches per tick, limiting client-side concurrency directly limits server-side procedure concurrency too — at most 4 in-flight procedures instead of 127.

const limitCleanup = pLimit(CLEAR_TRASH_CLEANUP_CONCURRENCY);
await Promise.all(
  taskIds.map((taskId) =>
    limitCleanup(async () => {
      await stopTaskSession(taskId);
      await cleanupTaskWorkspace(taskId);
    }),
  ),
);

Error semantics are unchanged: both stopTaskSession and cleanupTaskWorkspace already swallow their own errors (best-effort cleanup), so nothing in the chain can reject and abandon queued tasks.

Why 4, and why client-side

  • 4 concurrent git operations on one repo is comfortably below lock-contention territory while still parallelizing the common case (a handful of done tasks clears as fast as before).
  • A server-side bulk "clear trash" endpoint would be the more thorough redesign (single request, server-controlled throttling), but it's a much bigger change touching the router/API surface for the same practical outcome. Worth considering if other bulk flows appear. The other Promise.all fan-outs in web-ui are over small, user-selected sets (e.g. linked-task starts) — this was the only site iterating an entire column.

How to test

  • New regression test in use-board-interactions.test.tsx: builds a board with 25 trashed tasks, instruments stopTaskSession/cleanupTaskWorkspace to track in-flight chains, confirms max concurrency ≤ 4 and that every task still gets stopped and cleaned. Removing the limiter makes it fail with maxInFlight = 25.
  • Manual: put 100+ tasks in Done, click the clear-done (trash) button, confirm. The app should stay responsive and the column should empty.
  • npm run typecheck, npm run lint, npm test in web-ui all pass (498 tests).

…large boards

Clearing the Done column fired stopTaskSession + cleanupTaskWorkspace for
every trashed task in a single unbounded Promise.all. Because the tRPC
client uses httpBatchLink, all same-tick mutations coalesce into one
batched request and the server executes every procedure concurrently —
on a board with 100+ done tasks that meant 100+ simultaneous session
stops and git worktree deletions against the shared repo, which froze
and crashed the app.

Wrap the per-task cleanup chain in p-limit(4) so at most four tasks are
stopped/cleaned at a time. Adds a regression test asserting the cleanup
fan-out stays bounded while still covering every task.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedp-limit@​7.3.010010010083100

View full report

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a runtime freeze when clearing the Done column on large boards by bounding the per-task cleanup fan-out with p-limit. Without this, an unbounded Promise.all over 100+ tasks caused the tRPC httpBatchLink to fire all cleanup procedures in a single batched request, overwhelming the server with concurrent git operations.

  • Introduces CLEAR_TRASH_CLEANUP_CONCURRENCY = 4 and wraps each stopTaskSession → cleanupTaskWorkspace chain inside pLimit(4) in handleConfirmClearTrash, so at most 4 task cleanup chains run concurrently regardless of column size.
  • Adds a regression test with 25 trashed tasks that instruments the two cleanup functions to track in-flight concurrency and asserts maxInFlight ≤ 4 while confirming every task is still cleaned up.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped to the clear-trash path, adds a well-tested concurrency bound, and preserves existing error-swallowing semantics.

The fix is minimal and correct: pLimit(4) is instantiated fresh per invocation, wraps the full stop→cleanup chain, and lets pLimit's queue handle backpressure. The regression test instruments both cleanup functions, tracks in-flight concurrency across the full chain lifetime, and asserts both the bound and completeness. The p-limit dependency is ESM-compatible with the project's Vite + type:module setup.

No files require special attention.

Important Files Changed

Filename Overview
web-ui/src/hooks/use-board-interactions.ts Core fix: adds pLimit(4) wrapper around the stopTaskSession→cleanupTaskWorkspace chain in handleConfirmClearTrash; minimal, well-scoped change with clear explanatory comment.
web-ui/src/hooks/use-board-interactions.test.tsx New regression test with 25 trashed tasks correctly tracks full-chain concurrency and asserts maxInFlight ≤ 4 and full coverage.
web-ui/package.json Adds p-limit@^7.3.0 as a production dependency; compatible with the project's ESM setup.
web-ui/package-lock.json Lock file adds p-limit@7.3.0 and its only transitive dependency yocto-queue@1.2.2; integrity hashes and peer requirements look correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User (click "Clear Done")
    participant H as handleConfirmClearTrash
    participant L as pLimit(4)
    participant S as stopTaskSession (tRPC)
    participant C as cleanupTaskWorkspace (tRPC)
    participant B as httpBatchLink → Server

    U->>H: confirm dialog
    H->>H: setBoard / setSessions (optimistic clear)
    H->>L: create pLimit(4) queue
    loop for each of N tasks (max 4 at a time)
        L->>S: stopTaskSession(taskId)
        S->>B: batched RPC (≤4 in flight)
        B-->>S: ok
        S-->>L: resolved
        L->>C: cleanupTaskWorkspace(taskId)
        C->>B: batched RPC (≤4 in flight)
        B-->>C: ok (git ops complete)
        C-->>L: resolved → slot released
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User (click "Clear Done")
    participant H as handleConfirmClearTrash
    participant L as pLimit(4)
    participant S as stopTaskSession (tRPC)
    participant C as cleanupTaskWorkspace (tRPC)
    participant B as httpBatchLink → Server

    U->>H: confirm dialog
    H->>H: setBoard / setSessions (optimistic clear)
    H->>L: create pLimit(4) queue
    loop for each of N tasks (max 4 at a time)
        L->>S: stopTaskSession(taskId)
        S->>B: batched RPC (≤4 in flight)
        B-->>S: ok
        S-->>L: resolved
        L->>C: cleanupTaskWorkspace(taskId)
        C->>B: batched RPC (≤4 in flight)
        B-->>C: ok (git ops complete)
        C-->>L: resolved → slot released
    end
Loading

Reviews (1): Last reviewed commit: "fix: bound clear-done cleanup concurrenc..." | Re-trigger Greptile

@saoudrizwan
saoudrizwan merged commit cf853c0 into main Jul 12, 2026
6 checks passed
@saoudrizwan
saoudrizwan deleted the saoudrizwan/fix-clear-trash-unbounded-fanout branch July 12, 2026 07:36
@RTLflat RTLflat mentioned this pull request Jul 12, 2026
5 tasks
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