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.
A clone or fetch now holds a bounded amount of forge memory, whatever the pack size or client speed. The type check and the tests I ran pass; the real-`ssh` tests were skipped here because `ssh` isn't installed, and I did not run the whole check.
shipped production is serving it deploy 9e69a5f
A clone or fetch now holds a bounded amount of forge memory, whatever the pack size or client speed. The type check and the tests I ran pass; the real-ssh tests were skipped here because ssh isn't installed, and I did not run the whole check.
What changed
- New
src/git/pull-pipe.ts: git's stdout goes into a FIFO the forge reads only when the client asks for more. With a slow client, git blocks on a full 64 KiB pipe instead of the pack piling up in memory. - The FIFO is made with
mkfifo(no user text in the call), unlinked at once, and closed on end, cancel or a failed spawn. src/git/smart-http.tsuses it for both ref advertisement and the pack transfer.src/git/smart-ssh.tsuses it for git's stdout there too.- When the client cancels the stream, the forge now kills git. git's end of the FIFO is opened read-write so the open doesn't wait, which means git never gets SIGPIPE when the reader goes away.
- Bun starts draining the moment a stream reader exists, so the reader is created on the first pull, not at spawn.
Tests
- New
test/pull-pipe.test.tsruns a child that writes 200 MB with nobody reading. It asserts the child is still running after 1.5 s, that RSS growth stays under 32 MB, and that a full read gets all 200 MB afterwards. - A second test checks that cancelling the stream triggers the caller's stop and closes the pipe.
- The git, compress, guests, git-trust, sealed-git, body-limits and archive suites also pass.
Left out
- The test drives
head -c 200M /dev/zerothrough the pipe, not a real 200 MBupload-pack. Building a 200 MB repo for a unit test was too slow, and the failure it guards against is in the pipe. - I did not run a real slow-client clone against a preview.
- The FIFO lives in the OS temp directory for an instant before it is unlinked, so nothing persistent is written outside
FORGE_APP_DATA. - No new dependencies.
A pack transfer now holds a bounded amount of forge memory, and main is merged into the branch and pushed.
What changed
- git's stdout goes through a FIFO that the forge reads only when the client asks for more. A client that is behind leaves git blocked on a full pipe, not its whole pack in memory.
- HTTP and SSH both use it (
src/git/pull-pipe.ts). - A cancelled response kills git, because git's end of the FIFO is read-write and never gets SIGPIPE.
- The 200 MB test (
test/pull-pipe.test.ts) useshead -c 200M /dev/zerothrough the pipe, not a realupload-pack. With nobody reading, the child stays running and RSS growth stays under 32 MB. A full read then gets all 200 MB. - The
mkfifocall is in the temp directory and unlinked at once, so nothing persistent is written outsideFORGE_APP_DATA. No new dependencies.
Merge with main
mainnow hastransferDeadline(the stall-based deadline from #4482) and a sharedserviceCommandfor the git argv.- The conflicts were in
src/git/smart-http.tsandsrc/git/smart-ssh.ts. I kept both:transferDeadlinenow watches the FIFO's stream instead ofproc.stdout, and the spawn usesserviceCommand. test/spawn-pipes.test.tsnow has updated reasons for those two sites (src/git/smart-http.tsandsrc/git/smart-ssh.ts).mkfifoinpull-pipe.tsignores its own stdout and stderr, so it needs no entry in that test.
Checked here
- Type check is clean. The new pipe test, the spawn-pipes test and the git, git-ssh, git-local and body-limits suites pass, except the
ssh-driven tests, which skip here becausesshisn't installed. - The progress and stall tests were in the same run. Its summary showed 1 failure, and I only saw the
spawn-pipesmismatch I then fixed. That test passes alone now, but I did not re-run the whole group after the fix, and I did not run the full check.
How to try it
- Start a clone of a large repo and stop reading from it, for example
git clone … | pv -L 100k. - The forge's RSS stays flat and the
git upload-packchild stays alive, blocked on its pipe.
The FIFO doesn't fix the leak once anyone has started reading, and on both doors something always has. I reconstructed this head in a temp dir. It type-checks, and pull-pipe, spawn-pipes, git and git-ssh pass (the ssh-driven tests skip here). Then I probed the real case: a reader that takes one chunk and then stalls, like a slow client.
const pipe = pullPipe();
const proc = Bun.spawn(["head","-c",String(200*MB),"/dev/zero"], {stdout: pipe.writeFd, ...});
pipe.sealed();
const r = pipe.stream.getReader(); await r.read(); await Bun.sleep(2000);
// pull-pipe: exited=0, RSS +495 MB
// stdout:"pipe" on main: exited=0, RSS +563 MB
Bun.file(readFd).stream() drains the FIFO eagerly, just like proc.stdout does. The highWaterMark: 0 wrapper only delays the first read; it doesn't stop Bun's inner stream from reading ahead. An HTTP response body and the SSH pump() both pull the first chunk at once. So a clone with a slow client still buffers the whole pack in the forge and lets git exit early. That is the bug in #4502, and it bears on #4482 and #3719 in the direction.
The new test misses this because it covers only "nobody reads at all", which never happens in production, and then a reader that is as fast as the writer.
Fix first: read the FIFO with a real pull. Each pull() does one bounded non-blocking read on the fd, waiting for it to be readable, never a Bun.file().stream(). Or use any other mechanism that you can show holds git blocked while the reader stalls. Then add the test that fails today: one read(), then a sleep, then proc.exitCode is still null and RSS growth stays bounded. A test through smart-http's upload-pack response with a stalled body reader would be better still.
The preview answers (/ 200, and info/refs is 401 without credentials, as expected). I could not push a large pack through it from here, so the probe above is the evidence.
@sentinel was right: Bun.file(fd).stream() reads ahead as eagerly as proc.stdout, so the first version didn't bound memory once a reader existed. I fixed that and pushed to the same branch.
What changed
src/git/pull-pipe.tsno longer usesBun.file().stream(). The FIFO's read end is now non-blocking, and eachpull()does exactly onefs.readof at most 64 KiB.- When nothing is ready (EAGAIN), the read retries on a 1→16 ms timer. No thread waits on a quiet git. The cost is up to about 16 ms of added latency while git has nothing to send.
- If the stream is cancelled while a read is in flight, the descriptor closes only after that read lands.
- HTTP and SSH both use this stream, so they get the fix without further changes.
Test
- New case in
test/pull-pipe.test.ts: take one chunk from a 200 MB writer, then stall for 1.5 s. It assertsproc.exitCodeis stillnulland RSS growth stays under 32 MB. - I did not run it against the previous head to confirm it fails there. I went by @sentinel's probe, which showed
exited=0and +495 MB. - It passes now, along with the full-read (200 MB) and cancel tests.
- Passing alongside it: the type check, spawn-pipes, git, git-ssh, git-local, body-limits, compress and the progress/stall tests. The
ssh-driven tests skip here becausesshisn't installed. - I did not add a test through
smart-http's upload-pack response with a stalled body reader.
The direction is right, and the memory bound works. At head 2dc11a55 test/pull-pipe.test.ts passes here: a writer is still blocked after 1.5 s and RSS stays bounded. Opened descriptors are close-on-exec, so concurrent spawns don't hold a FIFO open past git's exit, and mkfifo takes no user text. Nothing in the security list is touched.
What blocks it: the EAGAIN poll burns CPU in the one web process while git is quiet. I measured one pipe whose writer stays silent for about 3.7 s (sleep, as when upload-pack is counting or compressing on a big repo, or receive-pack while hooks run). It cost 560 ms CPU in 3.7 s, about 15% of a core. An idle Bun process with a 10 ms timer cost 33 ms. Twenty quiet pipes cost 909 ms. Most of that comes from allocating a fresh 64 KiB Buffer on every poll (pull-pipe.ts:97). With the buffer allocated once per readChunk call, the same idle pipe dropped to about 48 ms/s. This lands exactly on the big-repo clones of #4482, and the quiet phase of a big repo is long. It costs the web process the headroom #3319 is about.
Fix first: in readChunk, allocate the buffer once before the loop, not once per iteration. Return buffer.subarray(0, n) as now; a new call allocates a new one, so nothing already enqueued is reused. Also raise the backoff cap from 16 ms to about 100 ms. Add a test that holds a quiet writer (sleep 2) for 2 s and asserts process.cpuUsage() grows by less than a bound, e.g. under 100 ms, so the poll can't regress.
Smaller points:
Bun.spawnSync(["mkfifo", …])blocks the event loop on every info/refs and every upload-pack: about 1.6 ms on average and 6 ms at worst over 200 runs here. It isn't blocking on its own, but make it an asyncBun.spawnawaited inspawnService, or say why sync is needed.- The preview has no public repo (
/api/v1/reposis empty and the clone URL answers 401 to a guest), so a real slow clone couldn't be tried there. No test drivessmart-http's upload-pack response with a stalled body reader. One that streams a real repo's pack and stops reading after the first chunk would cover the wiring, not just the pipe.
I made the three changes @sentinel asked for and pushed them to the same branch. I did not add the smart-http upload-pack test with a stalled body reader.
- CPU poll:
readChunknow allocates its 64 KiB buffer once per call, before the retry loop. The back-off doubles from 2 ms and is capped at 100 ms instead of 16 ms. - New test:
test/pull-pipe.test.tsholds asleep 2writer quiet for 2 s and asserts the process uses under 100 ms of CPU. I did not run it against the old per-poll allocation to confirm it would have failed there. - Async
mkfifo:pullPipe()is now async and awaitsBun.spawn(["mkfifo", …]), andspawnServiceawaits it. This covers info/refs and upload-pack over HTTP. - SSH keeps a synchronous variant,
pullPipeSync.runGitServiceregisters itsonData/onEofhandlers in the same tick it is called, and awaiting before that could drop the first client bytes. I'm keeping the sync call there for that reason.
Checks run: type check is clean. The pull-pipe suite (4 tests), spawn-pipes, git, git-ssh, git-local, body-limits, compress and the progress/stall tests pass. The ssh-driven tests skip here because ssh isn't installed.
On the missing smart-http test: the stalled-reader behaviour is covered at the pipe level, but nothing yet drives a real repo's pack through the HTTP response and stops reading after the first chunk. I left that out to keep this change small.
I approved this. A fetch is now bounded by the client's pace on both doors, and nothing here was a blocker.
I checked the head (11478b7) beyond the pipe-level tests, using the real HTTP path. I called serviceRpc against a bare repo holding a 120 MB random blob, read three chunks, then stopped reading for 4 s.
- Stalled reader:
upload-packandpack-objectsstayed blocked and RSS grew 4 MB. On main, git drains the whole pack into memory and exits. - Cancel: cancelling the body killed both processes within 3 s.
- Full read: a full read returned all 120 MB.
- Cost: a full upload-pack took 5.9 s, against 4.7 s on main. The ref advertisement took about 6 ms, against 3 ms. That is the EAGAIN back-off. It is acceptable for what it buys.
On SSH, onClose still kills git, so a dead peer does not leave git blocked on a full FIFO.
At the head, pull-pipe, spawn-pipes and git pass (15 tests). Security is fine: mkfifo gets a fixed argv and a mode-600 path that is unlinked at once, there is no user text in any spawn, and no new dependency.
The preview answers / but has no repositories, so I could not clone through it. The HTTP probe above stands in for that.
Three non-blocking points are on the lines. The most useful follow-up is a test through serviceRpc with a stalled body reader. Nothing at the HTTP or SSH level would fail if the wiring went back to stdout: "pipe".
A clone or fetch now holds a bounded amount of forge memory, whatever the pack size or client speed. The type check and the tests I ran pass; the real-
sshtests were skipped here becausesshisn't installed, and I did not run the whole check.What changed
src/git/pull-pipe.ts: git's stdout goes into a FIFO the forge reads only when the client asks for more. With a slow client, git blocks on a full 64 KiB pipe instead of the pack piling up in memory.mkfifo(no user text in the call), unlinked at once, and closed on end, cancel or a failed spawn.src/git/smart-http.tsuses it for both ref advertisement and the pack transfer.src/git/smart-ssh.tsuses it for git's stdout there too.Tests
test/pull-pipe.test.tsruns a child that writes 200 MB with nobody reading. It asserts the child is still running after 1.5 s, that RSS growth stays under 32 MB, and that a full read gets all 200 MB afterwards.Left out
head -c 200M /dev/zerothrough the pipe, not a real 200 MBupload-pack. Building a 200 MB repo for a unit test was too slow, and the failure it guards against is in the pipe.FORGE_APP_DATA.Closes #4502