Skip to content

Commit b41e777

Browse files
ihor-sokoliukclaudecodex
committed
fix(cache,search): address PR review findings
- cache: normalize cleanupIntervalMs (NaN/<=0 -> 60s default) so a bad value can't create a hot-loop cleanup interval - cache: purge expired entries before LFU eviction so a stale-but-frequently-hit entry can no longer survive over a fresh valid one - search: build the /config-unavailable warning/note from the filters actually supplied, so a categories-only or engines-only call no longer names both Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com>
1 parent b148494 commit b41e777

4 files changed

Lines changed: 98 additions & 8 deletions

File tree

__tests__/unit/cache.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,38 @@ async function runTests() {
169169
testCache.destroy();
170170
}, results);
171171

172+
await testFunction('Cache purges expired entries before LFU eviction', async () => {
173+
const testCache = new SimpleCache(50, 2, 1000);
174+
175+
testCache.set('expired-popular-url', '<html>expired</html>', '# Expired');
176+
for (let i = 0; i < 5; i++) {
177+
assert.ok(testCache.get('expired-popular-url'));
178+
}
179+
180+
await new Promise(resolve => setTimeout(resolve, 80));
181+
182+
testCache.set('fresh-url', '<html>fresh</html>', '# Fresh');
183+
testCache.set('new-url', '<html>new</html>', '# New');
184+
185+
assert.equal(testCache.get('expired-popular-url'), null, 'Expected expired popular URL to be purged');
186+
assert.ok(testCache.get('fresh-url'), 'Expected fresh URL to remain cached');
187+
assert.ok(testCache.get('new-url'), 'Expected new URL to remain cached');
188+
assert.equal(testCache.getStats().size, 2);
189+
190+
testCache.destroy();
191+
}, results);
192+
193+
await testFunction('Cache normalizes invalid cleanup interval to default', () => {
194+
const testCache = new SimpleCache(1000, 500, Number.NaN);
195+
const interval = (testCache as any).cleanupInterval;
196+
197+
assert.ok(interval, 'Expected cleanup interval to be created');
198+
assert.equal((interval as any)._idleTimeout, 60000);
199+
assert.equal(interval.hasRef(), false, 'Cleanup interval should be unref()ed');
200+
201+
testCache.destroy();
202+
}, results);
203+
172204
await testFunction('Cache uses CACHE_MAX_ENTRIES to evict when fourth entry is added', () => {
173205
const previousMaxEntries = process.env.CACHE_MAX_ENTRIES;
174206
process.env.CACHE_MAX_ENTRIES = '3';

__tests__/unit/search.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,53 @@ async function runTests() {
10931093
envManager.restore();
10941094
}, results);
10951095

1096+
await testFunction('unavailable /config prepends categories-only text warning', async () => {
1097+
clearInstanceInfoCacheForTests();
1098+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
1099+
1100+
const mockServer = createMockServer();
1101+
1102+
fetchMocker.mock(async (url) => {
1103+
const parsedUrl = new URL(url.toString());
1104+
if (parsedUrl.pathname.endsWith('/config')) {
1105+
return createMockFetch({ ok: false, status: 403, statusText: 'Forbidden' })(url);
1106+
}
1107+
return createMockFetch({ json: { results: makeMockSearchResults(1) } })(url);
1108+
});
1109+
1110+
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, 'Unknown Category');
1111+
1112+
assert.ok(result.startsWith('Note: categories were not validated or normalized (SearXNG /config unavailable).'), result);
1113+
assert.ok(!result.includes('categories and engines were not validated'), result);
1114+
assert.ok(!result.includes('engines were not validated'), result);
1115+
1116+
fetchMocker.restore();
1117+
envManager.restore();
1118+
}, results);
1119+
1120+
await testFunction('unavailable /config includes engines-only JSON warning', async () => {
1121+
clearInstanceInfoCacheForTests();
1122+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
1123+
1124+
const mockServer = createMockServer();
1125+
1126+
fetchMocker.mock(async (url) => {
1127+
const parsedUrl = new URL(url.toString());
1128+
if (parsedUrl.pathname.endsWith('/config')) {
1129+
throw new Error('config blocked');
1130+
}
1131+
return createMockFetch({ json: { query: 'test query', results: [] } })(url);
1132+
});
1133+
1134+
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, undefined, 'Unknown Engine', 'json');
1135+
const payload = JSON.parse(result);
1136+
1137+
assert.deepEqual(payload.warnings, ['Engines were not validated or normalized because SearXNG /config is unavailable.']);
1138+
1139+
fetchMocker.restore();
1140+
envManager.restore();
1141+
}, results);
1142+
10961143
await testFunction('omitting engines skips /config validation and sends no engines param', async () => {
10971144
clearInstanceInfoCacheForTests();
10981145
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');

src/cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class SimpleCache {
3535
) {
3636
this.ttlMs = normalizePositiveInteger(ttlMs, DEFAULT_CACHE_TTL_MS);
3737
this.maxEntries = normalizePositiveInteger(maxEntries, DEFAULT_CACHE_MAX_ENTRIES);
38-
this.startCleanup(cleanupIntervalMs);
38+
this.startCleanup(normalizePositiveInteger(cleanupIntervalMs, DEFAULT_CLEANUP_INTERVAL_MS));
3939
}
4040

4141
private startCleanup(cleanupIntervalMs: number): void {
@@ -56,6 +56,8 @@ class SimpleCache {
5656
}
5757

5858
private evictIfNeeded(): void {
59+
this.cleanupExpired();
60+
5961
while (this.cache.size > this.maxEntries) {
6062
let evictionKey: string | null = null;
6163
let evictionEntry: CacheEntry | null = null;

src/search.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ import {
1414
type ErrorContext
1515
} from "./error-handler.js";
1616

17-
const FILTER_VALIDATION_WARNING = "Categories and engines were not validated or normalized because SearXNG /config is unavailable.";
18-
const FILTER_VALIDATION_NOTE = "Note: categories and engines were not validated or normalized (SearXNG /config unavailable).";
19-
2017
function getOperatorMaxResults(mcpServer: McpServer): number | undefined {
2118
const rawValue = process.env.SEARXNG_MAX_RESULTS;
2219
if (rawValue === undefined || rawValue.trim() === "") {
@@ -122,6 +119,7 @@ type NormalizedFilters = {
122119
categories?: string;
123120
engines?: string;
124121
validationWarning?: string;
122+
validationNote?: string;
125123
};
126124

127125
async function normalizeSearchFilters(
@@ -136,6 +134,14 @@ async function normalizeSearchFilters(
136134
return {};
137135
}
138136

137+
const unavailableFilterLabel = effectiveCategories && effectiveEngines
138+
? "categories and engines"
139+
: effectiveCategories
140+
? "categories"
141+
: "engines";
142+
const unavailableWarning = `${unavailableFilterLabel[0].toUpperCase()}${unavailableFilterLabel.slice(1)} were not validated or normalized because SearXNG /config is unavailable.`;
143+
const unavailableNote = `Note: ${unavailableFilterLabel} were not validated or normalized (SearXNG /config unavailable).`;
144+
139145
let knownCategories: Set<string> | null | undefined;
140146
let knownEngines: Set<string> | null | undefined;
141147

@@ -145,7 +151,8 @@ async function normalizeSearchFilters(
145151
return {
146152
categories: effectiveCategories,
147153
engines: effectiveEngines,
148-
validationWarning: FILTER_VALIDATION_WARNING,
154+
validationWarning: unavailableWarning,
155+
validationNote: unavailableNote,
149156
};
150157
}
151158
}
@@ -156,7 +163,8 @@ async function normalizeSearchFilters(
156163
return {
157164
categories: effectiveCategories,
158165
engines: effectiveEngines,
159-
validationWarning: FILTER_VALIDATION_WARNING,
166+
validationWarning: unavailableWarning,
167+
validationNote: unavailableNote,
160168
};
161169
}
162170
}
@@ -185,7 +193,8 @@ async function normalizeSearchFilters(
185193
return {
186194
categories: effectiveCategories,
187195
engines: effectiveEngines,
188-
validationWarning: FILTER_VALIDATION_WARNING,
196+
validationWarning: unavailableWarning,
197+
validationNote: unavailableNote,
189198
};
190199
}
191200

@@ -455,7 +464,7 @@ export async function performWebSearch(
455464

456465
const metadata = formatSearchMetadata(data);
457466
const leadingSections = [
458-
filters.validationWarning ? FILTER_VALIDATION_NOTE : null,
467+
filters.validationNote ?? null,
459468
metadata || null,
460469
].filter(Boolean).join("\n\n");
461470

0 commit comments

Comments
 (0)