Skip to content

Commit fc960e3

Browse files
committed
fix(search): simplify bounded response reader
Coverage: 94.97% (was 94.90%)
1 parent a7c3ac6 commit fc960e3

2 files changed

Lines changed: 121 additions & 65 deletions

File tree

__tests__/unit/searxng-response.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const results = createTestResults();
1717
const encoder = new TextEncoder();
1818

1919
interface StreamOptions {
20+
cancelAborts?: AbortController;
2021
cancelRejects?: boolean;
2122
leaveOpen?: boolean;
2223
readRejects?: boolean;
@@ -40,6 +41,7 @@ function responseWithChunks(chunks: Uint8Array[], options: StreamOptions = {}) {
4041
cancel(reason) {
4142
cancelCalls++;
4243
cancelReason = reason;
44+
options.cancelAborts?.abort(new Error("cancel-triggered abort"));
4345
if (options.cancelRejects) return Promise.reject(new Error("cancel failed"));
4446
return undefined;
4547
},
@@ -136,6 +138,18 @@ async function runTests() {
136138
);
137139
assert.equal(overflowWithCancelFailure.getCancelCalls(), 1);
138140
assert.equal(overflowWithCancelFailure.response.body!.locked, false);
141+
142+
const cancellationAbortController = new AbortController();
143+
const cancellationAbort = responseWithChunks([encoder.encode("abcd")], {
144+
leaveOpen: true,
145+
cancelAborts: cancellationAbortController,
146+
});
147+
await assert.rejects(
148+
() => readSearxngResponseBody(cancellationAbort.response, 3, { signal: cancellationAbortController.signal }),
149+
/SearXNG response exceeds configured byte limit/,
150+
);
151+
assert.equal(cancellationAbort.getCancelCalls(), 1);
152+
assert.equal(cancellationAbort.response.body!.locked, false);
139153
}, results);
140154

