Repository navigation
Pack streaming follow-ups for #656: a size-cap fix, less work per pull, native inflate - #660
Maximo-Guk wants to merge 24 commits into
Conversation
The size varint in a pack entry header had no length limit. After about 147 continuation bytes the multiplier overflows to Infinity, and a zero digit then makes the size NaN. NaN passes both the check against maxObjectSize and the running check during inflation, so the entry inflates with no cap and fails only at its end, as "smaller than its declared size". With a 1,024-byte cap, an entry carrying such a size inflated 8 MiB in full. decodePackStream now rejects a continuation byte whose multiplier is over the cap, since no size under the cap has a digit that high. The arithmetic was the same in decodePackBytes on main. Co-Authored-By: Claude Code <noreply@anthropic.com>
PackReader asked for 64 KiB per read, but a BYOB read returns as soon as one of the source's chunks arrives. GitHub sent a vscode mount pack (38.6 MiB) as 3,358 sideband packets, 67% of them 8 KiB and 26% 16 KiB, and the gatekeeper forwards each as its own chunk. Through a Workers RPC hop in workerd, a default stream cut the same way took 3,330 reads. Reads now use readAtLeast, which waits for a full buffer: 590 reads for that pack. Each read that follows a storage write costs an implicit commit, so fewer reads mean fewer commits. At the end of the stream readAtLeast returns the short remainder with done unset, then done, on JS byte streams, native streams and RPC-carried ones alike, so more() needs no other change. Not measured end to end: locally the per-read cost was about 0.2 ms and the difference was inside the run-to-run spread. Co-Authored-By: Claude Code <noreply@anthropic.com>
consumePack stores a pack's blobs as they arrive and its trees at the end. Each tree entry then added the gatekeeper to the blob's pullableFrom, rewriting a row that already had it in onRemote. Nothing reads that hint apart from the proof: pull routing takes the union of the two lists, the marking walk tests either, and no path removes an onRemote entry. #recordPullable now writes nothing when the gatekeeper is already in onRemote. Consuming a vscode mount pack (23,280 objects) makes 28,428 metadata writes instead of 47,153, and a TypeScript one (65,044 objects) 66,023 instead of 86,975. Co-Authored-By: Claude Code <noreply@anthropic.com>
A pull sends no haves, so the remote answers with everything the commit needs whether or not the cache has it. A retried pull, or one for the next commit of a repository that is already mounted, is therefore mostly objects the same gatekeeper stored before, and each was deflated and written again along with its metadata row. #storeVerifiedObject now returns early for an object that is present, measured, and already proven for this gatekeeper. The check reads the metadata row first, so an object new to the cache pays nothing extra. Because consumePack keeps a failed pack's blobs, a pull that was cut off (the Overseer reset on CPU, for one) also resumes from them rather than repeating the same work. The same pack consumed a second time, local workerd on Durable Object SQLite, two runs each on a busy machine: - vscode (23,280 objects): 6.7-7.9 s -> 2.8-3.2 s, no writes - TypeScript (65,044 objects): 11.6-15.1 s -> 4.8-7.1 s, two writes (its two oversized trees are measured again) Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
Preview:
|
An object over MAX_GIT_OBJECT_SIZE was held with the commits and trees and only measured once the whole pack had verified. A pull that ended early (a bad trailer, a stream error, the Overseer reset) recorded nothing for it, so the next read asked for the same object again and met the same end. A blob fetch sends no filter, so that is every large file a batch read touches. consumePack now records the size when the object arrives. The oid is the hash of the bytes in hand, the same grade of evidence as the small blobs already stored before the trailer, and it is what lets ensureGitObjects fail fast on the object afterwards. The object is still kept to the end of the pack as a possible delta base, in its own map, so the final loop stores only commits, trees and tags. Co-Authored-By: Claude Code <noreply@anthropic.com>
Each blob consumePack stores as it arrives sat in its own storage.transaction, and the metadata put inside opens a nested one for its index. A transaction is there to undo a store that fails after its first write, and what can fail there is parsing the objects the stored one names, in #referentEntries and the marking walk. A blob names none, and an oversized object is only measured, so both are now stored without the wrapper. Commits, trees and tags keep theirs. If the metadata write itself failed, the blob's row would stay with no metadata: a hash-verified object no gatekeeper can read, which the next pull completes. First pull in local workerd on Durable Object SQLite, best of three, on a machine that was busy for part of the "before" runs: - vscode (23,280 objects): 8.2 s -> 7.3 s - TypeScript (65,044 objects): 14.5 s -> 11.2 s Not measured in production, where the third commit of #656 found the nesting far more expensive than it is locally. Co-Authored-By: Claude Code <noreply@anthropic.com>
encodeLooseObject is the largest single CPU cost of storing a pack: about 2.0 s of 8 s for a vscode mount in local workerd, at zlib's default level 6. It now uses level 1, which is git's own default for loose objects (core.looseCompression). The output is still standard zlib, so isomorphic-git and records written before read the same way. This trades stored bytes for CPU. First pull in local workerd on Durable Object SQLite, best of three: - vscode (23,280 objects): 7.3 s -> 6.2 s, 38.8 MiB -> 43.3 MiB stored - TypeScript (65,044 objects): 11.2 s -> 10.4 s, 26.4 MiB -> 28.9 MiB Co-Authored-By: Claude Code <noreply@anthropic.com>
The pack decoder kept pako because an entry's zlib stream has no recorded length, and finding its end takes an inflater that reports unconsumed input. node:zlib's inflateSync does that when asked for `info`: the returned engine's bytesWritten is the input the stream took. In workerd it tolerates the bytes that follow and enforces maxOutputLength. PackReader now keeps a window of unread bytes instead of one chunk, and inflate() hands inflateSync as much input as zlib itself could turn the declared size into. A stream that runs past that (another deflater's, or a padded one) is inflated again from twice as much; one that ends with the pack still short is truncated. The window drops consumed bytes into the hash when it runs out of room, doubles while one fill keeps reading, and never grows past what the fill asked for. The pako Inflate import, its internals interface and that cast are gone; the `info` result needs a cast of its own, since @types/node types it as a Buffer. The cost is memory for very large entries: the window has to hold an entry's whole compressed stream, and reads ahead up to its declared size, where pako needed one 64 KiB chunk of input. A mount pack's blobs are under 64 KiB, so there the window is about two read buffers except around a large tree. A result smaller than zlib's 16 KiB output chunk comes back as a view on the whole chunk, so it is copied before it is held. Decode only, no storage, local workerd, five alternating runs each: - vscode (23,280 objects): 1.57-1.93 s -> 0.87-1.27 s - TypeScript (65,044 objects): 2.35-3.40 s -> 1.58-2.29 s Both decoders yield the same objects, and the four new tests (declared size mismatch, corrupt data, an entry spanning many reads, a padded stream) pass on either. Co-Authored-By: Claude Code <noreply@anthropic.com>
Two things #656 depends on had no test. Every consumePack test builds a byte stream by hand, the one shape that is certain to support BYOB reads. Production gets a default stream that gatekeeper-github's demuxGitFetchResponse makes in another Worker and Workers RPC carries over; handed to the decoder directly, such a stream throws "This ReadableStream does not support BYOB reads". The new test loads a small Worker through the LOADER binding, which calls consumePack on a GitCacheImpl stub with a default pull stream, as a gatekeeper does. A ref-delta whose base comes later in the pack decoded on main and is now rejected. Git writes a base before its deltas, so nothing real should send one, but the rule was only stated in a comment. Co-Authored-By: Claude Code <noreply@anthropic.com>
The GitCache.consumePack doc called it exactly equivalent to a put() of each object. It differs in ways a gatekeeper can see: an oversized object is left out of the result where put() throws and, since #656, a delta has to follow its base and a pack that fails partway can leave some of its objects stored. The return value was not described at all. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
consumePack keeps every object over MAX_GIT_OBJECT_SIZE inflated until the pack ends, because a later delta may name one as its base. A blob fetch sends no filter, so a batch read can carry any number of them, and the pack's 64 MiB cap bounds only their compressed size. Thirty 5 MiB files that compress well are 150 MiB against the Overseer's 128 MB. #656 kept them the same way, in `held`. They are now kept up to MAX_OVERSIZED_BASE_BYTES (32 MiB), the oldest let go first and the newest always kept, since git writes a delta soon after its base. A delta that names one already let go fails the pack with "delta base ... is unavailable". Every oversized object before it has been measured by then, so the next pull does not ask for them, and without them in the pack the blob that needed the base should arrive whole. That retry is reasoned, not exercised. Nothing here measures Worker memory; the test checks which base is still on hand once two blobs pass the budget. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
The budget on oversized delta bases failed a pack whenever a delta named a base it had let go, on the reasoning that the sizes recorded by then keep those objects out of the next request. That holds only for a request that named each blob. A mount wants a commit and sends no haves, so the remote answers a retry with the same pack, and it fails the same way: a valid pack could never be mounted. Devin's review caught this. Bases are now let go only from a pack that has carried nothing but blobs, which is what the answer to explicit blob wants looks like. A pack with a commit, tree or tag in it keeps every oversized base, as #656 does. For the blob case the retry now happens inside the same read. ensureGitObjects checked recorded sizes once, before its first pull; it now checks on every round, so after a pull fails on a dropped base the objects it measured surface as GitObjectTooLargeError, ensureBlobs takes them out of the batch, and the next pull brings the dependent blob whole. This also turns "every connection failed" into the too-large error whenever a failed pull was what measured the object. Two tests fail on the previous commit: a pack with a commit and two blobs past the budget is consumed, and a batch read whose pack fails on a dropped base completes in one ensureBlobs call with two pulls. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
The last commit let oversized bases go only from a pack that had carried nothing but blobs, taking that to mean the request named each blob. It inferred the request from the pack, and the inference fails when blobs come before the commit: a base is let go before the commit shows the pack is a mount's, a later delta names it, and the retry gets the same pack. Devin's review caught this too. consumePack is now told what the pull asked for. #pullGitObjects mints the stub it hands to gitPull() with the pull's oids, and only an oversized object among them can be let go. That is safe for exactly the reason the budget needs: a pull that names an oversized object can only end in GitObjectTooLargeError for it, and once measured it is left out of the next request, so the pack that failed is not the one the retry gets. An object the pull did not name, such as anything a commit's traversal brought, is never let go, in whatever order the pack carries it. A stub minted for anything else names nothing and lets nothing go. The test for the mount case now puts the two blobs before the commit, and fails on the last commit. The line in #pullGitObjects that passes the oids has no test of its own. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
The pack reaches consumePack() as a stream over RPC. When the gatekeeper's end of it fails (the transfer limit, an error line from the server, a truncated response, the fetch timing out), the overseer's reader learns only that the stream ended early. consumePack() rejects with "ReadableStream received over RPC disconnected prematurely", and gitPull() passed that on. Mounting torvalds/linux showed it. Its depth-1 mount pack is 190.9 MiB against the 64 MiB transfer limit, and the agent was told the connection had dropped, so it tried again. demuxGitFetchResponse now reports what its stream failed with, and pullGitObjectsIntoCache throws that in place of consumePack()'s rejection. Only a failure of the stream's own source is kept, so a rejection that is consumePack()'s own, an invalid pack for one, still comes through. Checked two ways besides the tests. In workerd, a default stream whose pull throws reaches a reader in another Worker as that disconnect error. And this transport run against github.com for that commit throws "git fetch response exceeded the 67108864-byte transfer limit" after 67,067,903 pack bytes. Co-Authored-By: Claude Code <noreply@anthropic.com>
| if (inflator.err) { | ||
| throw new Error(`invalid packfile: corrupt object data (${inflator.msg || inflator.err})`); | ||
| for (let want = size + (size >>> 12) + (size >>> 14) + 13;; want *= 2) { | ||
| let buffered = await this.#fill(want); |
There was a problem hiding this comment.
[P2] Try the buffered input before reading ahead by the inflated size. want is based on the uncompressed size, so a highly compressible 2 MiB blob whose complete zlib stream fits in the first 64 KiB still waits for roughly 2 MiB of subsequent pack data, or clean EOF, before it is decoded. If the fetch stream fails during that read-ahead (timeout, transfer limit, or missing final flush), #fill throws even though this entry has already arrived completely. Consequently consumePackFromGatekeeper never records its oversized measurement, and the next read fetches it again; complete following blobs also lose the streaming progress the previous inflater preserved. Start with the available/read-buffer-sized input and grow it only after Z_BUF_ERROR, and cover a complete compressible oversized entry followed by a later stream error.
There was a problem hiding this comment.
Agreed, fixed in 84353e9. inflate() now starts from what is already buffered, grows to zlib's bound or one read, and doubles from there. There is a test for your scenario: an oversized entry inside the first read is measured though the stream fails on the second.
|
Review: 1 findings. Posted 1 actionable inline comment. |
MAX_GIT_PACK_BYTES did two jobs: it capped the pack, and through maxObjectSize it capped each object inflated from one. The 64 MiB came from gatekeeper-context's artifact sync, where it bounds a repository loaded into memory, and consumePack matched it while it still read the whole pack into memory before decoding. Since #656 the pack streams through, so its cap no longer bounds memory, only how much one pull may download, decode and store. The per-object cap still is a memory bound. They are now separate: MAX_GIT_PACK_OBJECT_SIZE stays at 64 MiB, and MAX_GIT_PACK_BYTES goes to 256 MiB, the smallest round figure that admits a depth-1 mount of torvalds/linux (190.8 MiB, 98,591 objects). That pack through consumePack, local workerd on Durable Object SQLite: every object stored (222.8 MiB of rows), the tree listing all 102,334 paths, in 44 s fed from a local stream and 70 s through an RPC hop in GitHub-sized pieces. In production it would run past the 30 s CPU limit, which this does not touch. Co-Authored-By: Claude Code <noreply@anthropic.com>
MAX_GIT_FETCH_BYTES bounds the raw body of an upload-pack fetch and is kept equal to the overseer's cap on the pack inside it, which is now 256 MiB. The gatekeeper holds none of the body: it strips the framing and streams the pack on, so the limit bounds the work of one pull, not memory here. A depth-1 mount of torvalds/linux is a 190.9 MiB response. Under the 64 MiB limit it failed after 67,067,903 pack bytes. Co-Authored-By: Claude Code <noreply@anthropic.com>
fetchGitUploadPack gave the whole fetch 120 s, body included. That suited a body read straight into memory. Since #656 the pack streams on to the overseer, which reads it only as fast as it stores it, so the budget now covers decoding and storing the pack too. A depth-1 mount of torvalds/linux took 70 s that way in local workerd, and a pack at the 256 MiB cap would be past 90 s. The 120 s now covers the wait for the response and an error body. Once the pack is streaming, git-transport.ts fails the fetch if the server sends nothing for GIT_FETCH_STALL_MS (60 s) while the pull is waiting on it. Time the reader takes between reads does not count. git's upload-pack sends a keepalive every few seconds when it has nothing else to send, so a live fetch stays well inside that. Two tests with fake timers: a body that goes quiet fails after 60 s, and a reader that pauses for ten times that between reads does not. The change to fetchGitUploadPack itself has no test; nothing covered its timeout before either. Co-Authored-By: Claude Code <noreply@anthropic.com>
| await this.#hash?.write(this.#window.subarray(0, this.#pos)); | ||
| let unread = this.#window.subarray(this.#pos, this.#end); | ||
| let window = new Uint8Array( | ||
| Math.min(2 * (unread.byteLength + next.value.byteLength), n + PACK_READ_SIZE)); |
There was a problem hiding this comment.
[P1] Bound the compressed-input window before allocating it. maxObjectSize bounds the inflated output, but want doubles without a memory bound on Z_BUF_ERROR, and this allocation can exceed the Worker's entire memory limit while the pack remains below MAX_GIT_PACK_BYTES. For example, a valid zlib stream consisting of 23,000,000 empty stored blocks and a final empty block consumes 115,000,011 bytes and inflates to zero bytes. For that zero-size entry, the retry reaches want = 218,103,808, and this expression requests a 218,169,344-byte (~208 MiB) window after receiving only ~104 MiB. I confirmed the stream is accepted by native inflateSync({ info: true, maxOutputLength: 1 }) and traced the allocation using this fill/growth arithmetic. The old chunked inflater did not retain this compressed input. Add an independent compressed-entry/window cap and reject before allocating beyond it, or use a streaming inflater, so an otherwise bounded pack cannot terminate the isolate instead of returning a pack error.
There was a problem hiding this comment.
Fixed in cef2254, which landed just after this comment: one entry may take at most an eighth over its declared size in compressed input, plus 64 KiB. Your example scaled down (1 MB of empty stored blocks around a 0-byte object) is now refused with a pack error.
|
Review: 1 findings. Posted 1 new actionable inline comment. The previously posted read-ahead finding also remains applicable. |
Native inflate needs an entry's whole zlib stream in one piece, so the reader buffers it. When the first attempt ran out of input it doubled the amount and tried again, for as long as the stream stayed unfinished. A stream can be padded without limit (empty stored blocks produce no output), so the only bound on that buffer was the pack cap, which the pako reader never leaned on: it took input a chunk at a time. At a 256 MiB pack cap that is more than the Overseer's memory. Three megabytes of padding around ten bytes decoded without complaint. inflate() now makes two attempts: with as much input as zlib itself could produce for the declared size, then with a deflater's worst case (an eighth over, plus a read's slack for small objects). A stream still unfinished there is rejected. The buffer is now bounded by the object cap, not the pack's. The vscode, TypeScript and Linux mount packs from GitHub (23,280, 65,044 and 98,591 objects) all decode within it. The test that held two 200 kB arrays to toStrictEqual now compares oids: under load it took nine seconds and timed out. Co-Authored-By: Claude Code <noreply@anthropic.com>
| let longest = size + (size >>> 3) + PACK_READ_SIZE; | ||
| for (let want of [size + (size >>> 12) + (size >>> 14) + 13, longest]) { |
There was a problem hiding this comment.
🟡 Valid pack entries with short blocks fail
When a valid entry uses many short deflate blocks, longest rejects it before the stream ends. The pack fails to import despite containing valid object data.
Learn more
The compressed length of a valid zlib stream is not limited to an eighth more than its inflated length. Each stored block has five bytes of framing, and a deflater can split the payload into arbitrarily short blocks. The second inflation attempt then receives an incomplete stream and throws Z_BUF_ERROR; the loop exits and the decoder rejects the pack. The prior retry loop accepted the same valid stream.
Example: A 20,000-byte blob stored as 20,000 one-byte deflate blocks produces a valid stream of roughly 120,011 bytes. Its longest value is 88,036 bytes, so decoding rejects the pack instead of yielding that blob.
Recommended fix: Avoid treating a compressed-to-inflated ratio as a validity bound in PackReader.inflate. If the reader must cap per-entry buffering, define it as an explicit resource limit and acknowledge that otherwise-valid streams exceeding it cannot be decoded; alternatively use a streaming inflater that can find the entry boundary without retaining the entire compressed stream.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Agreed that this is a resource limit and not a rule of the format; 84353e9 says so in the comment and the error. The cap stays, because the reader holds an entry's whole stream, and admitting your example would let one entry buffer six times its size.
|
Review: 1 findings. The existing read-ahead finding remains applicable at No new inline comments were posted. |
demuxGitFetchResponse counted the raw response body against MAX_GIT_FETCH_BYTES, a second copy of the overseer's cap on the pack that had to be kept equal to it by hand. The gatekeeper holds none of the body, so the limit protected nothing here that the overseer's does not: consumePack() rejects a pack over its cap and cancels the stream it was reading, which ends the fetch. The limiter is gone, along with the constant. A reader's cancel does reach the source across RPC: in workerd, a default stream handed to another Worker had its cancel() called one pull after that Worker's reader cancelled. One thing is given up. The limiter also counted the bytes around the pack (the sections before it, progress messages), which the overseer never sees. Those are a few bytes per packet from a server that was asked for no progress, but nothing bounds them now. Co-Authored-By: Claude Code <noreply@anthropic.com>
MAX_GIT_PACK_BYTES was documented as matching a transfer limit that gatekeepers were expected to apply to their fetch. The GitHub gatekeeper no longer applies one, and none needs to: a pack over the cap fails in consumePack(), which cancels the stream it was read from. Co-Authored-By: Claude Code <noreply@anthropic.com>
readOrStall declared its timer as possibly undefined and assigned it inside the Promise executor. Under the Workers types clearTimeout takes `number | null`, so `tsc` in this package failed with TS2345 on the clearTimeout call, and the workspace build with it. The timer is now created outside the executor, so it always has a value. Checked with `tsc` run directly in the package: it fails on the previous commit and passes on this one. The earlier commit was pushed on the strength of `vp run build` reporting success, which was a cached result replayed for content it had not checked. Co-Authored-By: Claude Code <noreply@anthropic.com>
Two review comments on PackReader.inflate, both right. It asked for as much input as zlib could turn the declared size into before its first attempt. For an object that compresses well that is far more than its stream: two megabytes of zeros are a couple of kilobytes, yet the reader waited on two more megabytes of the pack behind them. If the stream failed in that wait, an entry that had fully arrived was never decoded, and an oversized one never measured. inflate() now starts from what is already buffered, grows to zlib's bound or one read's worth, and doubles from there. The cap on that input was described as the longest any deflate could be, and a stream past it as padding. Neither is true: a stream may be any length for its output, twenty thousand one-byte stored blocks being six times theirs. The cap is a limit on memory, since the window holds an entry's whole stream. The comment and the error now say so, and that a valid stream past it is refused. The other open comment, that the window could grow to the size of the pack, was answered by the commit that added the cap. New test: an oversized entry inside the first read is measured though the stream fails on the second. It fails on the previous commit. Co-Authored-By: Claude Code <noreply@anthropic.com>
| throw new Error(`git fetch response exceeded the ${maxBytes}-byte transfer limit`); | ||
| } | ||
| for (let item of parser.push(value)) { | ||
| for (let item of parser.push(result.value)) { |
There was a problem hiding this comment.
🟡 Final flush bypasses fetch overhead limit
When a response chunk ends with a flush, demuxPackData returns before checking accumulated overhead. A single chunk can carry excessive progress data and still complete successfully.
Learn more
The demultiplexer counts body bytes in received and pack bytes in delivered, but checks their difference only after processing every parsed packet in a chunk. Processing a flush exits the generator from inside that loop, so a chunk containing a flush never reaches this check. That is common for a small response received as one chunk, and it can also happen after a large progress packet. The intended response overhead limit is then unenforced for the final chunk.
Example: A fetch body arrives as one chunk containing the packfile header, a small band-1 pack, twenty 60,000-byte band-2 progress packets, and a flush. The generator yields the pack and returns on the flush; it never checks the roughly 1.2 MB of progress against the 1 MB allowance.
Recommended fix: Check the accumulated overhead before returning on a flush, and ensure the check also runs for terminal packets in a chunk. Keep the ordinary between-chunk check for responses that continue without a flush.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let input = this.#window.subarray(this.#pos, this.#pos + Math.min(buffered, want)); | ||
| let inflated: InflatedEntry; | ||
| try { | ||
| inflated = inflateSync(input, { info: true, maxOutputLength: size || 1 }) as |
There was a problem hiding this comment.
[P1] Budget for the inflater's output copies as well as its input window. A single 48 MiB blob compressed with level 0 is below both current caps, but this reader grows its window to 56,754,176 bytes before decoding it. Workerd's inflateSync(..., { info: true }) uses processChunkSync, which retains the inflated chunks while allocating Buffer.concat(buffers, nread): another two live 48 MiB allocations. Together these require 157,417,472 bytes (150 MiB), already beyond the isolate's 128 MB limit, even with no retained delta bases. I verified the compressed stream with native zlib and traced the current window-growth arithmetic. The previous chunked reader did not retain this additional compressed-input window, so an entry that previously fit can now exhaust memory before its oversized measurement is recorded. Use a streaming inflater, or enforce a smaller per-entry budget accounting for the compressed window and both output allocations before entering this call.
|
Review: 1 findings. Posted 1 actionable inline comment. |
Removing the transfer limit left one thing unbounded, which review caught: the bytes of a response that are not pack data. The overseer caps the pack it is sent, but it never sees packet framing, the sections before the pack, progress or keepalives, and each of those that arrives also holds off the stall timeout. A server that kept sending progress would be read without end. demuxPackData now counts what it receives against what it delivers, and gives the fetch up once the difference passes 1 MiB plus a sixteenth of the pack data delivered so far. That scales with the pack the overseer is already limiting, so there is still no second copy of its cap here. A depth-1 mount of torvalds/linux through this transport: 200,205,634 bytes of pack and 106,875 of everything else, 0.053%. Co-Authored-By: Claude Code <noreply@anthropic.com>
Native inflate needs an entry's whole compressed stream at once, and joins its output from 16 KiB chunks. For one large object that is the stream, the chunks and the joined copy all live together: review worked it through for a 48 MiB blob deflated at level 0, about 150 MiB against the Overseer's 128 MB. The pako reader in #656 held one read of input, so the same entry fit there. An entry declaring more than 1 MiB now goes back to that loop: pako, a read at a time, with no input kept beyond the read in hand. Its output is written into one buffer of the declared size, so the chunk list and the copy that joined it are gone too, and inflating that 48 MiB entry takes about 48 MiB where #656 took 96. Entries up to 1 MiB, which is all of a mount pack but the odd large tree, stay on native inflate, where the most one can buffer is now a little over a megabyte. pako's Inflate import, its internals interface and that cast are back for this path. It is given windowBits 15, so it reads zlib only, not the gzip it would otherwise also accept. Run for real: the 48 MiB level-0 entry goes through consumePack and is measured; the vscode, TypeScript and Linux mount packs decode to the same totals, TypeScript's two trees over 1 MiB on the new path. Memory is reasoned from what each path holds, not measured. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
LGTM! |
|
Review: 1 findings. The existing final-flush overhead-limit finding remains applicable at No new inline comments were posted. |
Eval resultsVerdict: ⚪ Unchanged. No task moved beyond what 10 runs can tell apart from noise.
Failed checks
|
🔬 Eval runs reviewPerformanceAgainst the supplied baseline, Change Calendar rose from 80% to 90%, Incident Desk fell from 100% to 90%, and Chess and Worker Logs stayed at 100%; none moved beyond noise in 10 runs. Worker Logs shortened from 25.1 to 19.9 steps and 3.9 to 3.4 minutes, with mean cost approximately $0.0220 → $0.0207, but cache hits declined significantly from 92.41% to 90.23%, without an additional miss standing out in the transcripts. Chess remained the largest expense at approximately $0.0614 → $0.0617 per run, with unmatched-edit errors rising from 7 to 16; the candidate’s two failed runs came from an incorrect incident success result and a calendar verifier mismatch. ⚪ VERDICT: NO REGRESSION FROM THIS PRThe comparison is unchanged, and the diff’s git-pack decoding, storage and fetch changes explain neither the candidate failures nor the cache-rate shift, whose turn-boundary misses appear on both sides. TriageFailure modes
Tool errors
Prompt cache
What to do
|
|
Snagging a few of these: the rest I think its pragmatic to defer until slated runtime changes (cpu top-off) |
|
Closing, as relevant findings have been cherry-picked |
Twenty-four commits on top of #656, from reviewing it by running the branch in local workerd against the real vscode and TypeScript depth-1 packs. The first ten are ordered from least to most debatable and each is meant to be taken or dropped on its own. Commits 11 to 13 answer Devin's review comments here and go together. Commit 14 is in
gatekeeper-githuband stands alone. Commits 15 to 17 lift the limits that stopped a mount of torvalds/linux, short of the CPU limit. Commits 18, 22 and 24 fix commit 8 and should be taken with it. Commits 19 and 20 remove the gatekeeper's own transfer limit, which makes 16 unnecessary. Commit 21 fixes a type error in 17. Commit 23 restores a bound that 19 dropped. Take whichever you want.PackReaderworkshop-shared)gatekeeper-github)consumePack: all 98,591 objects stored, 44–70 s locallygatekeeper-github)gatekeeper-github)gatekeeper-github); replaces 16tscingatekeeper-githubtscrun directly fails on 17 and passes hereCommits 1–10 together, first pull, best of three, two alternating passes: vscode 8.1–9.6 s → 5.0–5.8 s, TypeScript 15.7 s → 10.0–10.5 s. Every timing here is local workerd on Durable Object SQLite, on a machine that was busy with another build, so treat them as ±15%. Nothing was measured in production.
Dependencies: 6 and 11 edit the lines 5 introduces, 12 and 13 correct 11, 18, 22 and 24 correct 8, 21 corrects 17, 19 replaces 16, 23 corrects 19, and 8 rewrites the reader that 2 changes one line of. The rest are independent.
1. Bound a pack entry's size varint (bug)
The size varint had no length limit.
0xB0, about 150 ×0x80, then0x00makessizeNaN, and NaN passes bothsize > maxObjectSizeand the runningtotal > size. The decoder now rejects a continuation byte whose multiplier is over the cap. The arithmetic was the same on main.2. Read full 64 KiB buffers
A BYOB read returns as soon as one source chunk arrives, and GitHub sent a 38.6 MiB vscode pack as 3,358 sideband packets, mostly 8 KiB.
readAtLeastwaits for a full buffer. Locally a read after a write cost about 0.2 ms, and the difference was inside the run-to-run spread, so this one wants the production harness.3. Skip the
pullableFromwrite for a gatekeeper already inonRemoteBlobs are stored before the trees that name them, so every tree entry rewrote a blob's row to add a hint its proof already covers. Pull routing unions the two lists, the marking walk tests either, and nothing removes an
onRemoteentry. TypeScript goes from 86,975 metadata writes to 66,023.4. Leave an object the same gatekeeper already stored untouched
Pulls send no
haves, so a retry, or the next commit of a mounted repository, delivers nearly everything again, and all of it was deflated and rewritten. The metadata row is checked first, so an object new to the cache pays nothing extra. A pull that was reset on CPU also resumes from the blobs the first attempt kept. TypeScript, same pack again: 11.6–15.1 s → 4.8–7.1 s.5. Measure an oversized entry as it arrives
An object over 1 MiB was only measured after the trailer verified, so a pull that ended early recorded nothing and the next read fetched it again. It is now measured on arrival and still kept as a possible delta base.
6. No transaction around a streamed blob
What can fail partway through a store is parsing the objects it names, and a blob names none. This removes one savepoint level per blob, the thing your third commit found expensive in production. I only measured it locally, and your earlier numbers show local transaction cost does not predict production, so check it in your harness before taking it.
7. Deflate at level 1
encodeLooseObjectis the largest single CPU cost of storing a pack. Level 1 is git's own default for loose objects. It costs 12% more stored bytes on vscode and 9% on TypeScript (26.4 MiB → 28.9 MiB). Your second commit kept the default level on purpose, so this is a call for you.8. Native inflate (the largest change)
inflateSync(buf, { info: true, maxOutputLength })works in workerd and reports the input it consumed inengine.bytesWritten, so the decoder no longer needs pako to find where an entry ends.PackReaderkeeps a window of unread bytes instead of one chunk, and a stream longer than zlib itself would make it is inflated again from twice the input.Things to weigh:
PackReader, about 90 lines of the hostile-input read path.as unknown ascasts: one for theinforesult, which@types/nodetypes as a Buffer, and the cast to pako's internals that Stream git packs through consumePack instead of buffering them #656 already had.9 and 10. Tests and docs
A test loads a small Worker through
LOADERthat callsconsumePackwith a default pull stream, the shape production sees; until now every test built a byte stream by hand. Another pins that a ref-delta whose base comes later in the pack is rejected, which main accepted. TheconsumePackdoc inworkshop-sharednow describes the return value, the delta order rule and what a failed pack leaves stored.11 to 13. Bound the oversized objects kept as delta bases
consumePackkeeps every object over 1 MiB inflated until the pack ends, because a later delta may name one as its base. A blob fetch sends no filter, so a batch read can carry any number of them, and the 64 MiB pack cap bounds only their compressed size. #656 keeps them the same way, inheld.With all three commits:
MAX_OVERSIZED_BASE_BYTES(32 MiB) of kept bases, the ones the pull asked for by name are dropped, oldest first, and the newest is always kept.consumePacklearns what the pull asked for from the stub:#pullGitObjectsnow mints theGitCacheImplit hands togitPull()with the pull's oids. Any other stub names nothing and drops nothing.GitObjectTooLargeErrorfor it, and once measured it is left out of the next request. So when a delta names a dropped base and the pack fails, the retry is a different request.ensureGitObjectsnow checks recorded sizes on every round, so the blobs the failed pull measured surface as too large,ensureBlobstakes them out, and the next pull brings the dependent blob whole. A test does this in oneensureBlobscall with two pulls.How it got here: commit 11 dropped from every pack, and Devin pointed out that a mount would then fail for good, since it sends no
haves and gets the same pack on every retry. Commit 12 dropped only from packs of blobs alone, and Devin pointed out that blobs can precede the commit. Commit 13 stops inferring the request from the pack. They are separate commits so the review threads stay anchored; I can squash them.Things to weigh:
#pullGitObjectsthat passes the oids has no test of its own, and nothing here measures Worker memory.14. Report why a git fetch failed
The pack reaches
consumePack()as a stream over RPC. When the gatekeeper's end of it fails, the overseer's reader learns only that the stream ended early, soconsumePack()rejects with "ReadableStream received over RPC disconnected prematurely" andgitPull()passed that on. That covers the transfer limit, an error line from the server, a truncated response and the fetch timing out.I hit it mounting torvalds/linux in local dev. Its depth-1 mount pack is 190.9 MiB against the 64 MiB transfer limit, and the agent was told the connection had dropped, so it retried.
pullGitObjectsIntoCachenow throws what its own stream failed with. A rejection that isconsumePack()'s own still comes through.Commits 15 to 17 then raise the limit.
15 to 17. Let a larger repository mount
Both 64 MiB caps date from when the whole pack was held in memory: the figure came from
gatekeeper-context's artifact sync, where it bounds a repository loaded into memory, andconsumePackmatched it while it buffered the pack. Since #656 the pack streams through.MAX_GIT_PACK_BYTESdid. The per-object inflation cap is still a memory bound and stays at 64 MiB asMAX_GIT_PACK_OBJECT_SIZE. The pack cap now only bounds the work of one pull and goes to 256 MiB, the smallest round figure that admits a depth-1 mount of torvalds/linux (190.8 MiB, 98,591 objects).MAX_GIT_FETCH_BYTESingatekeeper-githubto match. Commit 19 then removes that limit altogether, so 16 is only needed if 19 is not taken.fetchGitUploadPackgave the whole fetch 120 s, body included. With the pack streaming to the overseer, that budget now covers decoding and storing it: Linux took 70 s through an RPC hop locally. The 120 s now covers the wait for the response, and once the pack is streaming the fetch fails only if the server sends nothing for 60 s while the pull is waiting on it.Things to weigh:
fetchGitUploadPackitself has no test; nothing covered its timeout before either.19 and 20. One limit on a pull's size
MAX_GIT_FETCH_BYTESingatekeeper-githubwas a second copy of the overseer's pack cap, kept equal to it by hand. The gatekeeper holds none of the body, andconsumePack()already rejects a pack over its cap and cancels the stream it was reading, which ends the fetch. I checked that a reader's cancel reaches the source across RPC: in workerd, a default stream handed to another Worker had itscancel()called one pull after that Worker's reader cancelled.The gatekeeper's limiter also counted the bytes around the pack (framing, the sections before it, progress, keepalives), which the overseer never sees, and each of those that arrives holds off the stall timeout. Commit 19 left them unbounded, which review caught. Commit 23 bounds them without bringing the second copy of the cap back: a response may carry 1 MiB of bytes that are not pack data, plus a sixteenth of the pack delivered so far. A real Linux fetch carries 106,875, or 0.053%.
Testing
workshop-backendunit suite passes (1,318 tests) and bothgatekeeper-githubsuites pass (97 and 60 tests), along with lint.vp run --no-cache build, 76 tasks). Commit 17 went up with a type error because I had trustedvp run buildfor the package, and it replayed a cached success; commit 21 fixes it.Left out
These need a decision more than code: TypeScript's two trees over 1 MiB (the mount reports success but those directories cannot be listed), and the CPU limit (
limits.cpu_ms), which is what stops a repository the size of TypeScript or Linux in production.🤖 Generated with Claude Code