From d5a83362a7c64c4fd1a400ef13e98f558888dfd2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:23:13 +0000 Subject: [PATCH] fix(server): stop Git transport helpers on command timeout Killing only the direct Git process left HTTP and SSH helpers holding connections after a fetch timed out. Use the existing process-tree cleanup so repeated refreshes do not accumulate those helpers. --- .../server/src/utils/run-git-command.test.ts | 8 +--- packages/server/src/utils/run-git-command.ts | 3 +- .../run-git-command.windows-shell.test.ts | 42 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 32844d0de8..eeefabd6ff 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -16,7 +16,6 @@ interface FakeSpawnBehavior { interface FakeSpawnController { activeCount: number; - nextPid: number; peakActiveCount: number; processes: FakeChildProcess[]; queue: FakeSpawnBehavior[]; @@ -25,7 +24,6 @@ interface FakeSpawnController { const fakeSpawnController = vi.hoisted(() => ({ activeCount: 0, - nextPid: 1000, peakActiveCount: 0, processes: [], queue: [], @@ -35,7 +33,6 @@ const fakeSpawnController = vi.hoisted(() => ({ } this.activeCount = 0; - this.nextPid = 1000; this.peakActiveCount = 0; this.processes = []; this.queue = []; @@ -43,7 +40,8 @@ const fakeSpawnController = vi.hoisted(() => ({ })); class FakeChildProcess extends EventEmitter { - public readonly pid: number; + // This is not an OS process; tree cleanup must use the direct-child fallback. + public readonly pid = undefined; public readonly stderr = new EventEmitter(); public readonly stdout = new EventEmitter(); public killed = false; @@ -57,8 +55,6 @@ class FakeChildProcess extends EventEmitter { public constructor(behavior: FakeSpawnBehavior) { super(); this.behavior = behavior; - this.pid = fakeSpawnController.nextPid; - fakeSpawnController.nextPid += 1; fakeSpawnController.activeCount += 1; fakeSpawnController.peakActiveCount = Math.max( diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bdf7bc1f32..920ea7878d 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -13,6 +13,7 @@ import { submitGitCommandTrace, } from "./git-command-trace.js"; import { spawnProcess } from "./spawn.js"; +import { signalProcessTree } from "./tree-kill.js"; import { GitProcessScheduler, type GitProcessPriority, @@ -422,7 +423,7 @@ function executeGitCommand( timer = setTimeout(() => { timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`); - child.kill("SIGKILL"); + void signalProcessTree(child, "SIGKILL"); settle(() => reject(timeoutError)); if (processExit) { settleTimeoutTrace(processExit.exitCode, processExit.signal); diff --git a/packages/server/src/utils/run-git-command.windows-shell.test.ts b/packages/server/src/utils/run-git-command.windows-shell.test.ts index ed7980c558..d3db4bcbe2 100644 --- a/packages/server/src/utils/run-git-command.windows-shell.test.ts +++ b/packages/server/src/utils/run-git-command.windows-shell.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { createServer, type Socket } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { runGitCommand } from "./run-git-command.js"; @@ -20,6 +21,47 @@ afterEach(() => { }); describe("runGitCommand shell behavior", () => { + it.each(["http", "ssh"])( + "closes a stalled %s fetch connection on timeout and admits the next command", + async (protocol) => { + const repo = makeTempRepo(); + await runGitCommand(["init"], { cwd: repo }); + const sockets: Socket[] = []; + const server = createServer((socket) => { + sockets.push(socket); + // Consume the request but never answer, keeping the real transport helper busy. + socket.resume(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing TCP address"); + const remote = `${protocol}://127.0.0.1:${address.port}/repo.git`; + try { + await expect( + runGitCommand(["fetch", remote, "main"], { + cwd: repo, + timeout: 2000, + envOverlay: { + NO_PROXY: "127.0.0.1", + no_proxy: "127.0.0.1", + GIT_SSH_COMMAND: "ssh -F none -o BatchMode=yes", + }, + }), + ).rejects.toThrow("Git command timed out after 2000ms"); + + expect(sockets).toHaveLength(1); + await expect.poll(() => sockets[0]?.destroyed, { timeout: 3000 }).toBe(true); + await expect(runGitCommand(["--version"], { cwd: repo })).resolves.toMatchObject({ + exitCode: 0, + truncated: false, + }); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); + it("passes git arguments directly instead of through the platform shell", async () => { const repo = makeTempRepo(); const literalName = "%PASEO_GIT_SHELL_SENTINEL%";