141155
await testFunction("complete mode measures raw UTF-8 bytes before decoding with actual bodies", async () => {

src/searxng-response.ts

Lines changed: 107 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const PREVIEW_MAX_SEARXNG_RESPONSE_BYTES = 64 * 1024;
88

99
const INVALID_RESPONSE_BODY_MESSAGE = "Invalid SearXNG response body";
1010
const RESPONSE_TOO_LARGE_MESSAGE = "SearXNG response exceeds configured byte limit";
11+
const responseLimitErrors = new WeakSet<Error>();
1112
let warnedInvalidConfigurationServers = new WeakSet<object>();
1213

1314
export interface SearxngResponseReadOptions {
@@ -88,28 +89,29 @@ function abortReason(signal: AbortSignal): unknown {
8889
return error;
8990
}
9091

91-
export async function readSearxngResponseBody(
92-
response: Response,
93-
maxBytes: number,
94-
options: SearxngResponseReadOptions = {},
95-
): Promise<SearxngResponseReadResult> {
96-
const body = responseBodyOrThrow(response);
97-
if (body === null) {
98-
return { text: "", bytesRead: 0, truncated: false };
99-
}
92+
function responseTooLargeError(): Error {
93+
const error = new Error(RESPONSE_TOO_LARGE_MESSAGE);
94+
responseLimitErrors.add(error);
95+
return error;
96+
}
10097

101-
const preview = options.preview === true;
102-
const previewLimit = options.previewMaxBytes ?? PREVIEW_MAX_SEARXNG_RESPONSE_BYTES;
103-
const effectiveLimit = preview ? Math.min(maxBytes, previewLimit) : maxBytes;
104-
const reader = body.getReader();
105-
const chunks: Uint8Array[] = [];
106-
let bytesRetained = 0;
107-
let bytesRead = 0;
98+
interface ResponseAccumulator {
99+
chunks: Uint8Array[];
100+
bytesRead: number;
101+
bytesRetained: number;
102+
}
103+
104+
interface AbortReadRace {
105+
dispose(): void;
106+
read(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<ReadableStreamReadResult<Uint8Array>>;
107+
throwIfAborted(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void>;
108+
}
109+
110+
function createAbortReadRace(signal: AbortSignal | undefined): AbortReadRace {
108111
let abortRequested = false;
109112
let abortedWith: unknown;
110113
let abortHandler: (() => void) | undefined;
111114
let abortPromise: Promise<never> | undefined;
112-
const signal = options.signal;
113115

114116
if (signal !== undefined) {
115117
abortPromise = new Promise<never>((_resolve, reject) => {
@@ -118,66 +120,106 @@ export async function readSearxngResponseBody(
118120
abortedWith = abortReason(signal);
119121
reject(abortedWith);
120122
};
121-
if (signal.aborted) {
122-
abortHandler();
123-
} else {
124-
signal.addEventListener("abort", abortHandler, { once: true });
125-
}
123+
if (signal.aborted) abortHandler();
124+
else signal.addEventListener("abort", abortHandler, { once: true });
126125
});
127126
}
128127

129-
try {
130-
while (true) {
131-
const readPromise = reader.read();
132-
const { done, value } = abortPromise === undefined
133-
? await readPromise
134-
: await Promise.race([readPromise, abortPromise]);
135-
if (done) {
136-
break;
137-
}
138-
if (!value) {
139-
continue;
128+
return {
129+
dispose: () => {
130+
if (signal !== undefined && abortHandler !== undefined) signal.removeEventListener("abort", abortHandler);
131+
},
132+
read: (reader) => abortPromise === undefined
133+
? reader.read()
134+
: Promise.race([reader.read(), abortPromise]),
135+
throwIfAborted: async (reader) => {
136+
if (abortRequested) {
137+
await bestEffortCancel(reader, abortedWith);
138+
throw abortedWith;
140139
}
140+
},
141+
};
142+
}
141143

142-
bytesRead += value.byteLength;
143-
const remaining = effectiveLimit - bytesRetained;
144-
if (value.byteLength > remaining) {
145-
if (preview && remaining > 0) {
146-
chunks.push(value.subarray(0, remaining));
147-
bytesRetained += remaining;
148-
}
149-
await bestEffortCancel(reader);
150-
if (preview) {
151-
return {
152-
text: new TextDecoder("utf-8").decode(concatenateChunks(chunks, bytesRetained)),
153-
bytesRead,
154-
truncated: true,
155-
};
156-
}
157-
throw new Error(RESPONSE_TOO_LARGE_MESSAGE);
158-
}
144+
function buildReadResult(accumulator: ResponseAccumulator, truncated: boolean): SearxngResponseReadResult {
145+
return {
146+
text: new TextDecoder("utf-8").decode(concatenateChunks(accumulator.chunks, accumulator.bytesRetained)),
147+
bytesRead: accumulator.bytesRead,
148+
truncated,
149+
};
150+
}
151+
152+
async function retainResponseChunk(
153+
reader: ReadableStreamDefaultReader<Uint8Array>,
154+
value: Uint8Array | undefined,
155+
effectiveLimit: number,
156+
preview: boolean,
157+
accumulator: ResponseAccumulator,
158+
): Promise<SearxngResponseReadResult | undefined> {
159+
if (!value) return undefined;
160+
161+
accumulator.bytesRead += value.byteLength;
162+
const remaining = effectiveLimit - accumulator.bytesRetained;
163+
if (value.byteLength <= remaining) {
164+
accumulator.chunks.push(value);
165+
accumulator.bytesRetained += value.byteLength;
166+
return undefined;
167+
}
159168

160-
chunks.push(value);
161-
bytesRetained += value.byteLength;
169+
if (preview && remaining > 0) {
170+
accumulator.chunks.push(value.subarray(0, remaining));
171+
accumulator.bytesRetained += remaining;
172+
}
173+
await bestEffortCancel(reader);
174+
if (preview) return buildReadResult(accumulator, true);
175+
throw responseTooLargeError();
176+
}
177+
178+
async function consumeResponseBody(
179+
reader: ReadableStreamDefaultReader<Uint8Array>,
180+
effectiveLimit: number,
181+
preview: boolean,
182+
signal: AbortSignal | undefined,
183+
): Promise<SearxngResponseReadResult> {
184+
const accumulator: ResponseAccumulator = { chunks: [], bytesRead: 0, bytesRetained: 0 };
185+
const abortRace = createAbortReadRace(signal);
186+
187+
try {
188+
while (true) {
189+
const { done, value } = await abortRace.read(reader);
190+
if (done) return buildReadResult(accumulator, false);
191+
const truncated = await retainResponseChunk(reader, value, effectiveLimit, preview, accumulator);
192+
if (truncated !== undefined) return truncated;
162193
}
163194
} catch (error) {
164-
if (abortRequested) {
165-
await bestEffortCancel(reader, abortedWith);
166-
throw abortedWith;
167-
}
195+
if (error instanceof Error && responseLimitErrors.has(error)) throw error;
196+
await abortRace.throwIfAborted(reader);
168197
throw error;
169198
} finally {
170-
if (signal !== undefined && abortHandler !== undefined) {
171-
signal.removeEventListener("abort", abortHandler);
172-
}
173-
reader.releaseLock();
199+
abortRace.dispose();
174200
}
201+
}
175202

176-
return {
177-
text: new TextDecoder("utf-8").decode(concatenateChunks(chunks, bytesRetained)),
178-
bytesRead,
179-
truncated: false,
180-
};
203+
export async function readSearxngResponseBody(
204+
response: Response,
205+
maxBytes: number,
206+
options: SearxngResponseReadOptions = {},
207+
): Promise<SearxngResponseReadResult> {
208+
const body = responseBodyOrThrow(response);
209+
if (body === null) {
210+
return { text: "", bytesRead: 0, truncated: false };
211+
}
212+
213+
const preview = options.preview === true;
214+
const previewLimit = options.previewMaxBytes ?? PREVIEW_MAX_SEARXNG_RESPONSE_BYTES;
215+
const effectiveLimit = preview ? Math.min(maxBytes, previewLimit) : maxBytes;
216+
const reader = body.getReader();
217+
218+
try {
219+
return await consumeResponseBody(reader, effectiveLimit, preview, options.signal);
220+
} finally {
221+
reader.releaseLock();
222+
}
181223
}
182224

183225
export async function cancelAuxiliaryResponseBody(response: Response): Promise<void> {

0 commit comments

Comments
 (0)