Skip to content

desktop: UI freezes from per-file git spawns in VCS diff and /api/fs/list request storm #56790

Closed

Description

@ZhouRenX

Summary

The desktop app on Windows becomes completely unresponsive for minutes at a time. Logs show two compounding cases:

  1. VCS diff spawns 2 git.exe processes per changed/untracked file, sequentially (~25-30 ms per spawn on Windows). With ~1,476 dirty files a single GET /api/vcs/diff takes ~80 s, exceeding the client's 60 s timeout; the client aborts (HTTP 499) and retries immediately, pinning the server and freezing the UI.
  2. During one episode the renderer issued 21,696 /api/fs/list requests within ~4 minutes (~180 req/s peak), each recompute re-running the same git pipeline (5,245 git spawns in the same window) - even though the worktree only had ~185 dirty files at that point.

Environment

  • Desktop app 2.0.32 (bundled CLI 2.0.32); opencode --version on PATH is 1.18.29 (separate older CLI install, not used by the desktop app)
  • OS: Windows 10 Pro 22H2, build 19045.6466 (win32 x64)
  • Terminal: N/A (desktop app); server-side shell commands run via PowerShell 5.1 / cmd
  • Install/channel: packaged release, auto-update channel latest, updater reports 2.0.32 up to date
  • Active plugins: none (global config has no plugins entry; one MCP server vscode is configured)

Reproduction

Intermittent, but observed repeatedly in one day of normal use:

  1. Desktop app on Windows, project worktree inside a git repo with several hundred changed/untracked files (first observed with ~1,476 files; later with only ~185).
  2. Have a session open in that project; switch sessions / let the session create or edit files.
  3. The UI stops responding to clicks for minutes (sometimes longer).

Server log (~/.local/share/opencode/log/opencode.log) shows:

  • thousands of spawns, one pair (--numstat + --patch) per untracked file:
    spawning process command="C:\Program Files\Git\cmd\git.EXE" args="[…,"diff","--no-index","--patch",…,"--","/dev/null","<file>"]"
  • aborted diff requests:
    Sent HTTP response http.span=60175 … GET http.url=/api/vcs/diff http.status=499

Desktop renderer log shows:

  • [server-request-queue] server thrashing detected (repeated every ~10 s during an episode)
  • [global-sdk] event stream disconnected

Expected Behavior

  • Diff/status is computed with a bounded number of git invocations (batch git status / git diff for the whole tree), capped and cancellable when the client aborts.
  • No runaway /api/fs/list polling from the renderer; a timed-out request is not retried in a tight loop.

Actual Behavior

  • The server spends minutes spawning ~30-50 git processes per second; every other request queues behind it.
  • The desktop UI freezes completely (clicks do nothing) until the queue drains.
  • GET /api/vcs/diff aborts at the 60 s client timeout and immediately retries, sustaining the saturation.
  • /api/fs/list was requested ~180x/s at peak for ~4 minutes with no corresponding user action.

Additional Context

Measured from opencode.log on 2026-10-18 (UTC):

