Skip to content

Commit 2e2f588

Browse files
fix(miner): serialize per-repo base clone to prevent concurrent git races (#6917)
ensureRepoCloned resolves one deterministic path per repoFullName and mutates it in place (git fetch/checkout/reset --hard) with no locking, while worktree-allocator only caps the total active-slot count and never enforces per-repo exclusivity. Two attempts for the same repo could therefore run these git commands against the shared base clone concurrently, corrupting the index/HEAD/refs or tripping .git/index.lock. Add a per-repoPath in-process async mutex: a Map of repoPath to the tail of an in-flight promise chain serializes same-repo ensureRepoCloned calls while different repoPaths still run in parallel. The lock is released on both success and failure (finally), so one failing attempt can neither reject a waiter nor wedge the queue, and the entry is dropped once the chain drains. Closes #6762 Co-authored-by: e11734937-beep <e11734937-beep@users.noreply.github.com>
1 parent f1fb812 commit 2e2f588

2 files changed

Lines changed: 166 additions & 2 deletions

File tree

packages/loopover-miner/lib/repo-clone.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,57 @@ async function defaultRunGit(args, cwd, timeoutMs) {
7272
}
7373
}
7474

75+
// Per-repoPath in-process serialization for ensureRepoCloned (#6762). Two attempts for the SAME repo share
76+
// one deterministic base-clone path and mutate it in place (git fetch/checkout/reset --hard); worktree-
77+
// allocator.js only caps the TOTAL active-slot count, never per-repo exclusivity, so without this two
78+
// same-repo attempts can interleave git subprocesses on the same .git dir and corrupt the index/HEAD/refs or
79+
// trip .git/index.lock. `repoCloneLocks` maps a resolved repoPath to the tail of its in-flight promise chain:
80+
// same-repo calls run strictly one after another, while different repoPaths stay fully parallel. The tail
81+
// promise's handlers swallow, so it never rejects -- one failing attempt can neither reject a waiter nor
82+
// wedge the queue -- and the finally drops the entry once the chain drains, keeping the Map bounded.
83+
const repoCloneLocks = new Map();
84+
85+
/**
86+
* @template T
87+
* @param {string} repoPath key: the resolved base-clone path the git mutations run against.
88+
* @param {() => Promise<T>} fn the critical section (a single ensureRepoClonedUnlocked run).
89+
* @returns {Promise<T>}
90+
*/
91+
async function withRepoCloneLock(repoPath, fn) {
92+
const previous = repoCloneLocks.get(repoPath) ?? Promise.resolve();
93+
const run = previous.then(() => fn());
94+
const tail = run.then(
95+
() => {},
96+
() => {},
97+
);
98+
repoCloneLocks.set(repoPath, tail);
99+
try {
100+
return await run;
101+
} finally {
102+
if (repoCloneLocks.get(repoPath) === tail) repoCloneLocks.delete(repoPath);
103+
}
104+
}
105+
106+
/**
107+
* Serialize the git mutations of {@link ensureRepoClonedUnlocked} per resolved repo path so concurrent
108+
* same-repo attempts never race the shared base clone (#6762), while different repos still run in parallel.
109+
* Resolves the same `repoPath` the unlocked step computes and uses it as the mutex key; throws (before
110+
* locking) on a malformed `repoFullName`, matching the prior behaviour.
111+
*
112+
* @param {string} repoFullName
113+
* @param {{
114+
* baseBranch?: string, cloneBaseDir?: string, env?: Record<string, string | undefined>, timeoutMs?: number,
115+
* remoteUrl?: string, runGit?: (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean, stdout: string, stderr: string }>,
116+
* }} [options]
117+
* @returns {Promise<{ ok: boolean, repoPath: string, error?: string }>}
118+
*/
119+
export async function ensureRepoCloned(repoFullName, options = {}) {
120+
const target = normalizeRepoFullName(repoFullName);
121+
const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env);
122+
const repoPath = join(cloneBaseDir, target.owner, target.repo);
123+
return withRepoCloneLock(repoPath, () => ensureRepoClonedUnlocked(repoFullName, options));
124+
}
125+
75126
/**
76127
* Ensure a real, current local clone of `repoFullName` exists at the deterministic per-repo cache path.
77128
* First use: `git clone`. Subsequent use: `git fetch origin` + hard-reset the base branch to
@@ -84,7 +135,7 @@ async function defaultRunGit(args, cwd, timeoutMs) {
84135
* }} [options]
85136
* @returns {Promise<{ ok: boolean, repoPath: string, error?: string }>}
86137
*/
87-
export async function ensureRepoCloned(repoFullName, options = {}) {
138+
async function ensureRepoClonedUnlocked(repoFullName, options = {}) {
88139
const target = normalizeRepoFullName(repoFullName);
89140
const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : DEFAULT_BASE_BRANCH;
90141
const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env);

test/unit/miner-repo-clone.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFileSync } from "node:child_process";
2-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { afterEach, describe, expect, it } from "vitest";
@@ -195,3 +195,116 @@ describe("ensureRepoCloned (#5132)", () => {
195195
expect(runGitCalls).toBe(0);
196196
});
197197
});
198+
199+
describe("ensureRepoCloned per-repo concurrency guard (#6762)", () => {
200+
// Drains the microtask queue: a setImmediate callback only fires once no microtasks remain ready, so
201+
// awaiting this lets every already-schedulable git op run while leaving anything still blocked on a gate
202+
// (or queued behind the mutex) untouched.
203+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
204+
205+
it("REGRESSION: serializes two concurrent ensureRepoCloned calls for the SAME repo (no interleaved git ops)", async () => {
206+
// Both concurrent calls share one injected runGit whose first invocation blocks on `firstGate`. WITHOUT
207+
// the per-repo mutex the second call enters its own git op immediately and `events` shows two
208+
// "start:clone" before either ends; WITH the guard the second cannot start any git op until the first
209+
// fully settles, so exactly one op is ever in flight.
210+
const root = tempRoot("loopover-miner-repo-clone-concurrent-same-");
211+
const cloneBaseDir = join(root, "cache");
212+
const events: string[] = [];
213+
let releaseFirst!: () => void;
214+
const firstGate = new Promise<void>((resolve) => {
215+
releaseFirst = resolve;
216+
});
217+
let firstBlocked = false;
218+
const runGit = async (args: string[]) => {
219+
events.push(`start:${args[0]}`);
220+
if (!firstBlocked) {
221+
firstBlocked = true;
222+
await firstGate;
223+
}
224+
events.push(`end:${args[0]}`);
225+
return { ok: true, stdout: "", stderr: "" };
226+
};
227+
228+
const first = ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", runGit });
229+
const second = ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", runGit });
230+
231+
// Only the first call may have reached git; the second must be queued behind the per-repo lock.
232+
await flush();
233+
expect(events).toEqual(["start:clone"]);
234+
235+
releaseFirst();
236+
const [firstResult, secondResult] = await Promise.all([first, second]);
237+
expect(firstResult.ok).toBe(true);
238+
expect(secondResult.ok).toBe(true);
239+
// Strict, non-overlapping ordering: first runs start->end fully before second starts.
240+
expect(events).toEqual(["start:clone", "end:clone", "start:clone", "end:clone"]);
241+
});
242+
243+
it("does NOT serialize across DIFFERENT repos -- they run in parallel", async () => {
244+
// repo-a's git op blocks; repo-b's must still proceed (different repoPath => different lock), proving the
245+
// guard is per-repo rather than a single global mutex.
246+
const root = tempRoot("loopover-miner-repo-clone-concurrent-diff-");
247+
const cloneBaseDir = join(root, "cache");
248+
const started: string[] = [];
249+
let releaseA!: () => void;
250+
const gateA = new Promise<void>((resolve) => {
251+
releaseA = resolve;
252+
});
253+
const runGitFor = (name: string) => async () => {
254+
started.push(name);
255+
if (name === "a") await gateA;
256+
return { ok: true, stdout: "", stderr: "" };
257+
};
258+
259+
const a = ensureRepoCloned("acme/repo-a", { cloneBaseDir, remoteUrl: "unused", runGit: runGitFor("a") });
260+
const b = ensureRepoCloned("acme/repo-b", { cloneBaseDir, remoteUrl: "unused", runGit: runGitFor("b") });
261+
262+
await flush();
263+
// repo-b advanced into git even though repo-a is still blocked -> not serialized against each other.
264+
expect(started).toContain("b");
265+
266+
releaseA();
267+
const [aResult, bResult] = await Promise.all([a, b]);
268+
expect(aResult.ok).toBe(true);
269+
expect(bResult.ok).toBe(true);
270+
});
271+
272+
it("releases the per-repo lock when a call throws, so a later same-repo call still proceeds", async () => {
273+
const root = tempRoot("loopover-miner-repo-clone-concurrent-throw-");
274+
const cloneBaseDir = join(root, "cache");
275+
const throwing = async () => {
276+
throw new Error("git exploded");
277+
};
278+
await expect(ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", runGit: throwing })).rejects.toThrow("git exploded");
279+
280+
// If the lock were not released on throw, this second call would block forever (test would time out).
281+
const ok = async () => ({ ok: true, stdout: "", stderr: "" });
282+
const result = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", runGit: ok });
283+
expect(result.ok).toBe(true);
284+
});
285+
286+
it("keys the lock off the env-resolved base dir when no cloneBaseDir option is given", async () => {
287+
// Exercises the wrapper's env-fallback path for the lock key (no explicit cloneBaseDir option).
288+
const root = tempRoot("loopover-miner-repo-clone-concurrent-envdir-");
289+
const ok = async () => ({ ok: true, stdout: "", stderr: "" });
290+
const result = await ensureRepoCloned("acme/widgets", { env: { LOOPOVER_MINER_REPO_CLONE_DIR: root }, remoteUrl: "unused", runGit: ok });
291+
expect(result.ok).toBe(true);
292+
expect(result.repoPath).toBe(join(root, "acme", "widgets"));
293+
});
294+
295+
it("propagates real git stderr and falls back to a default across the fetch/checkout/reset steps", async () => {
296+
// existsSync(repoPath) true => fetch/checkout/reset path, driven entirely with injected runGit (fast,
297+
// deterministic). Covers both the real-stderr and empty-stderr fallback branch of each step.
298+
const root = tempRoot("loopover-miner-repo-clone-concurrent-stderr-");
299+
const cloneBaseDir = join(root, "cache");
300+
const repoPath = join(cloneBaseDir, "acme", "widgets");
301+
mkdirSync(repoPath, { recursive: true });
302+
303+
const failOn = (step: string, stderr: string) => async (args: string[]) => (args[0] === step ? { ok: false, stdout: "", stderr } : { ok: true, stdout: "", stderr: "" });
304+
305+
expect((await ensureRepoCloned("acme/widgets", { cloneBaseDir, runGit: failOn("fetch", "") })).error).toBe("git_fetch_failed");
306+
expect((await ensureRepoCloned("acme/widgets", { cloneBaseDir, runGit: failOn("fetch", "boom-fetch") })).error).toBe("boom-fetch");
307+
expect((await ensureRepoCloned("acme/widgets", { cloneBaseDir, runGit: failOn("checkout", "boom-checkout") })).error).toBe("boom-checkout");
308+
expect((await ensureRepoCloned("acme/widgets", { cloneBaseDir, runGit: failOn("reset", "boom-reset") })).error).toBe("boom-reset");
309+
});
310+
});

0 commit comments

Comments
 (0)