Repository navigation
Description
Area: apps/server
Impact: Major degradation or frequent failure
Summary
Since #32260, Windows process launches run on up to four worker threads (apps/server/src/process/SpawnWorker.ts). Two workers can be inside CreateProcess at the same moment. Node marks a child's stdio pipe handles inheritable for the length of its CreateProcess, so a second CreateProcess on another thread in that window inherits the first child's pipes. If the second child is long-lived, it holds the first child's stdout open: the short command has exited, but its output never reaches EOF, so T3 treats it as running until the long-lived child exits.
Steps to reproduce
- On Windows, open a project folder that is not a Git repository.
- Start a new thread and send any message, so thread title generation runs.
- Watch the thread before the agent starts: "Setting up worktree…" stays up for about 3 to 4 s.
Deterministic repro in a test (passes with the fix below, hangs without it): keep four workers warm with long-lived children, then launch a short child and a long-lived child at the same moment, 20 times. See the test under "Proposed fix".
Expected behavior
The workspace preparation step finishes in tens of milliseconds, as it does in a Git repository or when the thread has no generated title.
Actual behavior
ThreadLaunchService.prepareInBackground takes 2.8 to 4.2 s on every new thread in a non-Git folder. Almost all of it is one GitVcsDriver.probeWorkTree call (git rev-parse --is-inside-work-tree --show-toplevel --git-common-dir).
Git itself is not slow. With trace2.eventTarget set, Git's own trace shows the probe exiting after 23 ms, while T3's trace shows the same call running for 4.2 s and ending within about 15 ms of the title-generation claude -p process (runClaudeJson.runClaudeCommand), which launched in the same millisecond:
| start | end | |
|---|---|---|
| git probe, Git's trace2 | 24.288 | 24.311 |
git probe, T3 trace (processRunner.runProcessCore) |
24.307 | 28.505 |
title generation, T3 trace (runClaudeJson.runClaudeCommand) |
24.307 | 28.519 |
The same pairing held for every slow launch in a day of traces (4 of 4), and the one non-Git launch whose probe finished before the title process started took 37 ms. A thread started from t3 thread start (title from the message, no generation) took 43 ms.
The worse case: a git call from RepositoryIdentityResolver.resolveCacheKey that started a few ms before a thread's agent process launched ran for 60,010 ms, the default process timeout. The agent process lives for the whole session, so the inherited handle is never released and the call only ends when it is killed.
The leak does not reproduce with plain Node or with the Electron binary spawning from one thread; it needs CreateProcess on two threads at once, which the worker pool does by design when warm workers are idle (workers that own a running child are kept, so beside a running agent there are usually several).
Proposed fix
Serialize the spawn() call across all workers with a lock in a SharedArrayBuffer. A launch holds it only for its CreateProcess (measured 2 to 9 ms for git.exe and claude.exe), and the server thread still never waits, which keeps the point of #32260.
+// One spawn at a time across every worker of every pool. Node makes a child's stdio handles
+// inheritable for the length of its CreateProcess, so a second worker's CreateProcess in that
+// window inherits them, and a long-lived child then holds a short one's stdout open.
+const spawnLock = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);In workerSource:
-const { parentPort } = require("node:worker_threads");
+const { parentPort, workerData } = require("node:worker_threads");
const { spawn } = require("node:child_process");
const children = new Map();
+const spawnLock = workerData && workerData.spawnLock ? new Int32Array(workerData.spawnLock) : null;
+const withSpawnLock = (start) => {
+ if (spawnLock === null) return start();
+ while (Atomics.compareExchange(spawnLock, 0, 0, 1) !== 0) Atomics.wait(spawnLock, 0, 1);
+ try {
+ return start();
+ } finally {
+ Atomics.store(spawnLock, 0, 0);
+ Atomics.notify(spawnLock, 0, 1);
+ }
+};
...
- child = spawn(request.command, request.args, request.options);
+ child = withSpawnLock(() => spawn(request.command, request.args, request.options));And when creating each worker:
- this.worker = new NodeWorkerThreads.Worker(workerSource, { eval: true });
+ this.worker = new NodeWorkerThreads.Worker(workerSource, {
+ eval: true,
+ workerData: { spawnLock },
+ });The lock is module-level, so it also covers spawnPiped's separate pool (agent processes launch there).
Regression test (event-driven; without the lock it hangs on the first leaked round):
it.live("a short child's output ends while a sibling launched beside it still runs", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const untilStdin = "process.stdout.write('ready');process.stdin.once('data',()=>process.exit(0))";
// Workers stay alive while they own a child, as they do beside a running agent, so the
// two launches of each round land on two warm workers at the same moment.
yield* Effect.all(
Array.from({ length: 4 }, () =>
Effect.gen(function* () {
const holder = yield* spawner.spawn(node(untilStdin));
expect(yield* holder.stdout.pipe(Stream.take(1), text)).toBe("ready");
}),
),
{ concurrency: "unbounded" },
);
for (let round = 0; round < 20; round++) {
const [sibling, output] = yield* Effect.all(
[
spawner.spawn(node(untilStdin, [], { stdin: { stream: Stream.never } })),
spawner
.spawn(node("process.stdout.write('ok')"))
.pipe(Effect.flatMap((child) => text(child.stdout))),
],
{ concurrency: "unbounded" },
);
expect(output).toBe("ok");
// The short child's output ended with its sibling still running.
expect(yield* sibling.isRunning).toBe(true);
}
}).pipe(Effect.scoped, Effect.provide(layer)),
);Measured with the same rounds (a short child beside a 5 s child on warm workers): without the lock 58 of 60 rounds waited the full 5 s for the short child's output; with it 0 of 60, slowest 52 ms. In the app, a new thread in a non-Git folder went from 3,099 ms to 58 ms of preparation.
Version or commit
0.1.9, built from main at 7f6e403
Environment
Windows 11 (26200), desktop app, Git for Windows 2.46.0, Claude Code 2.1.325 as the provider.
Logs or stack traces
From the server trace (server.trace.ndjson), one launch:
24.303 4213ms ThreadLaunchService.prepareInBackground
24.307 4197ms processRunner.runProcessCore (process.command: git)
24.307 4198ms GitVcsDriver.probeWorkTree
24.307 4213ms runClaudeJson.runClaudeCommand
Activity