Hour git.exe spawns of which PNG asset diffs note
02:00 30,363 26,976 /api/vcs/diff aborting after ~60 s (499), retrying
03:00 20,698 14,594 until untracked file count was reduced
04:00 2,675 0
11:00 11,263 0 5,245 of them inside the 11:33-11:39Z burst
12:00 828 0
  • Aborted /api/vcs/diff spans: 60108 / 60101 / 60175 / 51125 ms (status=499).
  • /api/fs/list: 21,696 responses between 11:34:09Z and 11:38:07Z (single peak minute: 10,848 ~= 181/s), then stopped on its own; cause unknown.
  • Thrashing reports (local time): 10:50:22-10:51:12, 11:21:56-11:22:46, 12:56:52, 12:58:29, 19:34:13-19:38:00.
  • Per-spawn cost ~= 25-30 ms on this machine (derived from consecutive spawning process timestamps).
  • Reducing the dirty set (adding a 1,004-file unreferenced asset dir to .gitignore) cut a single diff pass from >60 s (endless 499 retry loop) to ~23 s - still slow - and the UI froze again later during the /api/fs/list burst with only ~185 dirty files.

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed numbers. I reproduced the per-file git spawning in /api/vcs/diff on 2.0.32, and it still happens on the latest dev (da9d436c).

    Since 2.0.32, untracked files are diffed in one batch: a few git processes in total. That batch depends on git add --intent-to-add --sparse. If that command fails, all untracked files go back to the old path of two git diff --no-index … -- /dev/null <file> processes per file, one after another. Nothing limits how many files go down that path. That matches the spawn lines in your log.

    What I ran:

    • A repo with 1,500 untracked binary .png files and a Git that rejects --sparse (as Git older than 2.45 does): 3,007 git spawns for one GET /api/vcs/diff, about 8 s on Linux. At your measured 25-30 ms per spawn on Windows, that's roughly 75-90 s, past the 60 s client timeout.
    • The same repo with a current Git, where the batch works: 7 spawns, 0.18 s.
    • After the client aborted the request (499), the server kept spawning git processes for at least several more seconds.

    The script below reproduces it. I couldn't test the Windows desktop app here, so the /api/fs/list request storm and the UI freeze itself are not confirmed yet.

    Could you share the output of git --version for C:\Program Files\Git\cmd\git.exe? If it is older than 2.45, that is very likely the trigger, and updating Git for Windows should work around it for now. If it is newer, something else is making the batch fail on Windows, and that would be useful to know.

    Related: #52953 (the same --sparse problem in snapshots) and the open PR #56527 (retry without --sparse). #55191 described a similar renderer request fan-out.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #56790: GET /api/vcs/diff falls back to 2 git spawns per untracked file
    # when the batched untracked path (`git add --intent-to-add --sparse ...`) fails.
    #
    # On Linux with git >= 2.45 the batch works (a few spawns). To exercise the fallback we put a
    # `git` wrapper first on PATH that rejects `--sparse` exactly as Git < 2.45 does (exit 129,
    # "unknown option `sparse'"); set OLDGIT=0 to run with the real git for comparison.
    #
    # Usage:  bash repro.sh                      # uses `opencode` from PATH (e.g. @opencode/[email protected])
    #         OPENCODE="bun --conditions=browser --preload=/workspace/repo/packages/tui/node_modules/@opentui/solid/scripts/preload.js /workspace/repo/packages/cli/src/index.ts" bash repro.sh
    set -uo pipefail
    OPENCODE=${OPENCODE:-opencode}
    N=${N:-1500}
    PORT=${PORT:-4199}
    OLDGIT=${OLDGIT:-1}
    ROOT=/tmp/opencode/vcsrepro-$$
    P=$ROOT/project
    export HOME=$ROOT/home XDG_DATA_HOME=$ROOT/home/data
    mkdir -p "$P" "$HOME" "$ROOT/bin"
    cd "$P"
    git init -q; git config user.email a@b; git config user.name a
    echo hi > README.md; git add .; git commit -qm init
    mkdir assets
    for i in $(seq 1 "$N"); do head -c 20000 /dev/urandom > "assets/img$i.png"; done   # untracked binary files
    
    cat > "$ROOT/bin/git" <<'EOF'
    #!/bin/bash
    for a in "$@"; do [ "$a" = "--sparse" ] && { echo "error: unknown option \`sparse'" >&2; exit 129; }; done
    exec /usr/bin/git "$@"
    EOF
    chmod +x "$ROOT/bin/git"
    [ "$OLDGIT" = 1 ] && export PATH="$ROOT/bin:$PATH"
    
    OPENCODE_PASSWORD=pw $OPENCODE serve --port "$PORT" > "$ROOT/serve.out" 2>&1 &
    SP=$!
    trap 'kill $SP 2>/dev/null' EXIT
    for i in $(seq 1 120); do curl -s -o /dev/null -u opencode:pw "http://127.0.0.1:$PORT/" && break; sleep 0.5; done
    URL="http://127.0.0.1:$PORT/api/vcs/diff?mode=working&location%5Bdirectory%5D=$P"
    LOG=$(ls -t $XDG_DATA_HOME/opencode/log/*.log 2>/dev/null | head -1)
    curl -s -o /dev/null -u opencode:pw "$URL"   # first call boots the location
    sleep 1; LOG=$(ls -t $XDG_DATA_HOME/opencode/log/*.log | head -1)
    sleep 2
    before=$(grep -c 'spawning process' "$LOG")
    curl -s -m 300 -o "$ROOT/diff.json" -u opencode:pw -w 'HTTP %{http_code} in %{time_total}s\n' "$URL"
    sleep 1
    after=$(grep -c 'spawning process' "$LOG")
    echo "untracked files: $N   git spawns for one /api/vcs/diff: $((after - before))"
    echo "of which per-file 'diff --no-index': $(tail -n +1 "$LOG" | grep 'spawning process' | tail -n $((after - before)) | grep -c 'no-index')"

    Run it: bash repro.sh

  2. ZhouRenX commented on Oct 18, 2026

    @ZhouRenX
    Author

    Thanks for the detailed numbers. I reproduced the per-file git spawning in /api/vcs/diff on 2.0.32, and it still happens on the latest dev (da9d436c).

    Since 2.0.32, untracked files are diffed in one batch: a few git processes in total. That batch depends on git add --intent-to-add --sparse. If that command fails, all untracked files go back to the old path of two git diff --no-index … -- /dev/null <file> processes per file, one after another. Nothing limits how many files go down that path. That matches the spawn lines in your log.

    What I ran:

    • A repo with 1,500 untracked binary .png files and a Git that rejects --sparse (as Git older than 2.45 does): 3,007 git spawns for one GET /api/vcs/diff, about 8 s on Linux. At your measured 25-30 ms per spawn on Windows, that's roughly 75-90 s, past the 60 s client timeout.
    • The same repo with a current Git, where the batch works: 7 spawns, 0.18 s.
    • After the client aborted the request (499), the server kept spawning git processes for at least several more seconds.

    The script below reproduces it. I couldn't test the Windows desktop app here, so the /api/fs/list request storm and the UI freeze itself are not confirmed yet.

    Could you share the output of git --version for C:\Program Files\Git\cmd\git.exe? If it is older than 2.45, that is very likely the trigger, and updating Git for Windows should work around it for now. If it is newer, something else is making the batch fail on Windows, and that would be useful to know.

    Related: #52953 (the same --sparse problem in snapshots) and the open PR #56527 (retry without --sparse). #55191 described a similar renderer request fan-out.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash

    Repro for #56790: GET /api/vcs/diff falls back to 2 git spawns per untracked file

    when the batched untracked path (git add --intent-to-add --sparse ...) fails.

    On Linux with git >= 2.45 the batch works (a few spawns). To exercise the fallback we put a

    git wrapper first on PATH that rejects --sparse exactly as Git < 2.45 does (exit 129,

    "unknown option `sparse'"); set OLDGIT=0 to run with the real git for comparison.

    Usage: bash repro.sh # uses opencode from PATH (e.g. @opencode/[email protected])

    OPENCODE="bun --conditions=browser --preload=/workspace/repo/packages/tui/node_modules/@opentui/solid/scripts/preload.js /workspace/repo/packages/cli/src/index.ts" bash repro.sh

    set -uo pipefail
    OPENCODE=${OPENCODE:-opencode}
    N=${N:-1500}
    PORT=${PORT:-4199}
    OLDGIT=${OLDGIT:-1}
    ROOT=/tmp/opencode/vcsrepro-$$
    P=$ROOT/project
    export HOME=$ROOT/home XDG_DATA_HOME=$ROOT/home/data
    mkdir -p "$P" "$HOME" "$ROOT/bin"
    cd "$P"
    git init -q; git config user.email a@b; git config user.name a
    echo hi > README.md; git add .; git commit -qm init
    mkdir assets
    for i in $(seq 1 "$N"); do head -c 20000 /dev/urandom > "assets/img$i.png"; done # untracked binary files

    cat > "$ROOT/bin/git" <<'EOF'
    #!/bin/bash
    for a in "$@"; do [ "$a" = "--sparse" ] && { echo "error: unknown option `sparse'" >&2; exit 129; }; done
    exec /usr/bin/git "$@"
    EOF
    chmod +x "$ROOT/bin/git"
    [ "$OLDGIT" = 1 ] && export PATH="$ROOT/bin:$PATH"

    OPENCODE_PASSWORD=pw $OPENCODE serve --port "$PORT" > "$ROOT/serve.out" 2>&1 &
    SP=$!
    trap 'kill $SP 2>/dev/null' EXIT
    for i in $(seq 1 120); do curl -s -o /dev/null -u opencode:pw "http://127.0.0.1:$PORT/" && break; sleep 0.5; done
    URL="http://127.0.0.1:$PORT/api/vcs/diff?mode=working&location%5Bdirectory%5D=$P"
    LOG=$(ls -t $XDG_DATA_HOME/opencode/log/.log 2>/dev/null | head -1)
    curl -s -o /dev/null -u opencode:pw "$URL" # first call boots the location
    sleep 1; LOG=$(ls -t $XDG_DATA_HOME/opencode/log/
    .log | head -1)
    sleep 2
    before=$(grep -c 'spawning process' "$LOG")
    curl -s -m 300 -o "$ROOT/diff.json" -u opencode:pw -w 'HTTP %{http_code} in %{time_total}s\n' "$URL"
    sleep 1
    after=$(grep -c 'spawning process' "$LOG")
    echo "untracked files: $N git spawns for one /api/vcs/diff: $((after - before))"
    echo "of which per-file 'diff --no-index': $(tail -n +1 "$LOG" | grep 'spawning process' | tail -n $((after - before)) | grep -c 'no-index')"
    Run it: bash repro.sh

    Here's the requested info, plus a before/after re-measurement after updating Git for Windows.

    git --version for C:\Program Files\Git\cmd\git.exe (the exact binary in the spawn lines; also the only git on PATH):

    git version 2.31.1.windows.1     <- before
    git version 2.55.0.windows.5     <- after updating via winget (Git.Git)
    

    Control test in a throwaway repo with the same command OpenCode runs:

    • 2.31.1: git add --intent-to-add --force --sparse --ignore-errors --pathspec-from-file=<list> --pathspec-file-nul
      → exit 129, error: unknown option 'sparse'
    • 2.55.0: same command → exit 0

    GET /api/vcs/diff (mode=working), same machine/repo:

    scenario before (git 2.31.1) after (git 2.55.0)
    50 newly created untracked 1 KiB .png files per-file fallback, 2 spawns/file 7 git spawns, 0.25 s, zero --no-index
    472 dirty files 23.1-23.4 s (~940 spawns, ~2/file) not re-run at this size
    ~1,476 dirty files >60 s → 499 + retry loop → UI freeze —
    repeat, no changes — 0.32 s, 7 spawns

    The 7-spawn case matches your "current Git: 7 spawns, 0.18 s": rev-parse --verify HEAD, diff --name-status HEAD, diff --numstat HEAD, status --porcelain=v1 -uall, add --intent-to-add --force --sparse --pathspec-from-file=… --pathspec-file-nul, diff --numstat, diff --patch - no per-file --no-index at all. No app restart was needed.

    So: trigger confirmed on Windows, and updating Git for Windows works around it. This machine had 2.31.1 (2021); Windows users with installs that old will still hit it, so #56527 (retry without --sparse) is still valuable.

    The /api/fs/list fan-out remains separate: 21,696 responses in 11:34:09Z-11:38:07Z (peak minute 10,848 ~= 181/s) with server thrashing detected at 19:34:13 / 19:34:33 / 19:36:47 / 19:38:00 local, and 5,245 git spawns in the same window. Happy to collect more instrumentation if useful.

  3. reynold-hu commented on Oct 18, 2026

    @reynold-hu
    Contributor

    Some evidence from a Windows 11 machine (win32 x64) against current dev:

    • The per-file --numstat + --patch pair is the fallback path. The batched path (untrackedBatch, added in fix(core): batch untracked file diffs instead of spawning git per file #53449 on 2026-10-08) uses a throwaway index and three Git processes (add --intent-to-add, diff --numstat, diff --patch) for all eligible untracked files. 2.0.32 was published 2026-10-16, so the package should contain it.
    • I ran the existing batch test plus a new Windows case with 20 untracked files whose names contain spaces and non-ASCII characters, and with core.autocrlf=true and CRLF content. The batch stayed engaged (spawn count far below the file count) and produced correct stats and patches.
    • Seeing the per-file pair for every file means untrackedBatch returned empty for that request (both the stats and patch maps), not just a per-file patch fallback. That points at the temporary-index setup (git add --intent-to-add ... --pathspec-from-file=...) failing silently on that repository, or at every file being filtered out before the batch.

    Could you check:

    • do opencode-untracked-* temp files appear in %TEMP% during a diff, and does the log contain the git add --intent-to-add invocation with GIT_INDEX_FILE?
    • git config --list --show-origin | findstr /i core. (especially core.splitIndex, core.autocrlf, core.ignorecase, core.precomposeunicode), and whether any files are symlinks or inside a nested repository?
    • are the dirty files mostly empty, binary, or very large? Those are filtered out of the batch individually and fall back to the per-file commands.

    Separately, the /api/fs/list storm is client-side: the server only computes what is asked, and the desktop request queue logs server thrashing detected but does not retry. 21,696 requests in four minutes means something in the renderer re-lists the tree per event; that seems worth a separate issue even after the Git work is bounded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions