Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

486 lines · 27.0 KBRaw History
1import { execFileSync } from "node:child_process";
2import { randomBytes } from "node:crypto";
3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import { createHash } from "node:crypto";
7import { deflateSync, gzipSync, inflateSync } from "node:zlib";
8import { setFlagsFromString } from "node:v8";
9import { runInNewContext } from "node:vm";
10import { afterAll, describe, expect, it, vi } from "vitest";
11import { ENTRY_OVERHEAD, HASH_COPY_MAX, REF_DELTA_OVERHEAD, SCRATCH_BYTES, StreamedPack, TooMuchMemory, deflate, deflateChunks, hashObject, parsePack, writePack, type ChunkFeed, type GitObject, type PackSource, type StreamedObject } from "../src/index.js";
12
13const dir = mkdtempSync(join(tmpdir(), "pack-stream-"));
14afterAll(() => rmSync(dir, { recursive: true, force: true }));
15const git = (args: string[], input?: string) => execFileSync("git", args, { cwd: dir, input, maxBuffer: 1 << 28, env: { ...process.env, GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" } });
16
17// A history whose files change a little each commit, so the pack has delta chains, plus a large blob.
18git(["init", "-q", "-b", "main"]);
19let text = Array.from({ length: 400 }, (_, i) => `line ${i} of a file that keeps changing\n`).join("");
20// Random bytes, so the pack itself is several blocks long.
21const large = new Uint8Array(randomBytes(5 * 1024 * 1024));
22for (let c = 0; c < 12; c++) {
23 text = text.replace(`line ${c * 3}`, `changed ${c}`) + `appended ${c}\n`;
24 writeFileSync(join(dir, "a.txt"), text);
25 writeFileSync(join(dir, "b.txt"), text.split("").reverse().join(""));
26 if (c === 5) writeFileSync(join(dir, "big.bin"), large);
27 git(["add", "-A"]);
28 git(["commit", "-q", "-m", `c${c}`]);
29}
30const pack = (args: string[], revs: string) => new Uint8Array(git(["pack-objects", "--stdout", "-q", ...args], revs));
31const full = pack(["--revs"], "main\n");
32const thin = pack(["--revs", "--thin"], "main\n^main~4\n");
33
34/** Reads count how many bytes were fetched, to show the pack is read in ranges. */
35function source(data: Uint8Array, reads: { bytes: number; calls: number } = { bytes: 0, calls: 0 }): PackSource {
36 return {
37 size: data.length,
38 read: async (offset, length) => {
39 reads.calls++;
40 reads.bytes += length;
41 return data.slice(offset, offset + length);
42 },
43 };
44}
45
46/** A pack of deltas, each against the base with this oid (from outside the pack): `deltas[i]` is its instructions. */
47function refDeltaPack(baseOid: string, deltas: Uint8Array[]): Uint8Array {
48 const parts: Buffer[] = [Buffer.from("PACK"), Buffer.from([0, 0, 0, 2]), Buffer.alloc(4)];
49 parts[2]!.writeUInt32BE(deltas.length);
50 for (const d of deltas) {
51 const head: number[] = [];
52 let size = d.length;
53 let c = (7 << 4) | (size & 15);
54 size = Math.floor(size / 16);
55 while (size > 0) {
56 head.push(c | 0x80);
57 c = size & 0x7f;
58 size = Math.floor(size / 128);
59 }
60 head.push(c);
61 parts.push(Buffer.from(head), Buffer.from(baseOid, "hex"), deflateSync(d));
62 }
63 const body = Buffer.concat(parts);
64 return new Uint8Array(Buffer.concat([body, createHash("sha1").update(body).digest()]));
65}
66
67/** REF_DELTAs, each against the oid given, then one blob. */
68function refDeltaChain(deltas: { base: string; delta: Uint8Array }[], blob: Uint8Array): Uint8Array {
69 const parts: Buffer[] = [Buffer.from("PACK"), Buffer.from([0, 0, 0, 2]), Buffer.alloc(4)];
70 parts[2]!.writeUInt32BE(deltas.length + 1);
71 const header = (code: number, size: number) => {
72 const head: number[] = [];
73 let c = (code << 4) | (size & 15);
74 size = Math.floor(size / 16);
75 while (size > 0) {
76 head.push(c | 0x80);
77 c = size & 0x7f;
78 size = Math.floor(size / 128);
79 }
80 head.push(c);
81 return Buffer.from(head);
82 };
83 for (const d of deltas) parts.push(header(7, d.delta.length), Buffer.from(d.base, "hex"), deflateSync(d.delta));
84 parts.push(header(3, blob.length), deflateSync(blob));
85 const body = Buffer.concat(parts);
86 return new Uint8Array(Buffer.concat([body, createHash("sha1").update(body).digest()]));
87}
88
89const varint = (n: number) => {
90 const out: number[] = [];
91 do {
92 let b = n & 0x7f;
93 n = Math.floor(n / 128);
94 if (n > 0) b |= 0x80;
95 out.push(b);
96 } while (n > 0);
97 return out;
98};
99
100/** A delta that copies one byte of the base at `at`, then inserts `tag`. */
101function tinyDelta(baseSize: number, at: number, tag: number): Uint8Array {
102 return new Uint8Array([...varint(baseSize), ...varint(2), 0x80 | 0x0f | 0x10, at & 0xff, (at >> 8) & 0xff, (at >> 16) & 0xff, (at >> 24) & 0xff, 1, 1, tag]);
103}
104
105async function unpack(data: Uint8Array, opts: Parameters<typeof StreamedPack.open>[1] = {}) {
106 const seen = new Map<string, { type: string; content: Uint8Array }>();
107 const visit = async (o: StreamedObject, content: Uint8Array | null, feed: ChunkFeed | null) => {
108 let bytes = content;
109 if (!bytes) {
110 const parts: Uint8Array[] = [];
111 await feed!((c) => parts.push(c.slice()));
112 bytes = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
113 let at = 0;
114 for (const p of parts) bytes.set(p, (at += p.length) - p.length);
115 }
116 expect(bytes.length).toBe(o.size);
117 seen.set(o.oid, { type: o.type, content: bytes });
118 };
119 const p = await StreamedPack.open(source(data), { largeObjectBytes: 4 * 1024 * 1024, blockBytes: 64 * 1024, maxBlocks: 4, cacheBytes: 256 * 1024, ...opts }, visit);
120 return { p, seen };
121}
122
123describe("StreamedPack", () => {
124 it("finds the same objects, with the same contents, as parsePack", async () => {
125 const want = await parsePack(full);
126 const { p, seen } = await unpack(full);
127 expect(p.count).toBe(want.length);
128 expect(seen.size).toBe(new Set(want.map((o) => o.oid)).size);
129 for (const o of want) {
130 expect(seen.get(o.oid)?.type).toBe(o.type);
131 expect(Buffer.from(seen.get(o.oid)!.content).equals(Buffer.from(o.content))).toBe(true);
132 }
133 // Every object comes back again for storing: a delta's content rebuilt, any other as its zlib data's place in the pack.
134 const listed = [...p.objects()];
135 expect(listed.map((o) => o.oid).sort()).toEqual(want.map((o) => o.oid).sort());
136 const big = listed.find((o) => o.size === large.length)!;
137 expect(big.deflated).toBeDefined();
138 const deltas = listed.filter((x) => !x.deflated);
139 expect(deltas.length).toBeGreaterThan(0);
140 for (const o of [...deltas.slice(0, 25), ...listed.filter((x) => x.deflated && x !== big).slice(0, 25)]) {
141 expect(Buffer.from(await p.content(o.entry)).equals(Buffer.from(seen.get(o.oid)!.content))).toBe(true);
142 }
143 for (const o of listed.filter((x) => x.deflated && x !== big).slice(0, 25)) {
144 const parts: Uint8Array[] = [];
145 for await (const s of p.slices(o.deflated!.at, o.deflated!.length)) parts.push(s.slice());
146 expect(Buffer.from(inflateSync(Buffer.concat(parts))).equals(Buffer.from(seen.get(o.oid)!.content))).toBe(true);
147 }
148 let zlib = 0;
149 for await (const s of p.slices(big.deflated!.at, big.deflated!.length)) zlib += s.length;
150 expect(zlib).toBe(big.deflated!.length);
151 });
152
153 it("resolves a thin pack's bases from outside the pack", async () => {
154 const stored = new Map<string, GitObject>();
155 for (const o of await parsePack(full)) stored.set(o.oid, { type: o.type, content: o.content });
156 const external = async (oid: string) => stored.get(oid) ?? null;
157 const want = await parsePack(thin, external);
158 const { seen } = await unpack(thin, { external });
159 expect(seen.size).toBe(want.length);
160 for (const o of want) expect(Buffer.from(seen.get(o.oid)!.content).equals(Buffer.from(o.content))).toBe(true);
161 await expect(unpack(thin)).rejects.toThrow(/unresolvable delta base/);
162 });
163
164 it("reads the pack in ranges and keeps the #125 budget", async () => {
165 const reads = { bytes: 0, calls: 0 };
166 await StreamedPack.open(source(full, reads), { blockBytes: 64 * 1024, maxBlocks: 2 }, () => {});
167 expect(reads.calls).toBeGreaterThan(1);
168 // The same limit parsePack applies, counted the same way (deltas are resolved in another order,
169 // so the one that goes over can differ).
170 const over = /delta result of \d+ bytes is larger than the [\d.]+ bytes allowed|pack unpacks to more than/;
171 await expect(parsePack(full, undefined, { maxBytes: 5.2 * 1024 * 1024, largeObjectBytes: 4 * 1024 * 1024 })).rejects.toThrow(over);
172 await expect(unpack(full, { maxBytes: 5.2 * 1024 * 1024 })).rejects.toThrow(over);
173 await expect(unpack(full, { maxWork: 1024 * 1024 })).rejects.toThrow(/split it into smaller pushes/);
174 await expect(unpack(full, { maxObjects: 3 })).rejects.toThrow(/split it into smaller pushes/);
175 });
176
177 it("refuses a corrupt pack", async () => {
178 const bad = full.slice();
179 bad[bad.length - 1]! ^= 1;
180 await expect(unpack(bad)).rejects.toThrow(/checksum mismatch/);
181 const longer = new Uint8Array(full.length + 5);
182 longer.set(full.subarray(0, full.length - 20));
183 longer.set(full.subarray(full.length - 20), full.length + 5 - 20);
184 await expect(unpack(longer)).rejects.toThrow(/trailing garbage|zlib|bad object|size mismatch|truncated/);
185 await expect(unpack(new TextEncoder().encode("NOPE".padEnd(40, "x")))).rejects.toThrow(/not a packfile/);
186 });
187
188 it("refuses an object compressed with gzip instead of zlib", async () => {
189 // git can't read gzip, and the object would be stored as sent. pako takes gzip unless told not
190 // to, and only stumbles on what follows the stream; so these end exactly where a read does.
191 const content = new TextEncoder().encode("hello\n".repeat(100));
192 const gzipped = new Uint8Array(gzipSync(content));
193 const gz = await writePack([{ type: "blob", size: content.length, deflated: gzipped }]);
194 const blockBytes = 14 + gzipped.length;
195 await expect(unpack(gz, { blockBytes })).rejects.toThrow(/zlib error/);
196 await expect(unpack(gz, { blockBytes, largeObjectBytes: 16 })).rejects.toThrow(/zlib error/);
197 // In memory, a large blob is inflated a MiB at a time.
198 let n = 1024 * 1024 - 200;
199 while (gzipSync(new Uint8Array(n), { level: 0 }).length < 1024 * 1024) n++;
200 const big = new Uint8Array(randomBytes(n));
201 const bigGz = new Uint8Array(gzipSync(big, { level: 0 }));
202 expect(bigGz.length).toBe(1024 * 1024);
203 const bigPack = await writePack([{ type: "blob", size: big.length, deflated: bigGz }]);
204 await expect(parsePack(bigPack, undefined, { largeObjectBytes: 16 })).rejects.toThrow(/zlib error/);
205 await expect(parsePack(gz)).rejects.toThrow(/zlib error/);
206 // The same objects in zlib come in.
207 const zl = await writePack([{ type: "blob", size: content.length, deflated: new Uint8Array(deflateSync(content)) }]);
208 expect((await unpack(zl)).seen.size).toBe(1);
209 expect((await unpack(zl, { largeObjectBytes: 16 })).seen.size).toBe(1);
210 expect(await parsePack(await writePack([{ type: "blob", size: big.length, deflated: new Uint8Array(deflateSync(big)) }]), undefined, { largeObjectBytes: 16 })).toHaveLength(1);
211 });
212
213 it("reads a base from outside the pack once for all its deltas, and counts it", async () => {
214 const base = new Uint8Array(randomBytes(1024 * 1024));
215 const oid = await hashObject("blob", base);
216 let reads = 0;
217 const external = async (o: string) => (o === oid ? (reads++, { type: "blob" as const, content: base }) : null);
218 const many = refDeltaPack(oid, Array.from({ length: 200 }, (_, i) => tinyDelta(base.length, i * 4099, i)));
219 // However small the cache, tiny deltas can't make the server read it again and again.
220 for (const cacheBytes of [8 * 1024 * 1024, 64 * 1024]) {
221 reads = 0;
222 const { p, seen } = await unpack(many, { external, cacheBytes });
223 expect(seen.size).toBe(200);
224 expect(reads).toBe(1);
225 expect(p.workDone).toBeLessThan(base.length + 64 * 1024);
226 }
227 // It counts toward what the pack unpacks to, as in parsePack.
228 await expect(unpack(many, { external, maxBytes: base.length })).rejects.toThrow(/unpacks to more than/);
229 await expect(parsePack(many, external, { maxBytes: base.length })).rejects.toThrow(/unpacks to more than/);
230 });
231
232 it("resolves a long delta chain in one pass", { timeout: 60_000 }, async () => {
233 // Each delta's base is listed after it: resolving round by round took a round, and a pass over
234 // every delta left, per link.
235 const n = 20_000;
236 const blob = new TextEncoder().encode("x".repeat(64));
237 const contents = [blob];
238 for (let i = 1; i <= n; i++) contents.push(new Uint8Array([...blob, i & 0xff, i >> 8]));
239 const oids = await Promise.all(contents.map((c) => hashObject("blob", c)));
240 // Copy the 64 bytes the chain shares, then insert the two that number it.
241 const deltas = contents.slice(1).map((c, i) => ({ base: oids[i]!, delta: new Uint8Array([...varint(contents[i]!.length), ...varint(c.length), 0x90, 64, 2, c[64]!, c[65]!]) }));
242 const pack = refDeltaChain(deltas.reverse(), blob);
243 const t = Date.now();
244 const seen = new Map<string, Uint8Array>();
245 await StreamedPack.open(source(pack), {}, (o, content) => void seen.set(o.oid, content!));
246 // Round by round, this took most of a minute.
247 expect(Date.now() - t).toBeLessThan(20_000);
248 expect(seen.size).toBe(n + 1);
249 expect(Buffer.from(seen.get(oids[n]!)!).equals(Buffer.from(contents[n]!))).toBe(true);
250 });
251
252 it("refuses a pack whose index alone wouldn't fit in memory", async () => {
253 // The caches unpack() uses (4 blocks of 64 KiB and 256 KiB), then ENTRY_OVERHEAD for each object.
254 const caches = 4 * 64 * 1024 + 256 * 1024;
255 const count = new DataView(full.buffer, full.byteOffset).getUint32(8);
256 await expect(unpack(full, { maxHeldBytes: caches + (count - 1) * ENTRY_OVERHEAD })).rejects.toThrow(/pack needs more than 0 MiB of memory to unpack; split it into smaller pushes/);
257 // A REF_DELTA costs more (git pack-objects writes them unless asked for OFS_DELTAs): charged as it is read.
258 await expect(unpack(full, { maxHeldBytes: caches + count * ENTRY_OVERHEAD })).rejects.toThrow(/split it into smaller pushes/);
259 // Then the objects themselves while they are in use, and the zlib data kept to inflate again.
260 expect((await unpack(full, { maxHeldBytes: caches + count * (ENTRY_OVERHEAD + REF_DELTA_OVERHEAD) + 16 * 1024 * 1024 })).seen.size).toBeGreaterThan(0);
261 });
262
263 it("charges every object it holds, so one huge object is refused rather than run the isolate out of memory", async () => {
264 const base = new Uint8Array(randomBytes(64 * 1024));
265 const oid = await hashObject("blob", base);
266 // One delta that copies the 64 KiB base 6,000 times over: a 375 MiB blob from a 50 KB pack.
267 const ops: number[] = [];
268 for (let k = 0; k < 6000; k++) ops.push(0x80 | 0x20, 0x00); // copy 0x0000 -> 64 KiB (size 0 means 0x10000)
269 const result = 6000 * 64 * 1024;
270 const bomb = refDeltaChain([{ base: oid, delta: new Uint8Array([...varint(base.length), ...varint(result), ...ops]) }], base);
271 // A tree of zeros: 120 MiB from about 120 KB.
272 const zeros = await writePack([{ type: "tree", size: 120 * 1024 * 1024, deflated: new Uint8Array(deflateSync(new Uint8Array(120 * 1024 * 1024))) }]);
273 for (const pack of [bomb, zeros]) {
274 const before = process.memoryUsage().arrayBuffers;
275 let peak = before;
276 const t = setInterval(() => (peak = Math.max(peak, process.memoryUsage().arrayBuffers)), 1);
277 await expect(StreamedPack.open(source(pack), {}, () => {})).rejects.toThrow(/pack needs more than 80 MiB of memory to unpack; split it into smaller pushes/);
278 clearInterval(t);
279 expect(peak - before).toBeLessThan(64 * 1024 * 1024);
280 await expect(parsePack(pack)).rejects.toThrow(/split it into smaller pushes|larger than/);
281 }
282 });
283
284 it("reads each entry's own range again, keeps small ones from the first pass, and counts its reads", async () => {
285 const reads = { bytes: 0, calls: 0 };
286 const kept = await StreamedPack.open(source(full, reads), { blockBytes: 64 * 1024 }, () => {});
287 const keptCalls = reads.calls;
288 // Without keeping anything, every delta is read again, twice counted (storing replays it), each its own range.
289 const bare = { bytes: 0, calls: 0 };
290 const p = await StreamedPack.open(source(full, bare), { blockBytes: 64 * 1024, keepBytes: 0 }, () => {});
291 expect(bare.calls).toBeGreaterThan(keptCalls);
292 expect(bare.bytes).toBeLessThan(2 * full.length);
293 expect(p.readsDone).toBeGreaterThan(kept.readsDone);
294 await expect(StreamedPack.open(source(full), { blockBytes: 64 * 1024, keepBytes: 0, maxReads: p.readsDone - 1 }, () => {})).rejects.toThrow(/reads .* of storage to unpack; split it into smaller pushes/);
295 // Replaying reads only what was counted.
296 const counted = p.readsDone;
297 await p.replayDeltas(async () => {});
298 expect(p.readsDone).toBe(counted);
299 });
300
301 it("says what storing it adds: each delta's content, not its few bytes in the pack", async () => {
302 const base = new Uint8Array(randomBytes(64 * 1024));
303 const oid = await hashObject("blob", base);
304 // Each copies the base and adds one byte: 200 different 64 KiB blobs from a pack of a few KB.
305 const deltas = Array.from({ length: 200 }, (_, k) => ({ base: oid, delta: new Uint8Array([...varint(base.length), ...varint(base.length + 1), 0x80 | 0x20, 0x00, 1, k]) }));
306 const pack = refDeltaChain(deltas, base);
307 const p = await StreamedPack.open(source(pack), {}, () => {});
308 expect(pack.length).toBeLessThan(200 * 1024);
309 expect(p.storedBytes).toBeGreaterThanOrEqual(200 * (64 * 1024 + 1));
310 });
311
312 it("rebuilds the deltas for storing with exactly the work that already passed", async () => {
313 const stored = new Map<string, GitObject>();
314 for (const o of await parsePack(full)) stored.set(o.oid, { type: o.type, content: o.content });
315 const external = async (oid: string) => stored.get(oid) ?? null;
316 for (const [data, opts] of [
317 [full, {}],
318 [thin, { external }],
319 ] as const) {
320 const unlimited = await unpack(data, opts);
321 // At the tightest limit that lets the pack in, storing it can't go over: nothing is refused halfway through.
322 const { p, seen } = await unpack(data, { ...opts, maxWork: unlimited.p.workDone, yieldEvery: 4096 });
323 const done = p.workDone;
324 const rebuilt: string[] = [];
325 await p.replayDeltas(async (entry, content) => {
326 expect(Buffer.from(content).equals(Buffer.from(seen.get(p.oidOf(entry))!.content))).toBe(true);
327 rebuilt.push(p.oidOf(entry));
328 });
329 expect(p.workDone).toBe(done);
330 // It lets other work in the isolate run as it goes, as open does.
331 const timers = vi.spyOn(globalThis, "setTimeout");
332 await p.replayDeltas(async () => {});
333 expect(timers.mock.calls.length).toBeGreaterThan(1);
334 timers.mockRestore();
335 const deltas = [...p.objects()].filter((x) => !x.deflated);
336 expect(rebuilt.length).toBe(deltas.length);
337 expect(new Set(rebuilt)).toEqual(new Set(deltas.map((x) => x.oid)));
338 }
339 });
340
341 it("hashes and compresses a large object without a second copy of it", async () => {
342 const content = new Uint8Array(randomBytes(8 * 1024 * 1024));
343 const want = createHash("sha1").update(`blob ${content.length}\0`).update(content).digest("hex");
344 const digest = vi.spyOn(crypto.subtle, "digest");
345 expect(await hashObject("blob", content)).toBe(want);
346 // WebCrypto only hashes one whole buffer, so the header and content would be joined first.
347 for (const [, data] of digest.mock.calls) expect((data as Uint8Array).byteLength).toBeLessThanOrEqual(HASH_COPY_MAX + 64);
348 digest.mockRestore();
349 expect(await hashObject("blob", content.subarray(0, 1000))).toBe(createHash("sha1").update("blob 1000\0").update(content.subarray(0, 1000)).digest("hex"));
350 // The same zlib data as deflate, in chunks that are each far smaller than the object.
351 const chunks = [...deflateChunks(content)];
352 expect(Math.max(...chunks.map((c) => c.length))).toBeLessThanOrEqual(64 * 1024);
353 expect(Buffer.concat(chunks).equals(Buffer.from(deflate(content)))).toBe(true);
354 // A 50 MiB tree from a 51 KB pack is hashed where it lies: what it holds stays under the cap with the tree in it.
355 const tree = await writePack([{ type: "tree", size: 50 * 1024 * 1024, deflated: new Uint8Array(deflateSync(new Uint8Array(50 * 1024 * 1024))) }]);
356 const p = await StreamedPack.open(source(tree), {}, () => {});
357 expect(p.peakHeldBytes).toBeLessThan(80 * 1024 * 1024);
358 expect(p.peakHeldBytes).toBeGreaterThan(50 * 1024 * 1024 + SCRATCH_BYTES);
359 });
360
361 it("checks what a base from the repository takes before reading it", async () => {
362 // A 400 MiB blob of zeros is about 400 KB stored; a thin pack's tiny delta against it would inflate it whole.
363 const oid = "1".repeat(40);
364 const asked: number[] = [];
365 let read = false;
366 const external = async (o: string, maxBytes: number) => {
367 if (o !== oid) return null;
368 asked.push(maxBytes);
369 if (400 * 1024 * 1024 > maxBytes) return 400 * 1024 * 1024;
370 read = true;
371 return { type: "blob" as const, content: new Uint8Array(0) };
372 };
373 const thinPack = refDeltaPack(oid, [tinyDelta(400 * 1024 * 1024, 0, 1)]);
374 await expect(StreamedPack.open(source(thinPack), { external }, () => {})).rejects.toThrow(TooMuchMemory);
375 await expect(parsePack(thinPack, external)).rejects.toThrow(/pack needs more than 80 MiB of memory to unpack; split it into smaller pushes/);
376 expect(read).toBe(false);
377 expect(asked).toHaveLength(2);
378 for (const n of asked) expect(n).toBeLessThan(80 * 1024 * 1024);
379 });
380
381 it("rebuilds a long history of one large file holding two versions at a time", { timeout: 60_000 }, async () => {
382 // A 2.6 MiB lockfile over 60 commits, each version a delta against the one before, as git packs it.
383 let version = new Uint8Array(Buffer.from(Array.from({ length: 110_000 }, (_, i) => `"pkg-${i}": "1.0.${i % 97}",\n`).join("").slice(0, 2_600_000)));
384 const blob = version;
385 const deltas: { base: string; delta: Uint8Array }[] = [];
386 for (let k = 0; k < 60; k++) {
387 const n = version.length;
388 const half = n >> 1;
389 const copy = (off: number, len: number) => [0x80 | 0x0f | 0x70, off & 0xff, (off >> 8) & 0xff, (off >> 16) & 0xff, (off >>> 24) & 0xff, len & 0xff, (len >> 8) & 0xff, (len >> 16) & 0xff];
390 const delta = new Uint8Array([...varint(n), ...varint(n + 1), ...copy(0, half), 1, k, ...copy(half, n - half)]);
391 const next = new Uint8Array(n + 1);
392 next.set(version.subarray(0, half));
393 next[half] = k;
394 next.set(version.subarray(half), half + 1);
395 deltas.push({ base: await hashObject("blob", version), delta });
396 version = next;
397 }
398 const chain = refDeltaChain(deltas.reverse(), blob);
399 const last = await hashObject("blob", version);
400 let seen = false;
401 const p = await StreamedPack.open(source(chain), {}, (o, content) => {
402 if (o.oid === last) seen = Buffer.from(content!).equals(Buffer.from(version));
403 });
404 expect(seen).toBe(true);
405 // Its caches and scratch, then the version being rebuilt from and the one being rebuilt.
406 expect(p.peakHeldBytes).toBeLessThan(24 * 1024 * 1024 + 3 * version.length + 1024 * 1024);
407 let stored = 0;
408 await p.replayDeltas(async () => void stored++);
409 expect(stored).toBe(60);
410 // Parsed whole it holds every version: refused as memory, which a push takes as its cue to unpack it in pieces.
411 await expect(parsePack(chain)).rejects.toThrow(TooMuchMemory);
412 });
413
414 it("frees a chain's first version once the next is rebuilt from it", { timeout: 60_000 }, async () => {
415 setFlagsFromString("--expose-gc");
416 const gc = runInNewContext("gc") as () => void;
417 // A 26 MiB text file, then three versions of it, each a small delta against the one before.
418 const n = 26 * 1024 * 1024;
419 let version = new Uint8Array(Buffer.from("a line of a large text file\n".repeat(Math.ceil(n / 28)).slice(0, n)));
420 const blob = version;
421 const deltas: { base: string; delta: Uint8Array }[] = [];
422 for (let k = 0; k < 3; k++) {
423 const len = version.length;
424 // Copies of at most 8 MiB (a copy's size has three bytes), then the byte that's new.
425 const ops: number[] = [];
426 for (let off = 0; off < len; off += 8 << 20) {
427 const m = Math.min(8 << 20, len - off);
428 ops.push(0x80 | 0x0f | 0x70, off & 0xff, (off >> 8) & 0xff, (off >> 16) & 0xff, (off >>> 24) & 0xff, m & 0xff, (m >> 8) & 0xff, (m >> 16) & 0xff);
429 }
430 const delta = new Uint8Array([...varint(len), ...varint(len + 1), ...ops, 1, k]);
431 const next = new Uint8Array(len + 1);
432 next.set(version);
433 next[len] = k;
434 deltas.push({ base: await hashObject("blob", version), delta });
435 version = next;
436 }
437 const chain = new Uint8Array(refDeltaChain(deltas.reverse(), blob));
438 const last = await hashObject("blob", version);
439 version = new Uint8Array(0);
440 let atLast = 0;
441 await StreamedPack.open(source(chain), {}, (o) => {
442 if (o.oid !== last) return;
443 gc();
444 atLast = process.memoryUsage().arrayBuffers;
445 });
446 // Measured against what is left once the unpack is gone.
447 gc();
448 const live = atLast - process.memoryUsage().arrayBuffers;
449 // The version it was rebuilt from and itself, and what the caches hold: not the first version as well.
450 expect(live).toBeGreaterThan(2 * n);
451 expect(live).toBeLessThan(2 * n + 8 * 1024 * 1024);
452 });
453
454 it("measures what a large rebuilt delta compresses to, for counting and storing it", async () => {
455 // 40 versions of a 600 KB file that compresses well: each one stored inline, so no write of its own is counted.
456 let version = new Uint8Array(Buffer.from(Array.from({ length: 30_000 }, (_, i) => `"pkg-${i}": "1.0.${i % 7}",\n`).join("").slice(0, 600_000)));
457 const blob = version;
458 const deltas: { base: string; delta: Uint8Array }[] = [];
459 const versions: Uint8Array[] = [];
460 for (let k = 0; k < 40; k++) {
461 const len = version.length;
462 const delta = new Uint8Array([...varint(len), ...varint(len + 1), 0x80 | 0x70, len & 0xff, (len >> 8) & 0xff, (len >> 16) & 0xff, 1, k]);
463 const next = new Uint8Array(len + 1);
464 next.set(version);
465 next[len] = k;
466 deltas.push({ base: await hashObject("blob", version), delta });
467 versions.push(next);
468 version = next;
469 }
470 const chain = refDeltaChain(deltas.reverse(), blob);
471 const counted = await StreamedPack.open(source(chain), {}, () => {});
472 const p = await StreamedPack.open(source(chain), { writesOver: 512 * 1024 }, () => {});
473 expect(p.readsDone).toBe(counted.readsDone);
474 const byOid = new Map(await Promise.all(versions.map(async (v) => [await hashObject("blob", v), v] as const)));
475 let measured = 0;
476 for (const x of p.objects()) {
477 if (x.deflated) continue;
478 expect(p.zlibSizeOf(x.entry)).toBe(deflate(byOid.get(x.oid)!).length);
479 measured++;
480 }
481 expect(measured).toBe(40);
482 // A pack that unpacks to more than it may says so for the whole push, not for one object.
483 await expect(StreamedPack.open(source(chain), { maxBytes: 4 * 1024 * 1024 }, () => {})).rejects.toThrow("pack unpacks to more than 4 MiB; split it into smaller pushes");
484 await expect(parsePack(chain, undefined, { maxBytes: 4 * 1024 * 1024 })).rejects.toThrow("pack unpacks to more than 4 MiB; split it into smaller pushes");
485 });
486});