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`); + } +});