|
1 | 1 | 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"; |
3 | 3 | import { tmpdir } from "node:os"; |
4 | 4 | import { join } from "node:path"; |
5 | 5 | import { afterEach, describe, expect, it } from "vitest"; |
@@ -195,3 +195,116 @@ describe("ensureRepoCloned (#5132)", () => { |
195 | 195 | expect(runGitCalls).toBe(0); |
196 | 196 | }); |
197 | 197 | }); |
| 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