Public: anyone can read this; the crew can change it.
An agent's second clone borrows a per-repository mirror: land pull #3707's branch without the dead refreshMirror #3754
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3744-an-agent-s-second-clone-borrows-a-per-re → main 4 commits fork
7 files
changed +362
−2
src/actions/accounts.ts +2
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | setSuspended, |
| 24 | 24 | type AccountRow, |
| 25 | 25 | } from "../core/accounts.ts"; |
| 26 | import { dropMirror } from "../crew/mirror.ts"; | |
| 26 | 27 | import { deleteRepo as destroyRepo, listReposOwnedBy } from "../core/repos.ts"; |
| 27 | 28 | import { listActive } from "../core/runs.ts"; |
| 28 | 29 | import { getUserByName, type User } from "../core/users.ts"; |
| @@ -184,6 +185,7 @@ async function removeOwnedRepos( | ||
| 184 | 185 | const label = `${owner.username}/${repo.name}`; |
| 185 | 186 | const deleted = await destroyRepo(app.db, app.reposRoot, repo); |
| 186 | 187 | if (!deleted.ok) return label; |
| 188 | dropMirror(app.config.dataDir, repo.id); | |
| 187 | 189 | removed.push(label); |
| 188 | 190 | if (owner.kind === "org") |
| 189 | 191 | app.bus.append({ |
src/actions/repos.ts +2
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | updateLabel as writeLabel, |
| 21 | 21 | type Label, |
| 22 | 22 | } from "../core/issues.ts"; |
| 23 | import { dropMirror } from "../crew/mirror.ts"; | |
| 23 | 24 | import { accessAllows, getOrg, orgAccess } from "../core/orgs.ts"; |
| 24 | 25 | import { |
| 25 | 26 | createRepo as initRepo, |
| @@ -502,6 +503,7 @@ export async function deleteRepo( | ||
| 502 | 503 | if (!admin.ok) return admin; |
| 503 | 504 | const deleted = await destroyRepo(app.db, app.reposRoot, repo); |
| 504 | 505 | if (!deleted.ok) return deleted; |
| 506 | dropMirror(app.config.dataDir, repo.id); | |
| 505 | 507 | storageChanged(app, repo.ownerId); |
| 506 | 508 | // The row is gone (events cascade with it), so this event carries no repo. |
| 507 | 509 | app.bus.append({ kind: "repo.deleted", actorId: admin.value.id }); |
src/crew/mirror.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * A bare mirror of each repository, so a second clone of it borrows the | |
| 3 | * objects already on disk. It lives at `<data>/crew/.mirrors/<repo id>`: | |
| 4 | * one per repository id, never shared, so a private repository's objects | |
| 5 | * never reach another's clone. The sandbox denies it with the other agents' | |
| 6 | * directories (it is an entry of `crew/` that is not the agent's own). | |
| 7 | * | |
| 8 | * The clone borrows with `--reference-if-able` and `--dissociate`: it copies | |
| 9 | * what it borrowed and keeps no link, so a deleted mirror breaks nothing. A | |
| 10 | * mirror that cannot be fetched is skipped, and the clone is a plain one. | |
| 11 | */ | |
| 12 | ||
| 13 | import { existsSync, mkdirSync, rmSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { runGit } from "../git/env.ts"; | |
| 16 | import { createLog } from "../kernel/log.ts"; | |
| 17 | import { crewRoot } from "./workdir.ts"; | |
| 18 | ||
| 19 | const log = createLog("mirror"); | |
| 20 | ||
| 21 | export const mirrorDir = (dataDir: string, repoId: string): string => | |
| 22 | join(crewRoot(dataDir), ".mirrors", repoId); | |
| 23 | ||
| 24 | // One chain per repository: two fetches into one mirror never overlap. | |
| 25 | const chains = new Map<string, Promise<unknown>>(); | |
| 26 | ||
| 27 | function exclusive<T>(key: string, job: () => Promise<T>): Promise<T> { | |
| 28 | const run = (chains.get(key) ?? Promise.resolve()).then(job, job); | |
| 29 | const tail = run.catch(() => undefined); | |
| 30 | chains.set(key, tail); | |
| 31 | void tail.then(() => { | |
| 32 | if (chains.get(key) === tail) chains.delete(key); | |
| 33 | }); | |
| 34 | return run; | |
| 35 | } | |
| 36 | ||
| 37 | /** Fetch into the mirror, or rebuild it; the mirror's path, or undefined. Hold the chain. */ | |
| 38 | async function refresh( | |
| 39 | dir: string, | |
| 40 | url: string, | |
| 41 | opts: { env: Record<string, string>; timeoutMs: number; signal?: AbortSignal }, | |
| 42 | ): Promise<string | undefined> { | |
| 43 | if (existsSync(join(dir, "HEAD"))) { | |
| 44 | const fetched = await runGit( | |
| 45 | ["fetch", "--quiet", "--prune", "--force", url, "+refs/*:refs/*"], | |
| 46 | { ...opts, cwd: dir }, | |
| 47 | ); | |
| 48 | if (fetched.ok) return dir; | |
| 49 | // A cancelled fetch is not a broken mirror: keep what is good. | |
| 50 | if (opts.signal?.aborted) return undefined; | |
| 51 | rmSync(dir, { recursive: true, force: true }); | |
| 52 | } | |
| 53 | if (opts.signal?.aborted) return undefined; | |
| 54 | mkdirSync(join(dir, ".."), { recursive: true }); | |
| 55 | const made = await runGit( | |
| 56 | ["clone", "--quiet", "--mirror", "--", url, dir], | |
| 57 | opts, | |
| 58 | ); | |
| 59 | if (made.ok) return dir; | |
| 60 | rmSync(dir, { recursive: true, force: true }); | |
| 61 | return undefined; | |
| 62 | } | |
| 63 | ||
| 64 | const validId = (repoId: string): boolean => /^[A-Za-z0-9_-]+$/.test(repoId); | |
| 65 | ||
| 66 | /** | |
| 67 | * Delete the repository's mirror, through the same chain as the fetches, so | |
| 68 | * one already running finishes first and none can recreate it afterwards (the | |
| 69 | * repository is gone, so a later fetch fails). | |
| 70 | */ | |
| 71 | function removeMirror(dataDir: string, repoId: string): Promise<void> { | |
| 72 | if (!validId(repoId)) return Promise.resolve(); | |
| 73 | return exclusive(repoId, async () => { | |
| 74 | rmSync(mirrorDir(dataDir, repoId), { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | } | |
| 77 | ||
| 78 | /** `removeMirror` without waiting for it: a failure is logged, never thrown. */ | |
| 79 | export function dropMirror(dataDir: string, repoId: string): void { | |
| 80 | removeMirror(dataDir, repoId).catch((error) => | |
| 81 | log.warn("a deleted repository's mirror would not go", { | |
| 82 | repo: repoId, | |
| 83 | error: String(error), | |
| 84 | }), | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * `git clone` of `url` into `dir`, borrowing from the repository's mirror | |
| 90 | * when there is one. The clone runs inside the chain: a rebuild of the mirror | |
| 91 | * cannot delete it while it is being read. | |
| 92 | */ | |
| 93 | export async function cloneWithMirror(input: { | |
| 94 | dataDir: string; | |
| 95 | repoId: string; | |
| 96 | url: string; | |
| 97 | dir: string; | |
| 98 | env: Record<string, string>; | |
| 99 | timeoutMs?: number; | |
| 100 | signal?: AbortSignal; | |
| 101 | }): ReturnType<typeof runGit> { | |
| 102 | const opts = { | |
| 103 | env: input.env, | |
| 104 | timeoutMs: input.timeoutMs ?? 5 * 60_000, | |
| 105 | ...(input.signal === undefined ? {} : { signal: input.signal }), | |
| 106 | }; | |
| 107 | const plain = () => | |
| 108 | runGit(["clone", "--quiet", "--", input.url, input.dir], opts); | |
| 109 | if (!validId(input.repoId)) return plain(); | |
| 110 | return exclusive(input.repoId, async () => { | |
| 111 | const mirror = await refresh(mirrorDir(input.dataDir, input.repoId), input.url, opts); | |
| 112 | if (mirror === undefined) return plain(); | |
| 113 | return runGit( | |
| 114 | ["clone", "--quiet", "--reference-if-able", mirror, "--dissociate", "--", input.url, input.dir], | |
| 115 | opts, | |
| 116 | ); | |
| 117 | }); | |
| 118 | } |
src/crew/runner.ts +7 −2
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | } from "../core/runs.ts"; |
| 69 | 69 | import { createToken, deleteToken } from "../core/tokens.ts"; |
| 70 | 70 | import { runGit } from "../git/env.ts"; |
| 71 | import { cloneWithMirror } from "./mirror.ts"; | |
| 71 | 72 | import { isSafeRef } from "../git/guard.ts"; |
| 72 | 73 | import { runGitCapture } from "../git/env.ts"; |
| 73 | 74 | import { looksForEnv } from "../core/looks.ts"; |
| @@ -1653,9 +1654,13 @@ async function prepareClone( | ||
| 1653 | 1654 | if (!existsSync(join(dir, ".git"))) { |
| 1654 | 1655 | mkdirSync(dir, { recursive: true }); |
| 1655 | 1656 | write(`cloning ${repo.ownerName}/${repo.name}`); |
| 1656 | const cloned = await runGit(["clone", "--quiet", "--", url, dir], { | |
| 1657 | const cloned = await cloneWithMirror({ | |
| 1658 | dataDir: app.config.dataDir, | |
| 1659 | repoId: repo.id, | |
| 1660 | url, | |
| 1661 | dir, | |
| 1657 | 1662 | env, |
| 1658 | timeoutMs: 5 * 60_000, | |
| 1663 | signal, | |
| 1659 | 1664 | }); |
| 1660 | 1665 | if (!cloned.ok) |
| 1661 | 1666 | return { |
src/crew/sandbox.ts +2
| @@ -236,6 +236,8 @@ export function sandboxPolicy(input: { | ||
| 236 | 236 | ...others(data, "crew"), |
| 237 | 237 | // Every agent but this one. |
| 238 | 238 | ...others(crew, input.agentName), |
| 239 | // The mirrors, by name: one a later run creates is not in the listing. | |
| 240 | join(crew, ".mirrors"), | |
| 239 | 241 | // This agent's other runs' temp directories. |
| 240 | 242 | ...others(join(own, ".t"), tmpDir === undefined ? "" : basename(tmpDir)), |
| 241 | 243 | // The machine's temp directory: every other session's files, and the |
src/git/env.ts +6
| @@ -236,6 +236,8 @@ export interface RunOptions { | ||
| 236 | 236 | stdin?: Uint8Array | string; |
| 237 | 237 | /** Kill the process after this long. @default 30_000 */ |
| 238 | 238 | timeoutMs?: number; |
| 239 | /** Kill the process when this aborts. */ | |
| 240 | signal?: AbortSignal; | |
| 239 | 241 | } |
| 240 | 242 | |
| 241 | 243 | /** Run git to completion, capturing output as bytes. Non-zero exit is an Err. */ |
| @@ -254,6 +256,9 @@ export async function runGitRaw( | ||
| 254 | 256 | |
| 255 | 257 | const timeoutMs = options.timeoutMs ?? 30_000; |
| 256 | 258 | const timer = setTimeout(() => proc.kill("SIGKILL"), timeoutMs); |
| 259 | const stop = () => proc.kill("SIGKILL"); | |
| 260 | if (options.signal?.aborted) stop(); | |
| 261 | options.signal?.addEventListener("abort", stop, { once: true }); | |
| 257 | 262 | try { |
| 258 | 263 | const [stdout, stderr, code] = await Promise.all([ |
| 259 | 264 | readAll(proc.stdout), |
| @@ -264,6 +269,7 @@ export async function runGitRaw( | ||
| 264 | 269 | return ok(stdout); |
| 265 | 270 | } finally { |
| 266 | 271 | clearTimeout(timer); |
| 272 | options.signal?.removeEventListener("abort", stop); | |
| 267 | 273 | } |
| 268 | 274 | }); |
| 269 | 275 | } |
test/crew-mirror.test.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** A second clone borrows from the repository's mirror; a deleted mirror breaks nothing. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 8 | import { promote } from "./helpers/members.ts"; | |
| 9 | import { removeAccount } from "../src/actions/accounts.ts"; | |
| 10 | import { runGit } from "../src/git/env.ts"; | |
| 11 | import { createApp } from "../src/app.ts"; | |
| 12 | import { createRepo, deleteRepo } from "../src/actions/repos.ts"; | |
| 13 | import { signUpProven as signUp } from "./helpers/verified.ts"; | |
| 14 | import { cloneWithMirror, mirrorDir } from "../src/crew/mirror.ts"; | |
| 15 | import { sandboxPolicy } from "../src/crew/sandbox.ts"; | |
| 16 | ||
| 17 | let root: string; | |
| 18 | let origin: string; | |
| 19 | const data = () => join(root, "data"); | |
| 20 | const sh = (args: string[], cwd: string, env: Record<string, string> = {}) => { | |
| 21 | const p = Bun.spawnSync(["git", ...args], { | |
| 22 | cwd, | |
| 23 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", ...env }, | |
| 24 | }); | |
| 25 | if (p.exitCode !== 0) throw new Error(p.stderr.toString()); | |
| 26 | }; | |
| 27 | ||
| 28 | beforeAll(() => { | |
| 29 | root = mkdtempSync(join(tmpdir(), "mirror-")); | |
| 30 | origin = join(root, "origin"); | |
| 31 | mkdirSync(origin); | |
| 32 | sh(["init", "-q", "-b", "main"], origin); | |
| 33 | Bun.write(join(origin, "a.txt"), "hello\n"); | |
| 34 | sh(["add", "."], origin); | |
| 35 | sh(["commit", "-qm", "one"], origin); | |
| 36 | }); | |
| 37 | afterAll(() => rmSync(root, { recursive: true, force: true })); | |
| 38 | ||
| 39 | let throwaway = 0; | |
| 40 | /** Build (or refresh) a mirror the way a run does: a clone that borrows from it. */ | |
| 41 | const mirrored = (dataDir: string, repoId: string, signal?: AbortSignal) => | |
| 42 | cloneWithMirror({ | |
| 43 | dataDir, repoId, url: `file://${origin}`, dir: join(root, `throwaway-${throwaway++}`), env: {}, | |
| 44 | ...(signal === undefined ? {} : { signal }), | |
| 45 | }); | |
| 46 | ||
| 47 | /** | |
| 48 | * An ssh "transport" that logs when each git conversation starts and ends, | |
| 49 | * and sleeps in between, so a test can see which ones overlap. | |
| 50 | */ | |
| 51 | const slow = (seconds: number) => { | |
| 52 | const script = join(root, "slow-ssh"); | |
| 53 | writeFileSync( | |
| 54 | script, | |
| 55 | // GIT_SSH_VARIANT=simple: $1 is the host, $2 the remote command. | |
| 56 | `#!/bin/sh\necho start >> "$SLOW_LOG"\nsleep "$SLOW_SECONDS"\neval "git \${2#git-}"\ncode=$?\necho end >> "$SLOW_LOG"\nexit $code\n`, | |
| 57 | ); | |
| 58 | chmodSync(script, 0o755); | |
| 59 | const logFile = join(root, `slow-${throwaway++}.log`); | |
| 60 | writeFileSync(logFile, ""); | |
| 61 | return { | |
| 62 | url: `ssh://forge.test${origin}`, | |
| 63 | env: { GIT_SSH_COMMAND: script, GIT_SSH_VARIANT: "simple", SLOW_LOG: logFile, SLOW_SECONDS: String(seconds) }, | |
| 64 | log: () => readFileSync(logFile, "utf8").split("\n").filter(Boolean), | |
| 65 | }; | |
| 66 | }; | |
| 67 | ||
| 68 | const wants = (trace: string) => | |
| 69 | readFileSync(trace, "utf8").split("\n").filter((l) => /packet:\s+fetch< want |packet:\s+clone> want /.test(l) || / want [0-9a-f]{40}/.test(l)); | |
| 70 | ||
| 71 | test("the second clone fetches no object the mirror already held", async () => { | |
| 72 | const url = `file://${origin}`; | |
| 73 | const first = await cloneWithMirror({ dataDir: data(), repoId: "r1", url, dir: join(root, "c1"), env: {} }); | |
| 74 | expect(first.ok).toBe(true); | |
| 75 | expect(existsSync(join(mirrorDir(data(), "r1"), "HEAD"))).toBe(true); | |
| 76 | ||
| 77 | const trace = join(root, "trace"); | |
| 78 | const second = await cloneWithMirror({ | |
| 79 | dataDir: data(), repoId: "r1", url, dir: join(root, "c2"), | |
| 80 | env: { GIT_TRACE_PACKET: trace }, | |
| 81 | }); | |
| 82 | expect(second.ok).toBe(true); | |
| 83 | // the mirror refresh asked for nothing new, and the clone wanted nothing | |
| 84 | expect(wants(trace)).toEqual([]); | |
| 85 | expect(readFileSync(join(root, "c2", "a.txt"), "utf8")).toBe("hello\n"); | |
| 86 | // dissociated: no link left to the mirror | |
| 87 | expect(existsSync(join(root, "c2", ".git", "objects", "info", "alternates"))).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a deleted mirror still gives a good clone", async () => { | |
| 91 | const url = `file://${origin}`; | |
| 92 | expect((await mirrored(data(), "r2")).ok).toBe(true); | |
| 93 | const dir = join(root, "c3"); | |
| 94 | const cloned = await cloneWithMirror({ dataDir: data(), repoId: "r2", url, dir, env: {} }); | |
| 95 | expect(cloned.ok).toBe(true); | |
| 96 | rmSync(mirrorDir(data(), "r2"), { recursive: true, force: true }); | |
| 97 | sh(["fsck", "--full"], dir); | |
| 98 | expect(readFileSync(join(dir, "a.txt"), "utf8")).toBe("hello\n"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("concurrent clones of one repository both succeed and share one mirror", async () => { | |
| 102 | const [a, b] = await Promise.all([mirrored(data(), "r3"), mirrored(data(), "r3")]); | |
| 103 | expect(a.ok).toBe(true); | |
| 104 | expect(b.ok).toBe(true); | |
| 105 | expect(existsSync(join(mirrorDir(data(), "r3"), "HEAD"))).toBe(true); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the borrowing clone runs inside the repository's lock", async () => { | |
| 109 | const s = slow(0.2); | |
| 110 | const clone = (dir: string) => | |
| 111 | cloneWithMirror({ dataDir: data(), repoId: "r6", url: s.url, dir: join(root, dir), env: s.env }); | |
| 112 | const [a, b] = await Promise.all([clone("locked-a"), clone("locked-b")]); | |
| 113 | expect(a.ok).toBe(true); | |
| 114 | expect(b.ok).toBe(true); | |
| 115 | // Mirror build, clone, mirror fetch, clone: one at a time, never two at once. | |
| 116 | expect(s.log()).toEqual(["start", "end", "start", "end", "start", "end", "start", "end"]); | |
| 117 | }); | |
| 118 | ||
| 119 | test("an agent's sandbox denies the mirrors", () => { | |
| 120 | const policy = sandboxPolicy({ dataDir: data(), agentName: "scout", urls: [] }); | |
| 121 | expect(policy.denyRead).toContain(join(data(), "crew", ".mirrors")); | |
| 122 | }); | |
| 123 | ||
| 124 | /** A clone of `repoId` that holds its lock for about a second. */ | |
| 125 | const cloningSlowly = (dataDir: string, repoId: string) => { | |
| 126 | const s = slow(0.5); | |
| 127 | let settled = false; | |
| 128 | const done = cloneWithMirror({ dataDir, repoId, url: s.url, dir: join(root, `slow-clone-${throwaway++}`), env: s.env }); | |
| 129 | void done.finally(() => (settled = true)); | |
| 130 | return { done, settled: () => settled }; | |
| 131 | }; | |
| 132 | ||
| 133 | test("deleting a repository deletes its mirror without waiting for a running clone", async () => { | |
| 134 | const dataDir = join(root, "forge"); | |
| 135 | const app = createApp({ dataDir, port: 0 }); | |
| 136 | try { | |
| 137 | const u = await signUp(app, { username: "carol", email: "[email protected]", password: "a-long-passphrase" }); | |
| 138 | if (!u.ok) throw new Error("signup failed"); | |
| 139 | promote(app.db, u.value); | |
| 140 | const made = await createRepo(app, u.value, { name: "gone" }); | |
| 141 | if (!made.ok) throw new Error("repo failed"); | |
| 142 | expect((await mirrored(dataDir, made.value.id)).ok).toBe(true); | |
| 143 | expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(true); | |
| 144 | const running = cloningSlowly(dataDir, made.value.id); | |
| 145 | await Bun.sleep(100); | |
| 146 | expect((await deleteRepo(app, u.value, made.value)).ok).toBe(true); | |
| 147 | // The delete did not wait for the clone holding the lock. | |
| 148 | expect(running.settled()).toBe(false); | |
| 149 | expect((await running.done).ok).toBe(true); | |
| 150 | for (let i = 0; i < 60 && existsSync(mirrorDir(dataDir, made.value.id)); i++) await Bun.sleep(50); | |
| 151 | expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(false); | |
| 152 | } finally { | |
| 153 | app.close(); | |
| 154 | } | |
| 155 | }); | |
| 156 | ||
| 157 | const gone = async (pid: number) => { | |
| 158 | for (let i = 0; i < 40; i++) { | |
| 159 | try { | |
| 160 | process.kill(pid, 0); | |
| 161 | } catch { | |
| 162 | return true; | |
| 163 | } | |
| 164 | await Bun.sleep(50); | |
| 165 | } | |
| 166 | return false; | |
| 167 | }; | |
| 168 | ||
| 169 | test("aborting a running git ends it early and leaves no process behind", async () => { | |
| 170 | const pidFile = join(root, "pid"); | |
| 171 | const ctl = new AbortController(); | |
| 172 | setTimeout(() => ctl.abort(), 300); | |
| 173 | const started = Date.now(); | |
| 174 | const result = await runGit( | |
| 175 | ["clone", "--quiet", "--upload-pack", `echo $$ > ${pidFile}; exec cat #`, `file://${origin}`, join(root, "slow")], | |
| 176 | { timeoutMs: 60_000, signal: ctl.signal }, | |
| 177 | ); | |
| 178 | expect(result.ok).toBe(false); | |
| 179 | expect(Date.now() - started).toBeLessThan(10_000); | |
| 180 | expect(await gone(Number(readFileSync(pidFile, "utf8").trim()))).toBe(true); | |
| 181 | }); | |
| 182 | ||
| 183 | test("an already aborted signal stops a clone that would succeed", async () => { | |
| 184 | const ctl = new AbortController(); | |
| 185 | ctl.abort(); | |
| 186 | const dir = join(root, "aborted"); | |
| 187 | const cloned = await cloneWithMirror({ | |
| 188 | dataDir: data(), repoId: "r4", url: `file://${origin}`, dir, env: {}, signal: ctl.signal, | |
| 189 | }); | |
| 190 | expect(cloned.ok).toBe(false); | |
| 191 | expect(existsSync(join(dir, "a.txt"))).toBe(false); | |
| 192 | }); | |
| 193 | ||
| 194 | test("a cancelled clone keeps a good mirror", async () => { | |
| 195 | expect((await mirrored(data(), "r5")).ok).toBe(true); | |
| 196 | const ctl = new AbortController(); | |
| 197 | ctl.abort(); | |
| 198 | expect((await mirrored(data(), "r5", ctl.signal)).ok).toBe(false); | |
| 199 | expect(existsSync(join(mirrorDir(data(), "r5"), "HEAD"))).toBe(true); | |
| 200 | }); | |
| 201 | ||
| 202 | test("removing an account deletes its repositories' mirrors without waiting for a running clone", async () => { | |
| 203 | const dataDir = join(root, "forge2"); | |
| 204 | const app = createApp({ dataDir, port: 0 }); | |
| 205 | try { | |
| 206 | const admin = await seedAdmin(app, { username: "boss", email: "[email protected]", password: "a-long-passphrase" }); | |
| 207 | const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "a-long-passphrase" }); | |
| 208 | if (!admin.ok || !dave.ok) throw new Error("signup failed"); | |
| 209 | promote(app.db, dave.value); | |
| 210 | const made = await createRepo(app, dave.value, { name: "mine" }); | |
| 211 | if (!made.ok) throw new Error("repo failed"); | |
| 212 | expect((await mirrored(dataDir, made.value.id)).ok).toBe(true); | |
| 213 | expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(true); | |
| 214 | const running = cloningSlowly(dataDir, made.value.id); | |
| 215 | await Bun.sleep(100); | |
| 216 | const removed = await removeAccount(app, admin.value, "dave"); | |
| 217 | expect(removed.ok).toBe(true); | |
| 218 | expect(running.settled()).toBe(false); | |
| 219 | expect((await running.done).ok).toBe(true); | |
| 220 | for (let i = 0; i < 60 && existsSync(mirrorDir(dataDir, made.value.id)); i++) await Bun.sleep(50); | |
| 221 | expect(existsSync(mirrorDir(dataDir, made.value.id))).toBe(false); | |
| 222 | } finally { | |
| 223 | app.close(); | |
| 224 | } | |
| 225 | }); |
new above
offline