Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] f71591d5a3 test(server): accept reset connections from timed-out Git helpers
Force-killed Windows helpers reset their TCP sockets. Silent-remote fixtures must handle ECONNRESET while continuing to fail on unexpected socket errors.
2026-10-25 16:26:14 +00:00
paseo-bot[bot] d5a83362a7 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.
2026-10-25 16:26:14 +00:00
4 changed files with 61 additions and 8 deletions
@@ -16,7 +16,6 @@ interface FakeSpawnBehavior {
interface FakeSpawnController { interface FakeSpawnController {
activeCount: number; activeCount: number;
nextPid: number;
peakActiveCount: number; peakActiveCount: number;
processes: FakeChildProcess[]; processes: FakeChildProcess[];
queue: FakeSpawnBehavior[]; queue: FakeSpawnBehavior[];
@@ -25,7 +24,6 @@ interface FakeSpawnController {
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
activeCount: 0, activeCount: 0,
nextPid: 1000,
peakActiveCount: 0, peakActiveCount: 0,
processes: [], processes: [],
queue: [], queue: [],
@@ -35,7 +33,6 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
} }
this.activeCount = 0; this.activeCount = 0;
this.nextPid = 1000;
this.peakActiveCount = 0; this.peakActiveCount = 0;
this.processes = []; this.processes = [];
this.queue = []; this.queue = [];
@@ -43,7 +40,8 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
})); }));
class FakeChildProcess extends EventEmitter { 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 stderr = new EventEmitter();
public readonly stdout = new EventEmitter(); public readonly stdout = new EventEmitter();
public killed = false; public killed = false;
@@ -57,8 +55,6 @@ class FakeChildProcess extends EventEmitter {
public constructor(behavior: FakeSpawnBehavior) { public constructor(behavior: FakeSpawnBehavior) {
super(); super();
this.behavior = behavior; this.behavior = behavior;
this.pid = fakeSpawnController.nextPid;
fakeSpawnController.nextPid += 1;
fakeSpawnController.activeCount += 1; fakeSpawnController.activeCount += 1;
fakeSpawnController.peakActiveCount = Math.max( fakeSpawnController.peakActiveCount = Math.max(
+2 -1
View File
@@ -13,6 +13,7 @@ import {
submitGitCommandTrace, submitGitCommandTrace,
} from "./git-command-trace.js"; } from "./git-command-trace.js";
import { spawnProcess } from "./spawn.js"; import { spawnProcess } from "./spawn.js";
import { signalProcessTree } from "./tree-kill.js";
import { import {
GitProcessScheduler, GitProcessScheduler,
type GitProcessPriority, type GitProcessPriority,
@@ -422,7 +423,7 @@ function executeGitCommand<Output>(
timer = setTimeout(() => { timer = setTimeout(() => {
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`); timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
child.kill("SIGKILL"); void signalProcessTree(child, "SIGKILL");
settle(() => reject(timeoutError)); settle(() => reject(timeoutError));
if (processExit) { if (processExit) {
settleTimeoutTrace(processExit.exitCode, processExit.signal); settleTimeoutTrace(processExit.exitCode, processExit.signal);
@@ -1,12 +1,17 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { createServer, type Socket } from "node:net";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = []; const tempDirs: string[] = [];
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
function makeTempRepo(): string { function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-")); const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo); tempDirs.push(repo);
@@ -20,6 +25,49 @@ afterEach(() => {
}); });
describe("runGitCommand shell behavior", () => { 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);
// Force-killing a Windows peer resets the connection instead of sending EOF.
socket.on("error", allowConnectionReset);
// Consume the request but never answer, keeping the real transport helper busy.
socket.resume();
});
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()));
}
},
);
it("passes git arguments directly instead of through the platform shell", async () => { it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo(); const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%"; const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
+9 -1
View File
@@ -55,10 +55,18 @@ function createLegacyWorktreeForTest(
}); });
} }
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN. // A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> { async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
const connections = new Set<Socket>(); const connections = new Set<Socket>();
const server = createServer((socket) => connections.add(socket)); const server = createServer((socket) => {
connections.add(socket);
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
socket.on("error", allowConnectionReset);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo; const { port } = server.address() as AddressInfo;
return { return {