Public: anyone can read this; the crew can change it.
A clone holds its whole pack in the forge's memory: Bun drains a child's stdout eagerly #4511
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4502-a-clone-holds-its-whole-pack-in-the-forg → main 4 commits fork 8 threads · 8 unresolved
src/git/pull-pipe.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * A pipe that is read only when someone asks. Bun drains a child's stdout as | |
| 3 | * fast as the child writes it, whether or not anything reads the stream, so a | |
| 4 | * `stdout: "pipe"` holds a whole pack in the forge's memory and lets git exit | |
| 5 | * long before the client has it. A FIFO read one bounded chunk per pull is | |
| 6 | * pulled instead: git blocks on a full pipe (64 KiB) while the client is | |
| 7 | * behind, and the forge holds a chunk at a time. | |
| 8 | * | |
| 9 | * The FIFO is made in the temp directory and unlinked at once; only the two | |
| 10 | * descriptors name it afterwards. `mkfifo` takes no user text. | |
| 11 | */ | |
| 12 | ||
| 13 | import { | |
| 14 | closeSync, | |
| 15 | constants, | |
| 16 | openSync, | |
| 17 | read as readFd_, | |
| 18 | unlinkSync, | |
| 19 | } from "node:fs"; | |
| 20 | import { tmpdir } from "node:os"; | |
| 21 | import { join } from "node:path"; | |
| 22 | ||
| 23 | export interface PullPipe { | |
| 24 | /** Hand to `Bun.spawn` as `stdout`; call `sealed()` once it has spawned. */ | |
| 25 | writeFd: number; | |
| 26 | /** The parent's copy of the write end goes: EOF then follows git's exit. */ | |
| 27 | sealed(): void; | |
| 28 | /** Chunks as they are asked for; ends at git's exit, closes the pipe. */ | |
| 29 | stream: ReadableStream<Uint8Array>; | |
| 30 | /** Close both ends (a spawn that failed, a stream nobody will read). */ | |
| 31 | close(): void; | |
| 32 | } | |
| 33 | ||
| 34 | let serial = 0; | |
| 35 | ||
| 36 | /** What one pull reads. */ | |
| 37 | const CHUNK = 64 * 1024; | |
| 38 | ||
| 39 | /** | |
| 40 | * `onCancel` runs when the reader gives up: git's end is a read-write | |
| 41 | * descriptor (so the open does not wait), which means no SIGPIPE ever tells | |
| 42 | * it nobody is listening — the caller stops it. | |
| 43 | */ | |
| 44 | export async function pullPipe( | |
| 45 | onCancel: () => void = () => undefined, | |
| 46 | ): Promise<PullPipe> { | |
| 47 | const path = fifoPath(); | |
| 48 | const made = Bun.spawn(["mkfifo", "-m", "600", path], { | |
| 49 | stdin: "ignore", | |
| 50 | stdout: "ignore", | |
| 51 | stderr: "ignore", | |
| 52 | }); | |
| 53 | if ((await made.exited) !== 0) throw new Error("mkfifo failed"); | |
| 54 | return openPipe(path, onCancel); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * The same, made synchronously: for a caller that must wire its handlers in | |
| 59 | * the tick it is called (the SSH channel delivers data as soon as it exists). | |
| 60 | */ | |
| 61 | export function pullPipeSync(onCancel: () => void = () => undefined): PullPipe { | |
| 62 | const path = fifoPath(); | |
| 63 | const made = Bun.spawnSync(["mkfifo", "-m", "600", path], { | |
| 64 | stdout: "ignore", | |
| 65 | stderr: "ignore", | |
| 66 | }); | |
| 67 | if (made.exitCode !== 0) throw new Error("mkfifo failed"); | |
| 68 | return openPipe(path, onCancel); | |
| 69 | } | |
| 70 | ||
| 71 | function fifoPath(): string { | |
| 72 | return join( | |
| 73 | tmpdir(), | |
| 74 | `forge-pipe-${process.pid}-${Date.now().toString(36)}-${serial++}`, | |
| 75 | ); | |
| 76 | } | |
| 77 | ||
| 78 | function openPipe(path: string, onCancel: () => void): PullPipe { | |
| 79 | let writeFd = -1; | |
| 80 | let readFd = -1; | |
| 81 | try { | |
outdated
High: this is where it breaks. Once the iterator exists, |
||
| 82 | // O_RDWR opens without waiting for a reader; the read end is | |
| 83 | // non-blocking, so a read never parks a thread on a quiet git. | |
| 84 | writeFd = openSync(path, constants.O_RDWR); | |
| 85 | readFd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); | |
| 86 | } catch (error) { | |
| 87 | if (writeFd >= 0) closeSync(writeFd); | |
| 88 | throw error; | |
| 89 | } finally { | |
| 90 | unlinkSync(path); | |
| 91 | } | |
| 92 | ||
| 93 | let writeOpen = true; | |
| 94 | let readOpen = true; | |
| 95 | let reading = false; | |
| 96 | const sealed = (): void => { | |
outdated
A 16 ms cap means about 60 wakeups a second per quiet transfer, for as long as git is quiet. Back off further, to about 100 ms; the latency only matters while git has nothing to send. |
||
| 97 | if (!writeOpen) return; | |
outdated
A fresh 64 KiB allocation on every EAGAIN retry: one quiet transfer costs about 15% of a core (560 ms CPU over 3.7 s, against 33 ms for an idle process). Allocate once per |
||
| 98 | writeOpen = false; | |
| 99 | closeSync(writeFd); | |
| 100 | }; | |
| 101 | const close = (): void => { | |
| 102 | sealed(); | |
| 103 | // A read in flight holds the descriptor; it closes it when it lands. | |
| 104 | if (!readOpen || reading) { | |
| 105 | readOpen = false; | |
| 106 | return; | |
| 107 | } | |
| 108 | readOpen = false; | |
| 109 | closeSync(readFd); | |
| 110 | }; | |
| 111 | ||
| 112 | /** | |
| 113 | * One bounded read, never ahead of the caller: `Bun.file(fd).stream()` and | |
| 114 | * `proc.stdout` both read as fast as the writer fills the pipe. The | |
| 115 | * descriptor is non-blocking, so no thread waits on git; "nothing yet" | |
| 116 | * (EAGAIN) is retried on a growing timer, capped at 100 ms. | |
| 117 | */ | |
| 118 | const readChunk = async (): Promise<Uint8Array | null> => { | |
| 119 | reading = true; | |
| 120 | try { | |
| 121 | // One buffer per call, not per retry: a quiet git is polled for as | |
| 122 | // long as it stays quiet, and allocating each time costs a core's worth. | |
| 123 | const buffer = Buffer.allocUnsafe(CHUNK); | |
| 124 | for (let wait = 2; readOpen; wait = Math.min(wait * 2, 100)) { | |
| 125 | const n = await new Promise<number | "again">((done, fail) => | |
| 126 | readFd_(readFd, buffer, (error, count) => | |
| 127 | error === null | |
| 128 | ? done(count) | |
| 129 | : error.code === "EAGAIN" | |
| 130 | ? done("again") | |
| 131 | : fail(error), | |
| 132 | ), | |
| 133 | ); | |
| 134 | if (n === "again") await Bun.sleep(wait); | |
| 135 | else return n === 0 ? null : buffer.subarray(0, n); | |
| 136 | } | |
| 137 | return null; | |
| 138 | } finally { | |
| 139 | reading = false; | |
| 140 | if (!readOpen) { | |
| 141 | try { | |
| 142 | closeSync(readFd); | |
Not blocking. This |
||
| 143 | } catch { | |
| 144 | /* closed already */ | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | }; | |
| 149 | ||
| 150 | const stream = new ReadableStream<Uint8Array>( | |
| 151 | { | |
| 152 | async pull(controller) { | |
| 153 | try { | |
| 154 | const chunk = await readChunk(); | |
| 155 | if (chunk === null) { | |
| 156 | close(); | |
| 157 | controller.close(); | |
| 158 | } else controller.enqueue(chunk); | |
| 159 | } catch (error) { | |
| 160 | close(); | |
| 161 | controller.error(error); | |
| 162 | } | |
| 163 | }, | |
| 164 | cancel() { | |
| 165 | onCancel(); | |
| 166 | close(); | |
| 167 | }, | |
| 168 | }, | |
| 169 | { highWaterMark: 0 }, | |
| 170 | ); | |
| 171 | return { writeFd, sealed, stream, close }; | |
| 172 | } |
src/git/smart-http.ts +19 −11
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | readBounded, |
| 17 | 17 | } from "../http/limits.ts"; |
| 18 | 18 | import { gitEnv, transferGate } from "./env.ts"; |
| 19 | import { pullPipe, type PullPipe } from "./pull-pipe.ts"; | |
| 19 | 20 | import { transferDeadline, type Stall } from "./progress.ts"; |
| 20 | 21 | |
| 21 | 22 | export type GitService = "git-upload-pack" | "git-receive-pack"; |
| @@ -180,17 +181,24 @@ async function spawnService( | ||
| 180 | 181 | env.GIT_PROTOCOL = protocol; |
| 181 | 182 | } |
| 182 | 183 | |
| 183 | const spawn = () => | |
| 184 | Bun.spawn(serviceCommand(service, ["--stateless-rpc", ...extraArgs, repoDir]), { | |
| 185 | env: gitEnv(env), | |
| 186 | stdin: "pipe", | |
| 187 | stdout: "pipe", | |
| 188 | stderr: "ignore", | |
| 189 | }); | |
| 190 | let proc: ReturnType<typeof spawn>; | |
| 184 | // git's stdout is a pipe read on demand (`pull-pipe.ts`): a slow client | |
| 185 | // holds git blocked on a full pipe, not its whole pack in memory. | |
| 186 | let pipe: PullPipe | undefined; | |
| 187 | let proc: Bun.Subprocess<"pipe", number, "ignore">; | |
| 191 | 188 | try { |
| 192 | proc = spawn(); | |
| 189 | pipe = await pullPipe(() => proc.kill("SIGKILL")); | |
Not blocking. Nothing tests this wiring. A test that calls |
||
| 190 | proc = Bun.spawn( | |
| 191 | serviceCommand(service, ["--stateless-rpc", ...extraArgs, repoDir]), | |
| 192 | { | |
| 193 | env: gitEnv(env), | |
| 194 | stdin: "pipe", | |
| 195 | stdout: pipe.writeFd, | |
| 196 | stderr: "ignore", | |
| 197 | }, | |
| 198 | ); | |
| 199 | pipe.sealed(); | |
| 193 | 200 | } catch (error) { |
| 201 | pipe?.close(); | |
| 194 | 202 | // A spawn that never happened still holds a permit. Sixteen of these and |
| 195 | 203 | // no transfer would ever start again — the permit has to go back on |
| 196 | 204 | // every path out of here, not just the one where a process exists. |
| @@ -203,7 +211,7 @@ async function spawnService( | ||
| 203 | 211 | }; |
| 204 | 212 | const deadline = transferDeadline( |
| 205 | 213 | service === "git-upload-pack", |
| 206 | proc.stdout, | |
| 214 | pipe.stream, | |
| 207 | 215 | options, |
| 208 | 216 | kill, |
| 209 | 217 | ); |
| @@ -242,7 +250,7 @@ async function spawnService( | ||
| 242 | 250 | * loose in the process. |
| 243 | 251 | */ |
| 244 | 252 | async function feedStdin( |
| 245 | proc: Bun.Subprocess<"pipe", "pipe", "ignore">, | |
| 253 | proc: Bun.Subprocess<"pipe", number, "ignore">, | |
| 246 | 254 | service: GitService, |
| 247 | 255 | request: Request, |
| 248 | 256 | idleMs: number, |
src/git/smart-ssh.ts +19 −7
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { isSafeName } from "./guard.ts"; |
| 13 | 13 | import { gitEnv } from "./env.ts"; |
| 14 | import { pullPipeSync } from "./pull-pipe.ts"; | |
| 14 | 15 | import { transferDeadline, type Stall } from "./progress.ts"; |
| 15 | 16 | import { serviceCommand, type GitService } from "./smart-http.ts"; |
| 16 | 17 | |
| @@ -138,19 +139,30 @@ export function runGitService( | ||
| 138 | 139 | channel: GitChannel, |
| 139 | 140 | options: GitServiceOptions = {}, |
| 140 | 141 | ): Promise<number> { |
| 141 | const proc = Bun.spawn(serviceCommand(service, [dir]), { | |
| 142 | env: gitEnv(options.env), | |
| 143 | stdin: "pipe", | |
| 144 | stdout: "pipe", | |
| 145 | stderr: "pipe", | |
| 146 | }); | |
| 142 | // "git-upload-pack" the service is "upload-pack" the subcommand. | |
| 143 | // stdout is read on demand (`pull-pipe.ts`), so a slow peer leaves git | |
| 144 | // blocked on a full pipe instead of its pack buffered here. | |
| 145 | let proc: Bun.Subprocess<"pipe", number, "pipe">; | |
| 146 | const pipe = pullPipeSync(() => proc.kill("SIGKILL")); | |
Not blocking. |
||
| 147 | try { | |
| 148 | proc = Bun.spawn(serviceCommand(service, [dir]), { | |
| 149 | env: gitEnv(options.env), | |
| 150 | stdin: "pipe", | |
| 151 | stdout: pipe.writeFd, | |
| 152 | stderr: "pipe", | |
| 153 | }); | |
| 154 | } catch (error) { | |
| 155 | pipe.close(); | |
| 156 | throw error; | |
| 157 | } | |
| 158 | pipe.sealed(); | |
| 147 | 159 | |
| 148 | 160 | const kill = (): void => { |
| 149 | 161 | proc.kill("SIGKILL"); |
| 150 | 162 | }; |
| 151 | 163 | const { stdout, moved, stop } = transferDeadline( |
| 152 | 164 | service === "git-upload-pack", |
| 153 | proc.stdout, | |
| 165 | pipe.stream, | |
| 154 | 166 | options, |
| 155 | 167 | kill, |
| 156 | 168 | ); |
test/pull-pipe.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { pullPipe } from "../src/git/pull-pipe.ts"; | |
| 3 | ||
| 4 | const MB = 1024 * 1024; | |
| 5 | ||
| 6 | test("a child writing 200 MB stays blocked while nobody reads, and memory stays bounded", async () => { | |
| 7 | const before = process.memoryUsage.rss(); | |
| 8 | const pipe = await pullPipe(); | |
| 9 | const proc = Bun.spawn(["head", "-c", String(200 * MB), "/dev/zero"], { | |
| 10 | stdin: "ignore", | |
| 11 | stdout: pipe.writeFd, | |
| 12 | stderr: "ignore", | |
| 13 | }); | |
| 14 | pipe.sealed(); | |
| 15 | await Bun.sleep(1500); | |
This tests the state before any reader exists, which a response body never stays in. Add the case that matters, and it fails on this head: |
||
| 16 | expect(proc.exitCode).toBeNull(); | |
| 17 | expect(process.memoryUsage.rss() - before).toBeLessThan(32 * MB); | |
| 18 | ||
| 19 | let total = 0; | |
| 20 | for await (const chunk of pipe.stream) total += chunk.length; | |
| 21 | expect(total).toBe(200 * MB); | |
| 22 | expect(await proc.exited).toBe(0); | |
| 23 | expect(process.memoryUsage.rss() - before).toBeLessThan(64 * MB); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a reader that takes one chunk and stalls holds git blocked, memory bounded", async () => { | |
| 27 | const before = process.memoryUsage.rss(); | |
| 28 | const pipe = await pullPipe(); | |
| 29 | const proc = Bun.spawn(["head", "-c", String(200 * MB), "/dev/zero"], { | |
| 30 | stdin: "ignore", | |
| 31 | stdout: pipe.writeFd, | |
| 32 | stderr: "ignore", | |
| 33 | }); | |
| 34 | pipe.sealed(); | |
| 35 | const reader = pipe.stream.getReader(); | |
| 36 | expect((await reader.read()).done).toBe(false); | |
| 37 | await Bun.sleep(1500); | |
| 38 | expect(proc.exitCode).toBeNull(); | |
| 39 | expect(process.memoryUsage.rss() - before).toBeLessThan(32 * MB); | |
| 40 | await reader.cancel(); | |
| 41 | proc.kill("SIGKILL"); | |
| 42 | await proc.exited; | |
| 43 | }); | |
| 44 | ||
| 45 | test("cancelling the stream tells the caller to stop the writer and closes the pipe", async () => { | |
| 46 | let cancelled = 0; | |
| 47 | const pipe = await pullPipe(() => { | |
| 48 | cancelled++; | |
| 49 | proc.kill("SIGKILL"); | |
| 50 | }); | |
| 51 | const proc = Bun.spawn(["head", "-c", String(200 * MB), "/dev/zero"], { | |
| 52 | stdin: "ignore", | |
| 53 | stdout: pipe.writeFd, | |
| 54 | stderr: "ignore", | |
| 55 | }); | |
| 56 | pipe.sealed(); | |
| 57 | const reader = pipe.stream.getReader(); | |
| 58 | await reader.read(); | |
| 59 | await reader.cancel(); | |
| 60 | await proc.exited; | |
| 61 | expect(cancelled).toBe(1); | |
| 62 | expect(proc.signalCode).toBe("SIGKILL"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a quiet writer costs almost no CPU while the reader waits", async () => { | |
| 66 | const pipe = await pullPipe(); | |
| 67 | const proc = Bun.spawn(["sleep", "2"], { | |
| 68 | stdin: "ignore", | |
| 69 | stdout: pipe.writeFd, | |
| 70 | stderr: "ignore", | |
| 71 | }); | |
| 72 | pipe.sealed(); | |
| 73 | const before = process.cpuUsage(); | |
| 74 | for await (const _ of pipe.stream) void _; | |
| 75 | const used = process.cpuUsage(before); | |
| 76 | expect(await proc.exited).toBe(0); | |
| 77 | expect((used.user + used.system) / 1000).toBeLessThan(100); | |
| 78 | }); |
test/spawn-pipes.test.ts +2 −2
| @@ -24,8 +24,8 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = { | ||
| 24 | 24 | "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" }, |
| 25 | 25 | "src/git/local.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end" }, |
| 26 | 26 | "src/git/read.ts": { sites: ["stdout"], reason: "cat-file stdout is read by the long-lived reader; stderr ignored" }, |
| 27 | "src/git/smart-http.ts": { sites: ["stdout"], reason: "stdout is the response body; stderr ignored" }, | |
| 28 | "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout and stderr into the channel" }, | |
| 27 | "src/git/smart-http.ts": { sites: ["stdout"], reason: "stdout is a pull-pipe FIFO (the response body, read on demand); stderr ignored" }, | |
| 28 | "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" }, | |
| 29 | 29 | "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" }, |
| 30 | 30 | "src/looks/capture.ts": { sites: ["stderr"], reason: "stderr read in a background loop; stdout ignored" }, |
| 31 | 31 | "src/release/axe.ts": { sites: ["stderr"], reason: "stderr read for the DevTools address, then drained in a background loop; stdout ignored" }, |
A synchronous spawn blocks the event loop on every info/refs and upload-pack (about 1.6 ms on average, 6 ms at worst here). Prefer
await Bun.spawn(...).exited, since the callers are already async.