From 3824b4ebfc9aea3009f8fb608eca456adb20e6d3 Mon Sep 17 00:00:00 2001 From: manjunath Date: Fri, 30 Oct 2026 23:27:17 +0530 Subject: [PATCH] keep cancelled outline and search asks free during the Nominatim probe Recheck the caller's signal right after the capability probe on every path, so an ask cleared while the probe is pending starts no outline or last-resort search request, whether the probe says configured, unknown or not configured. --- src/search/defaults.js | 11 ++++----- src/search/photonSetting.test.mjs | 28 +++++++++++++++++++++++ src/sources/nominatimOutlines.js | 6 +++-- src/sources/nominatimOutlines.test.mjs | 31 ++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/search/defaults.js b/src/search/defaults.js index 0040ac638..0fec80650 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -80,11 +80,12 @@ export function createDefaultPlaceSearch({ createGoogleGeocoder({ async request(query, { bias, signal }) { // The app's own route says up front when Nominatim is off. - if ( - nominatimConfigured && - (await nominatimConfigured()) === false - ) - return null; + const capability = nominatimConfigured + ? await nominatimConfigured() + : null; + // A search cancelled during the probe starts no request. + signal?.throwIfAborted(); + if (capability === false) return null; const params = new URLSearchParams({ q: query }); if (bias) params.set('bounds', bias); return fetchImpl( diff --git a/src/search/photonSetting.test.mjs b/src/search/photonSetting.test.mjs index 9194ceb2e..b4f783fd8 100644 --- a/src/search/photonSetting.test.mjs +++ b/src/search/photonSetting.test.mjs @@ -53,3 +53,31 @@ test('the last-resort route is skipped when the server reports Nominatim unconfi const on = await run(true); assert.ok(on.includes('/api/geocode'), on.join(',')); }); + +test('a search cancelled while the Nominatim probe is pending starts no last-resort request', async () => { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const asked = []; + const controller = new AbortController(); + const search = createDefaultPlaceSearch({ + endpoints: { photon: null }, + fetchImpl: async (url) => { + const path = new URL(String(url), 'http://local.test').pathname; + asked.push(path); + if (path === '/api/geocode/status') { + controller.abort(); + await held; + return Response.json({ configured: true }); + } + return Response.json({ status: 'ZERO_RESULTS', results: [] }); + }, + }); + const pending = search.geocode('nowhere-cancelled', { + signal: controller.signal, + }); + release(); + await pending.catch(() => null); + assert.ok(!asked.includes('/api/geocode'), asked.join(',')); +}); diff --git a/src/sources/nominatimOutlines.js b/src/sources/nominatimOutlines.js index 35acb4125..ebc10ffb0 100644 --- a/src/sources/nominatimOutlines.js +++ b/src/sources/nominatimOutlines.js @@ -96,8 +96,10 @@ export function createNominatimOutlineClient({ if (remembered.has(key)) return remembered.get(key); // A cancelled ask spends nothing. signal?.throwIfAborted(); - if (configured && (await configured()) === false) { - signal?.throwIfAborted(); + const capability = configured ? await configured() : null; + // The probe may have waited: an ask cancelled meanwhile still spends nothing. + signal?.throwIfAborted(); + if (capability === false) { unavailable = { unavailable: true, retryable: false, diff --git a/src/sources/nominatimOutlines.test.mjs b/src/sources/nominatimOutlines.test.mjs index f5157274a..d756702e1 100644 --- a/src/sources/nominatimOutlines.test.mjs +++ b/src/sources/nominatimOutlines.test.mjs @@ -179,3 +179,34 @@ test('a configured server is asked as usual after one probe', async () => { assert.equal(calls.length, 2); assert.equal(probes.length, 1); }); + +test('an ask cancelled while the capability probe is pending spends nothing', async () => { + for (const [label, answer] of [ + ['configured', () => reply(200, { configured: true })], + ['unknown', () => reply(404, { error: 'Not Found' })], + ['unconfigured', () => reply(200, { configured: false })], + ]) { + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const { calls, lookup } = client( + () => reply(200, { status: 'ZERO_RESULTS', outline: null }), + { + status: async () => { + await held; + return answer(); + }, + }, + ); + const controller = new AbortController(); + const pending = lookup( + { query: 'Paris', kind: 'city' }, + { signal: controller.signal }, + ); + controller.abort(); + release(); + await assert.rejects(pending, { name: 'AbortError' }, label); + assert.equal(calls.length, 0, `${label}: no outline request`); + } +});