From df7151ba305eab3a33a9261a023705b29862b308 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:07:00 +0800 Subject: [PATCH] perf(providers): assemble fragmented responses once per line (#911) --- src/ghidra/GhidraResponseBuffer.test.ts | 49 ++++++++++++++++ src/ghidra/GhidraResponseBuffer.ts | 7 ++- src/hopper/HopperResponseStream.test.ts | 74 +++++++++++++++++++++++++ src/hopper/HopperResponseStream.ts | 7 ++- 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/ghidra/GhidraResponseBuffer.test.ts b/src/ghidra/GhidraResponseBuffer.test.ts index 931ca6846..ccae6d051 100644 --- a/src/ghidra/GhidraResponseBuffer.test.ts +++ b/src/ghidra/GhidraResponseBuffer.test.ts @@ -3,6 +3,55 @@ import { describe, expect, it } from "vitest"; import { GhidraResponseBuffer } from "./GhidraResponseBuffer.js"; describe("Ghidra response buffer", () => { + it("preserves fragmented lines, whitespace and a trailing partial response", () => { + const lines: string[] = []; + const buffer = new GhidraResponseBuffer({ + onLine: (line) => lines.push(line), + }); + buffer.push(' {"result":"雪'); + buffer.push('🦊"}\r'); + buffer.push('\n\n {"result":2}\n{"res'); + expect(lines).toEqual(['{"result":"雪🦊"}', '{"result":2}']); + buffer.push('ult":3}'); + expect(lines).toHaveLength(2); + buffer.push("\n"); + expect(lines).toEqual([ + '{"result":"雪🦊"}', + '{"result":2}', + '{"result":3}', + ]); + }); + + it("drops both pending fragments and remaining lines when reset during delivery", () => { + const lines: string[] = []; + const buffer = new GhidraResponseBuffer({ + onLine: (line) => { + lines.push(line); + buffer.reset(); + }, + }); + buffer.push("discarded partial"); + buffer.reset(); + buffer.push("first\nsecond\npartial"); + buffer.push("discarded"); + buffer.reset(); + buffer.push("next\n"); + expect(lines).toEqual(["first", "next"]); + }); + + it("accepts a large response arriving in many small socket fragments", () => { + const lines: string[] = []; + const buffer = new GhidraResponseBuffer({ + onLine: (line) => lines.push(line), + }); + const response = `{"result":"${"x".repeat(8 * 1024 * 1024)}"}`; + for (let offset = 0; offset < response.length; offset += 4096) + buffer.push(response.slice(offset, offset + 4096)); + expect(lines).toEqual([]); + buffer.push("\n"); + expect(lines).toEqual([response]); + }); + it("accepts complete responses larger than the former 64 MiB ceiling", () => { const lines: string[] = []; const buffer = new GhidraResponseBuffer({ diff --git a/src/ghidra/GhidraResponseBuffer.ts b/src/ghidra/GhidraResponseBuffer.ts index 6b35a4fc0..1d48c76db 100644 --- a/src/ghidra/GhidraResponseBuffer.ts +++ b/src/ghidra/GhidraResponseBuffer.ts @@ -7,6 +7,7 @@ export interface GhidraResponseBufferOptions { export class GhidraResponseBuffer { readonly #options: GhidraResponseBufferOptions; #buffer = ""; + #fragments: string[] = []; constructor(options: GhidraResponseBufferOptions) { this.#options = options; @@ -14,7 +15,10 @@ export class GhidraResponseBuffer { /** Consume one decoded socket chunk. */ push(chunk: string): void { - this.#buffer += chunk; + this.#fragments.push(chunk); + if (!chunk.includes("\n")) return; + this.#buffer += this.#fragments.join(""); + this.#fragments = []; let newline = this.#buffer.indexOf("\n"); while (newline >= 0) { const encodedLine = this.#buffer.slice(0, newline); @@ -28,5 +32,6 @@ export class GhidraResponseBuffer { /** Drop any incomplete line when a session closes. */ reset(): void { this.#buffer = ""; + this.#fragments = []; } } diff --git a/src/hopper/HopperResponseStream.test.ts b/src/hopper/HopperResponseStream.test.ts index 5b1983a8c..fe3bd7ab6 100644 --- a/src/hopper/HopperResponseStream.test.ts +++ b/src/hopper/HopperResponseStream.test.ts @@ -3,6 +3,80 @@ import { describe, expect, it } from "vitest"; import { HopperResponseStream } from "./HopperResponseStream.js"; describe("Hopper response stream", () => { + it("preserves fragmented messages and drops partial bytes on reset", () => { + const messages: unknown[] = []; + const stream = new HopperResponseStream({ + accept: (message) => { + messages.push(message); + return true; + }, + hasQueued: () => false, + nextRequestId: () => 4, + abort: (message) => { + throw new Error(message); + }, + }); + stream.push("discarded"); + stream.reset(); + stream.push(' {"id":1,"result":"雪'); + stream.push('🦊"}\r'); + stream.push('\n\n{"id":2,"result":null}\n{"id":'); + stream.push('3,"result":true}'); + expect(messages).toEqual([ + { id: 1, result: "雪🦊" }, + { id: 2, result: null }, + ]); + stream.push("\n"); + expect(messages).toEqual([ + { id: 1, result: "雪🦊" }, + { id: 2, result: null }, + { id: 3, result: true }, + ]); + }); + + it("stops delivering queued lines when validation aborts and resets the stream", () => { + const messages: unknown[] = []; + const failures: string[] = []; + const stream = new HopperResponseStream({ + accept: (message) => { + messages.push(message); + return true; + }, + hasQueued: () => false, + nextRequestId: () => 2, + abort: (message) => { + failures.push(message); + stream.reset(); + }, + }); + stream.push('malformed\n{"id":1,"result":true}\n'); + expect(messages).toEqual([]); + expect(failures).toHaveLength(1); + stream.push('{"id":1,"result":false}\n'); + expect(messages).toEqual([{ id: 1, result: false }]); + }); + + it("accepts a large response arriving in many small socket fragments", () => { + const messages: unknown[] = []; + const stream = new HopperResponseStream({ + accept: (message) => { + messages.push(message); + return true; + }, + hasQueued: () => false, + nextRequestId: () => 2, + abort: (message) => { + throw new Error(message); + }, + }); + const response = `{"id":1,"result":"${"x".repeat(8 * 1024 * 1024)}"}`; + for (let offset = 0; offset < response.length; offset += 4096) + stream.push(response.slice(offset, offset + 4096)); + expect(messages).toEqual([]); + stream.push("\n"); + expect(messages).toEqual([JSON.parse(response)]); + }); + it("accepts complete responses larger than the former 10 MiB ceiling", () => { const messages: unknown[] = []; const failures: string[] = []; diff --git a/src/hopper/HopperResponseStream.ts b/src/hopper/HopperResponseStream.ts index c4410580e..0f3c294ea 100644 --- a/src/hopper/HopperResponseStream.ts +++ b/src/hopper/HopperResponseStream.ts @@ -13,11 +13,15 @@ export interface HopperResponseStreamOptions { /** Incrementally validates Hopper NDJSON response frames. */ export class HopperResponseStream { #buffer = ""; + #fragments: string[] = []; constructor(readonly options: HopperResponseStreamOptions) {} push(chunk: string): void { - this.#buffer += chunk; + this.#fragments.push(chunk); + if (!chunk.includes("\n")) return; + this.#buffer += this.#fragments.join(""); + this.#fragments = []; let newline = this.#buffer.indexOf("\n"); while (newline >= 0) { const line = this.#buffer.slice(0, newline).trim(); @@ -29,6 +33,7 @@ export class HopperResponseStream { reset(): void { this.#buffer = ""; + this.#fragments = []; } #acceptLine(line: string): boolean {