Skip to content

Commit a7c3ac6

Browse files
committed
fix(search): harden response abort handling
Coverage: 94.90% (was 94.94%)
1 parent d62ee01 commit a7c3ac6

8 files changed

Lines changed: 254 additions & 110 deletions

File tree

__tests__/unit/instance-info.test.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -716,31 +716,44 @@ async function runTests() {
716716
const mockServer = createMockServer();
717717
const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, 'timeout');
718718
let observedSignal: AbortSignal | undefined;
719+
let attempts = 0;
720+
const timeoutReason = new Error('bounded test timeout');
721+
const cancellationReasons: unknown[] = [];
722+
let responseBody: ReadableStream<Uint8Array> | undefined;
719723
fetchMocker.mock(async (_url, options) => {
724+
attempts++;
720725
observedSignal = options?.signal ?? undefined;
721-
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
722-
const response = new Response(new ReadableStream<Uint8Array>({
723-
start(streamController) {
724-
controller = streamController;
726+
responseBody = new ReadableStream<Uint8Array>({
727+
cancel(reason) {
728+
cancellationReasons.push(reason);
725729
},
726-
}), { status: 200 });
727-
observedSignal?.addEventListener('abort', () => controller?.error(observedSignal?.reason), { once: true });
728-
return response;
730+
});
731+
return new Response(responseBody, { status: 200 });
729732
});
730733
Object.defineProperty(AbortSignal, 'timeout', {
731734
configurable: true,
732735
value: () => {
733736
const controller = new AbortController();
734-
setTimeout(() => controller.abort(new Error('bounded test timeout')), 5);
737+
setTimeout(() => controller.abort(timeoutReason), 5);
735738
return controller.signal;
736739
},
737740
});
738741

739742
try {
740-
const payload = JSON.parse(await fetchInstanceInfo(mockServer as any));
743+
const payload = JSON.parse(await Promise.race([
744+
fetchInstanceInfo(mockServer as any),
745+
new Promise<never>((_resolve, reject) => {
746+
setTimeout(() => reject(new Error('stalled /config response did not become unavailable promptly')), 250);
747+
}),
748+
]));
749+
const cachedPayload = JSON.parse(await fetchInstanceInfo(mockServer as any));
741750
assert.ok(observedSignal, 'expected the /config request signal');
742751
assert.equal(observedSignal.aborted, true);
743752
assert.equal(payload.available, false);
753+
assert.equal(cachedPayload.available, false);
754+
assert.equal(attempts, 1, 'a timed-out response must not populate the successful cache');
755+
assert.deepEqual(cancellationReasons, [timeoutReason]);
756+
assert.equal(responseBody?.locked, false, 'the shared reader must release its body lock after cancellation');
744757
} finally {
745758
if (timeoutDescriptor) {
746759
Object.defineProperty(AbortSignal, 'timeout', timeoutDescriptor);

__tests__/unit/search.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,6 +3311,56 @@ async function runTests() {
33113311
}
33123312
}, results);
33133313

3314+
await testFunction('HTML fallback accepts an exact response-body limit and rejects the next byte without caching or recording success', async () => {
3315+
searchCache.clear();
3316+
clearSearxngInstanceStateForTests();
3317+
envManager.set('SEARXNG_URL', 'https://fallback-bounded.example.com');
3318+
envManager.set('SEARXNG_HTML_FALLBACK', 'true');
3319+
const exactBody = searxngHtmlFixture;
3320+
envManager.set('SEARXNG_MAX_RESPONSE_BYTES', String(Buffer.byteLength(exactBody)));
3321+
const mockServer = createMockServer();
3322+
let jsonFetchCount = 0;
3323+
let htmlFetchCount = 0;
3324+
fetchMocker.mock(async (url) => {
3325+
const requestUrl = new URL(url.toString());
3326+
if (requestUrl.searchParams.get('format') === 'json') {
3327+
jsonFetchCount++;
3328+
return new Response('JSON format is disabled', { status: 403, statusText: 'Forbidden' });
3329+
}
3330+
3331+
htmlFetchCount++;
3332+
return createStreamResponse([utf8(htmlFetchCount === 1 ? exactBody : `${exactBody} `)]);
3333+
});
3334+
3335+
try {
3336+
const exactResult = await performWebSearch(mockServer as any, 'fallback exact body');
3337+
assert.ok(exactResult.includes('Alpha Result'));
3338+
3339+
envManager.set('SEARXNG_URL', 'https://fallback-bounded.example.com;https://cooled.example.com');
3340+
recordSearxngInstanceFailure('https://fallback-bounded.example.com');
3341+
recordSearxngInstanceFailure('https://fallback-bounded.example.com');
3342+
recordSearxngInstanceFailure('https://cooled.example.com');
3343+
recordSearxngInstanceFailure('https://cooled.example.com');
3344+
recordSearxngInstanceFailure('https://cooled.example.com');
3345+
await assert.rejects(
3346+
() => performWebSearch(mockServer as any, 'fallback next byte'),
3347+
/SearXNG response exceeds configured byte limit/,
3348+
);
3349+
assert.equal(isSearxngInstanceCooledDown('https://fallback-bounded.example.com'), true);
3350+
3351+
clearSearxngInstanceStateForTests();
3352+
envManager.set('SEARXNG_MAX_RESPONSE_BYTES', String(Buffer.byteLength(exactBody) + 1));
3353+
await performWebSearch(mockServer as any, 'fallback next byte');
3354+
assert.equal(htmlFetchCount, 3, 'an oversize HTML fallback response must not be stored in the search cache');
3355+
assert.equal(jsonFetchCount, 3, 'each HTML fallback attempt must begin with the JSON request');
3356+
} finally {
3357+
fetchMocker.restore();
3358+
envManager.restore();
3359+
searchCache.clear();
3360+
clearSearxngInstanceStateForTests();
3361+
}
3362+
}, results);
3363+
33143364
await testFunction('search rejects partial and invalid bounded JSON before cache writes', async () => {
33153365
searchCache.clear();
33163366
clearSearxngInstanceStateForTests();

__tests__/unit/searxng-response.test.ts

Lines changed: 89 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,39 @@ import { createTestResults, exitWithResults, printTestSummary, testFunction } fr
1616
const results = createTestResults();
1717
const encoder = new TextEncoder();
1818

19-
function responseWithChunks(chunks: Uint8Array[], options: { cancelRejects?: boolean; readRejects?: boolean } = {}) {
19+
interface StreamOptions {
20+
cancelRejects?: boolean;
21+
leaveOpen?: boolean;
22+
readRejects?: boolean;
23+
pending?: boolean;
24+
}
25+
26+
function responseWithChunks(chunks: Uint8Array[], options: StreamOptions = {}) {
2027
let cancelCalls = 0;
21-
let releaseCalls = 0;
22-
let index = 0;
23-
const reader = {
24-
async read(): Promise<ReadableStreamReadResult<Uint8Array>> {
28+
let cancelReason: unknown;
29+
const stream = new ReadableStream<Uint8Array>({
30+
start(controller) {
2531
if (options.readRejects) {
26-
throw new Error("read failed");
32+
controller.error(new Error("read failed"));
33+
return;
2734
}
28-
const value = chunks[index++];
29-
return value === undefined ? { done: true, value: undefined } : { done: false, value };
30-
},
31-
async cancel(): Promise<void> {
32-
cancelCalls++;
33-
if (options.cancelRejects) {
34-
throw new Error("cancel failed");
35+
if (!options.pending) {
36+
for (const chunk of chunks) controller.enqueue(chunk);
37+
if (!options.leaveOpen) controller.close();
3538
}
3639
},
37-
releaseLock(): void {
38-
releaseCalls++;
40+
cancel(reason) {
41+
cancelCalls++;
42+
cancelReason = reason;
43+
if (options.cancelRejects) return Promise.reject(new Error("cancel failed"));
44+
return undefined;
3945
},
40-
};
46+
});
47+
const response = new Response(stream);
4148
return {
42-
response: {
43-
body: {
44-
getReader: () => reader,
45-
cancel: () => reader.cancel(),
46-
},
47-
} as Response,
49+
response,
4850
getCancelCalls: () => cancelCalls,
49-
getReleaseCalls: () => releaseCalls,
51+
getCancelReason: () => cancelReason,
5052
};
5153
}
5254

@@ -62,6 +64,16 @@ function createLogger() {
6264
};
6365
}
6466

67+
async function expectPromptAbort(promise: Promise<unknown>, reason: Error): Promise<void> {
68+
await assert.rejects(
69+
Promise.race([
70+
promise,
71+
new Promise((_, reject) => setTimeout(() => reject(new Error("abort was not prompt")), 50)),
72+
]),
73+
(error: unknown) => error === reason,
74+
);
75+
}
76+
6577
async function runTests() {
6678
console.log("🧪 Testing: searxng-response.ts\n");
6779

@@ -71,7 +83,7 @@ async function runTests() {
7183
assert.equal(PREVIEW_MAX_SEARXNG_RESPONSE_BYTES, 64 * 1024);
7284
}, results);
7385

74-
await testFunction("resolves only accepted response-size configuration and warns once without raw values", async () => {
86+
await testFunction("pins accepted and rejected response-size configuration forms with value-free once-only warnings", async () => {
7587
const previous = process.env.SEARXNG_MAX_RESPONSE_BYTES;
7688
const logger = createLogger();
7789
resetSearxngResponseConfigWarningsForTesting();
@@ -82,18 +94,21 @@ async function runTests() {
8294
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), 1);
8395
process.env.SEARXNG_MAX_RESPONSE_BYTES = " 16777216 ";
8496
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), HARD_MAX_SEARXNG_RESPONSE_BYTES);
97+
process.env.SEARXNG_MAX_RESPONSE_BYTES = "+5242880";
98+
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), 5242880);
99+
process.env.SEARXNG_MAX_RESPONSE_BYTES = "0005242880";
100+
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), 5242880);
85101
process.env.SEARXNG_MAX_RESPONSE_BYTES = " ";
86102
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), DEFAULT_SEARXNG_RESPONSE_MAX_BYTES);
87-
process.env.SEARXNG_MAX_RESPONSE_BYTES = "0";
88-
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), DEFAULT_SEARXNG_RESPONSE_MAX_BYTES);
89-
process.env.SEARXNG_MAX_RESPONSE_BYTES = "16777217";
90-
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), DEFAULT_SEARXNG_RESPONSE_MAX_BYTES);
91-
process.env.SEARXNG_MAX_RESPONSE_BYTES = "5.5";
92-
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), DEFAULT_SEARXNG_RESPONSE_MAX_BYTES);
103+
for (const invalid of ["0", "16777217", "5.5", "-0", "1e3", "5MB", "9007199254740992"]) {
104+
process.env.SEARXNG_MAX_RESPONSE_BYTES = invalid;
105+
assert.equal(resolveSearxngResponseMaxBytes(logger.server as any), DEFAULT_SEARXNG_RESPONSE_MAX_BYTES);
106+
}
93107
await Promise.resolve();
94108
assert.equal(logger.messages.length, 1);
95-
assert.ok(!logger.messages[0].includes("16777217"));
109+
assert.ok(!logger.messages[0].includes("9007199254740992"));
96110
resetSearxngResponseConfigWarningsForTesting();
111+
process.env.SEARXNG_MAX_RESPONSE_BYTES = "5MB";
97112
resolveSearxngResponseMaxBytes(logger.server as any);
98113
await Promise.resolve();
99114
assert.equal(logger.messages.length, 2);
@@ -104,44 +119,48 @@ async function runTests() {
104119
}
105120
}, results);
106121

