From dc07f8b8ce649141b29782847ec1cbd9935bfa5f Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 5 Aug 2026 00:39:58 -0700 Subject: [PATCH] perf(gzip): read compressed input in chunks Read gzip sources with a 32 KiB buffer instead of issuing one reader call per byte. This removes generated call overhead before the native decompression stream receives the payload. Signed-off-by: Christian Stewart --- AGENTS.md | 1 + gs/compress/gzip/index.test.ts | 32 ++++++++++++++++++++++++++++++++ gs/compress/gzip/index.ts | 20 ++++++++++---------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a883e91db..3fd1bc30e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ - NEVER hardcode things: examples include function names, builtins, etc. - Actively improve touched code and design docs when the opportunity is clear: if you find a stale or contradictory design note in the area you are changing, update the doc to match the source/tests instead of preserving the contradiction. - Never use `as unknown as ...`. It is a red flag for a type-system escape hatch hiding a bad runtime contract; return the actual runtime type or fix the owner type signature so the checker catches mismatches. +- NEVER collect a byte stream with one-byte `Reader` calls when the `Reader` accepts a caller-sized buffer. Read in bounded chunks (32 KiB by default for runtime overrides), then parse bytes from the buffer. Cover bulk-input paths with a regression test that caps `Reader` calls. - Go standard library sources are located at "go env GOROOT" (shell command) - Leverage adding more tests (e.g., `compiler/analysis_test.go`) instead of debug logging for diagnosing issues. If the new test case is temporary, add a `tmp_test.go` file to keep things separated. - AVOID type arguments unless necessary (prefer type inference) diff --git a/gs/compress/gzip/index.test.ts b/gs/compress/gzip/index.test.ts index 818fbe030..ca6cdc969 100644 --- a/gs/compress/gzip/index.test.ts +++ b/gs/compress/gzip/index.test.ts @@ -57,6 +57,38 @@ describe('compress/gzip override', () => { expect($.bytesToString(out)).toBe('hello gzip world') }) + test('reader collects bulk input in chunks', async () => { + const input = $.makeSlice(128 * 1024, undefined, 'byte') + let state = 0x12345678 + for (let idx = 0; idx < input.length; idx++) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + input[idx] = state & 0xff + } + + const compressed = $.markAsStructValue(new bytes.Buffer()) + const writer = NewWriter(compressed) + expect(writer.Write(input)[1]).toBeNull() + expect(await writer.Close()).toBeNull() + + const source = bytes.NewReader(compressed.Bytes()) + let readCalls = 0 + const observedReader = { + Read(p: $.Bytes): [number, $.GoError] { + readCalls++ + return source.Read(p) + }, + } + + const [reader, readerErr] = NewReader(observedReader as io.Reader) + expect(readerErr).toBeNull() + const [out, readErr] = await io.ReadAll(reader!) + expect(readErr).toBeNull() + expect($.bytesToUint8Array(out)).toEqual($.bytesToUint8Array(input)) + expect(readCalls).toBeLessThanOrEqual(6) + }) + test('reader reset accepts async generated readers', async () => { const compressed = $.markAsStructValue(new bytes.Buffer()) const writer = NewWriter(compressed) diff --git a/gs/compress/gzip/index.ts b/gs/compress/gzip/index.ts index bd8e81659..0c1f854fa 100644 --- a/gs/compress/gzip/index.ts +++ b/gs/compress/gzip/index.ts @@ -11,6 +11,8 @@ type compressionRuntime = { gunzipSync?: (data: Uint8Array) => Uint8Array } +const readerBufferSize = 32 * 1024 + export const NoCompression = 0 export const BestSpeed = 1 export const BestCompression = 9 @@ -174,9 +176,7 @@ async function gzipBytes(data: Uint8Array, level: number): Promise { return streamTransform(data, new CompressionStreamCtor('gzip')) } -function gunzipBytes( - data: Uint8Array, -): Uint8Array | Promise { +function gunzipBytes(data: Uint8Array): Uint8Array | Promise { const runtime = nodeCompressionRuntime() if (runtime?.gunzipSync != null) { return runtime.gunzipSync(data) @@ -207,7 +207,7 @@ function readGunzipped( | { data: Uint8Array | null; err: $.GoError } | Promise<{ data: Uint8Array | null; err: $.GoError }> { const chunks: Uint8Array[] = [] - const buf = $.makeSlice(1, undefined, 'byte') + const buf = $.makeSlice(readerBufferSize, undefined, 'byte') while (true) { const read = r.Read(buf) if (read instanceof Promise) { @@ -249,12 +249,12 @@ function recordChunk(chunks: Uint8Array[], buf: $.Bytes, n: number): void { } } -function inflateRecorded( - chunks: Uint8Array[], -): { data: Uint8Array | null; err: $.GoError } | Promise<{ - data: Uint8Array | null - err: $.GoError -}> { +function inflateRecorded(chunks: Uint8Array[]): + | { data: Uint8Array | null; err: $.GoError } + | Promise<{ + data: Uint8Array | null + err: $.GoError + }> { try { const inflated = gunzipBytes(concat(chunks)) if (inflated instanceof Promise) {