107-
await testFunction("complete mode accepts exact bytes, rejects the next byte, and releases its lock", async () => {
122+
await testFunction("complete mode accepts exact bytes, rejects the next byte, cancels, and unlocks actual bodies", async () => {
108123
const exact = responseWithChunks([encoder.encode("abc")]);
109124
assert.deepEqual(await readSearxngResponseBody(exact.response, 3), {
110125
text: "abc", bytesRead: 3, truncated: false,
111126
});
112-
assert.equal(exact.getReleaseCalls(), 1);
113-
const overflow = responseWithChunks([encoder.encode("abcd")]);
127+
assert.equal(exact.response.body!.locked, false);
128+
const overflow = responseWithChunks([encoder.encode("abcd")], { leaveOpen: true });
114129
await assert.rejects(() => readSearxngResponseBody(overflow.response, 3), /SearXNG response exceeds configured byte limit/);
115130
assert.equal(overflow.getCancelCalls(), 1);
116-
assert.equal(overflow.getReleaseCalls(), 1);
117-
const overflowWithCancelFailure = responseWithChunks([encoder.encode("abcd")], { cancelRejects: true });
131+
assert.equal(overflow.response.body!.locked, false);
132+
const overflowWithCancelFailure = responseWithChunks([encoder.encode("abcd")], { cancelRejects: true, leaveOpen: true });
118133
await assert.rejects(
119134
() => readSearxngResponseBody(overflowWithCancelFailure.response, 3),
120135
/SearXNG response exceeds configured byte limit/,
121136
);
122-
assert.equal(overflowWithCancelFailure.getReleaseCalls(), 1);
137+
assert.equal(overflowWithCancelFailure.getCancelCalls(), 1);
138+
assert.equal(overflowWithCancelFailure.response.body!.locked, false);
123139
}, results);
124140

125-
await testFunction("complete mode measures raw UTF-8 bytes before decoding", async () => {
141+
await testFunction("complete mode measures raw UTF-8 bytes before decoding with actual bodies", async () => {
126142
const response = responseWithChunks([new Uint8Array([0xe2]), new Uint8Array([0x82, 0xac])]);
127143
assert.deepEqual(await readSearxngResponseBody(response.response, 3), {
128144
text: "€", bytesRead: 3, truncated: false,
129145
});
146+
assert.equal(response.response.body!.locked, false);
130147
const partial = responseWithChunks([new Uint8Array([0xe2]), new Uint8Array([0x82])]);
131148
assert.equal((await readSearxngResponseBody(partial.response, 2)).text, "�");
149+
assert.equal(partial.response.body!.locked, false);
132150
}, results);
133151

134-
await testFunction("preview mode truncates at its effective limit and preserves cancellation result", async () => {
135-
const truncated = responseWithChunks([encoder.encode("abcd")]);
152+
await testFunction("preview mode truncates at its effective limit, cancels, and unlocks actual bodies", async () => {
153+
const truncated = responseWithChunks([encoder.encode("abcd")], { leaveOpen: true });
136154
assert.deepEqual(await readSearxngResponseBody(truncated.response, 10, { preview: true, previewMaxBytes: 3 }), {
137155
text: "abc", bytesRead: 4, truncated: true,
138156
});
139157
assert.equal(truncated.getCancelCalls(), 1);
140-
assert.equal(truncated.getReleaseCalls(), 1);
141-
const cancelRejects = responseWithChunks([encoder.encode("abcd")], { cancelRejects: true });
158+
assert.equal(truncated.response.body!.locked, false);
159+
const cancelRejects = responseWithChunks([encoder.encode("abcd")], { cancelRejects: true, leaveOpen: true });
142160
const result = await readSearxngResponseBody(cancelRejects.response, 3, { preview: true });
143161
assert.equal(result.truncated, true);
144-
assert.equal(cancelRejects.getReleaseCalls(), 1);
162+
assert.equal(cancelRejects.getCancelCalls(), 1);
163+
assert.equal(cancelRejects.response.body!.locked, false);
145164
}, results);
146165

147166
await testFunction("null body is empty while an absent body is rejected without text fallback", async () => {
@@ -152,13 +171,37 @@ async function runTests() {
152171
await assert.rejects(() => readSearxngResponseBody(invalidResponse, 3), /Invalid SearXNG response body/);
153172
}, results);
154173

155-
await testFunction("reader failures release the lock and auxiliary cancellation never surfaces cancellation failures", async () => {
174+
await testFunction("reader failures unlock actual bodies and auxiliary cancellation hides cancellation rejection", async () => {
156175
const rejectedRead = responseWithChunks([], { readRejects: true });
157176
await assert.rejects(() => readSearxngResponseBody(rejectedRead.response, 3), /read failed/);
158-
assert.equal(rejectedRead.getReleaseCalls(), 1);
159-
const auxiliary = responseWithChunks([], { cancelRejects: true });
177+
assert.equal(rejectedRead.response.body!.locked, false);
178+
const auxiliary = responseWithChunks([], { pending: true, cancelRejects: true });
160179
await cancelAuxiliaryResponseBody(auxiliary.response);
161180
assert.equal(auxiliary.getCancelCalls(), 1);
181+
assert.equal(auxiliary.response.body!.locked, false);
182+
}, results);
183+
184+
await testFunction("signal abort cancels through the reader, unlocks the actual body, and keeps abort stable when cancel rejects", async () => {
185+
const controller = new AbortController();
186+
const reason = new Error("caller aborted");
187+
const pending = responseWithChunks([], { pending: true });
188+
const read = readSearxngResponseBody(pending.response, 3, { signal: controller.signal });
189+
await Promise.resolve();
190+
controller.abort(reason);
191+
await expectPromptAbort(read, reason);
192+
assert.equal(pending.getCancelCalls(), 1);
193+
assert.equal(pending.getCancelReason(), reason);
194+
assert.equal(pending.response.body!.locked, false);
195+
196+
const rejectingController = new AbortController();
197+
const rejectingReason = new Error("caller aborted with rejecting cancel");
198+
const rejecting = responseWithChunks([], { pending: true, cancelRejects: true });
199+
const rejectingRead = readSearxngResponseBody(rejecting.response, 3, { signal: rejectingController.signal });
200+
await Promise.resolve();
201+
rejectingController.abort(rejectingReason);
202+
await expectPromptAbort(rejectingRead, rejectingReason);
203+
assert.equal(rejecting.getCancelCalls(), 1);
204+
assert.equal(rejecting.response.body!.locked, false);
162205
}, results);
163206

164207
printTestSummary(results, "SearXNG Response Module");

__tests__/unit/suggestions.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,16 @@ async function runTests() {
155155
}
156156
}, results);
157157

158-
await testFunction('keeps the five-second signal active while a headers-first body stalls', async () => {
158+
await testFunction('cancels a stalled response body with the five-second request signal', async () => {
159159
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
160160
const mockServer = createMockServer();
161161
const originalTimeout = AbortSignal.timeout;
162162
const controller = new AbortController();
163+
const abortReason = new Error('autocomplete deadline elapsed');
163164
let requestedTimeout: number | undefined;
165+
let cancelCalls = 0;
166+
let cancellationReason: unknown;
167+
let responseBody: ReadableStream<Uint8Array> | undefined;
164168

165169
try {
166170
Object.defineProperty(AbortSignal, 'timeout', {
@@ -171,22 +175,30 @@ async function runTests() {
171175
},
172176
});
173177
fetchMocker.mock(async () => {
174-
const response = {
175-
ok: true,
176-
body: new ReadableStream<Uint8Array>({
177-
start(streamController) {
178-
controller.signal.addEventListener('abort', () => streamController.error(new Error('aborted while reading body')));
179-
},
180-
}),
181-
json: async () => ['type', ['unbounded-json-result']],
182-
} as unknown as Response;
178+
const stalledStream = new ReadableStream<Uint8Array>({
179+
cancel(reason) {
180+
cancelCalls++;
181+
cancellationReason = reason;
182+
},
183+
});
184+
const response = new Response(stalledStream, { status: 200 });
185+
responseBody = response.body ?? undefined;
183186
return response;
184187
});
185188
const pending = performSearchSuggestions(mockServer as any, 'type');
186189
await Promise.resolve();
187-
controller.abort();
188-
assert.deepEqual(await pending, []);
190+
controller.abort(abortReason);
191+
const result = await Promise.race([
192+
pending,
193+
new Promise<never>((_resolve, reject) => {
194+
setTimeout(() => reject(new Error('autocomplete did not finish after its abort signal')), 100);
195+
}),
196+
]);
197+
assert.deepEqual(result, []);
189198
assert.equal(requestedTimeout, 5000);
199+
assert.equal(cancelCalls, 1);
200+
assert.equal(cancellationReason, abortReason);
201+
assert.equal(responseBody?.locked, false);
190202
} finally {
191203
Object.defineProperty(AbortSignal, 'timeout', { configurable: true, value: originalTimeout });
192204
fetchMocker.restore();

0 commit comments

Comments
 (0)