From 97a1a667dee586f5be92f05f1716d1a9e232df27 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 21:03:13 +0100 Subject: [PATCH] =?UTF-8?q?fix(street-level):=20KartaView=20round-2=20foll?= =?UTF-8?q?ow-ups=20=E2=80=94=20context=20loss,=20size=20cap,=20slow=20loo?= =?UTF-8?q?kups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the second IC8 pass (launch ready; P3s closed here): - A lost WebGL context is redrawn on a new canvas (the old one only gives back its lost context), through the core, and only if no newer photo is opening; verified live with WEBGL_lose_context, twice. - The 16 MB answer cap counts bytes while reading: KartaView sends chunked answers with no Content-Length, so the header check never fired. - A lookup gets 8 s, then 15 s once more: KartaView sometimes sits on a lookup for 11 s and answers the same one in 3 s. Left as follow-ups: cancelling abandoned sequence pages; finding a page by binary search on the index when thousands of photos are missing. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 ++- .../streetLevel/providers/kartaview/policy.js | 5 ++ .../streetLevel/providers/kartaview/source.js | 48 ++++++++++++++--- .../providers/kartaview/source.test.mjs | 53 +++++++++++++++++-- .../streetLevel/providers/kartaview/viewer.js | 39 ++++++++++---- .../providers/kartaview/viewer.test.mjs | 21 +++++++- src/testSupport/kartaViewFakes.mjs | 8 +++ 7 files changed, 158 insertions(+), 24 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 449f1cf68..d51bf44a1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3221,7 +3221,10 @@ up front at the ends. `api.kartaview.org` speaks HTTP/1.1, so of the browser's six connections to it tiles take three, lookups two and pages one (newest first), and a lookup never waits behind tiles; the photo API answers 429 to more than about three requests at once, so a 429 is retried with -backoff (at most 10 s) and a timed-out lookup once. Photo URLs are built from +backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView +sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers +are read with a running byte count and cut off past 16 MB (KartaView sends +them chunked, without a length). Photo URLs are built from `storage` and `filepath*`: the API's `fileurl*` always name storage1, where photos stored elsewhere 404, and a `fileurl*` fallback must itself be an https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px, @@ -3232,7 +3235,8 @@ equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the canvas re-measures every frame). Without WebGL, or if the proxy fails within 15 s, a 360° photo shows its front view; a lost context draws the photo again -on a new one. FIT / FILL hide for panoramas (`supportsRenderModes`). The +on a new canvas (a lost canvas only gives back its lost context), through the +core and only if no newer photo is opening. FIT / FILL hide for panoramas (`supportsRenderModes`). The caption has no place name (KartaView's is where the drive began) and links to the full-size photo through the image proxy, since kartaview.org's photo pages fail for signed-out visitors. diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 9df86b887..96d27bc92 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -66,6 +66,11 @@ export const TILE_CONCURRENCY = 3; export const PAGE_CONCURRENCY = 1; export const TILE_TIMEOUT_MS = 20_000; export const PHOTO_API_TIMEOUT_MS = 15_000; +/** + * The first try of a lookup: KartaView sometimes sits on a lookup for 11 s + * and more, then answers the same one again in 3 s. + */ +export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000; /** A sequence page is about 650 kB of JSON. */ export const PAGE_TIMEOUT_MS = 30_000; /** Real tiles are under 1 MB; refuse anything absurd before parsing it. */ diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index aaf684aba..9e926a9b0 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -10,6 +10,7 @@ import { PHOTO_API_RETRIES, PHOTO_API_RETRY_BASE_MS, PHOTO_API_RETRY_MAX_MS, + PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, TILE_TIMEOUT_MS, @@ -144,11 +145,33 @@ export function createKartaViewSource({ { status: response.status }, ); const length = Number(response.headers?.get?.('content-length')); - if (length > MAX_RESPONSE_BYTES) - throw new KartaViewSourceError('KartaView sent too much data', { + const tooMuch = () => + new KartaViewSourceError('KartaView sent too much data', { status: response.status, }); - return await response.json(); + if (length > MAX_RESPONSE_BYTES) throw tooMuch(); + // KartaView answers chunked, without a length: count while reading. + const reader = response.body?.getReader?.(); + if (!reader) return await response.json(); + const chunks = []; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) { + reader.cancel().catch(() => {}); + throw tooMuch(); + } + chunks.push(value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); } catch (error) { if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; @@ -168,7 +191,11 @@ export function createKartaViewSource({ path, params, { signal } = {}, - { gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {}, + { + gate = limiter, + timeoutMs = PHOTO_API_TIMEOUT_MS, + firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS, + } = {}, ) { const query = params ? `?${new URLSearchParams(params)}` : ''; const url = `${apiHost}${path}${query}`; @@ -176,7 +203,12 @@ export function createKartaViewSource({ for (let attempt = 0; ; attempt++) { try { const payload = await gate.run( - () => getJson(url, { signal, timeoutMs }), + // A slow first answer is usually quick when asked again. + () => + getJson(url, { + signal, + timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs, + }), { signal }, ); const code = Number(payload?.status?.apiCode); @@ -300,7 +332,11 @@ export function createKartaViewSource({ `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, { page: String(page), itemsPerPage: String(itemsPerPage) }, { signal }, - { gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS }, + { + gate: pageLimiter, + timeoutMs: PAGE_TIMEOUT_MS, + firstTimeoutMs: PAGE_TIMEOUT_MS, + }, ); return { photos: Array.isArray(result.data) ? result.data : [], diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index bed8b2ec5..c0061c42f 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -10,6 +10,7 @@ import { NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES, + PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, } from './policy.js'; @@ -402,7 +403,7 @@ test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s'); }); -test('a lookup that hangs is given up after the photo timeout, once more, then reported', async (t) => { +test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const asked = []; const outcome = await onMockClock(t, (stamp) => @@ -415,9 +416,9 @@ test('a lookup that hangs is given up after the photo timeout, once more, then r }, }).getPhoto('1'), ); - assert.deepEqual(asked, [0, PHOTO_API_TIMEOUT_MS]); + assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]); assert.match(String(outcome?.error?.message), /did not answer in time/); - assert.equal(outcome.at, 2 * PHOTO_API_TIMEOUT_MS); + assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS); }); test('the nearest lookup asks for the few photos around the point at the search zoom', async () => { @@ -439,3 +440,49 @@ test('the nearest lookup asks for the few photos around the point at the search assert.equal(NEAREST_SEARCH_ZOOM, 18); assert.equal(NEAREST_LIMIT, 10); }); + +test('a chunked answer with no length is cut off once it grows past the cap', async () => { + let reads = 0; + let cancelled = false; + const chunk = new Uint8Array(6 * 1024 * 1024); + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + ++reads > 10 ? { done: true } : { done: false, value: chunk }, + cancel: async () => { + cancelled = true; + }, + }), + }, + }), + }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/); + assert.equal(reads, 3, 'stopped at the third 6 MB chunk'); + assert.equal(cancelled, true); + const small = createKartaViewSource({ + fetchImpl: async () => { + const bytes = new TextEncoder().encode('{"lines":[]}'); + let given = false; + return { + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: bytes }), + cancel: async () => {}, + }), + }, + }; + }, + }); + assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index e10787b72..fc6d5ef19 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -83,6 +83,8 @@ export function createKartaViewViewer({ let drag = null; let poseFrame = 0; let removeListeners = []; + /** The document the viewer was built in (new canvases come from it). */ + let documentOf = null; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -202,13 +204,35 @@ export function createKartaViewViewer({ } catch { /* already gone */ } - if (showing === 'pano' && photo) { + // A lost canvas hands back its lost context: draw on a new element. + const fresh = makeCanvas(documentOf); + fresh.hidden = canvas.hidden; + canvas.replaceWith?.(fresh); + canvas = fresh; + // Show the photo again through the core, unless a newer one is opening. + if (showing === 'pano' && photo && openingId === null) { const again = photo.id; show(null); - adapter.open(again).catch(() => {}); + keepViewFor = again; + openingId = again; + onStep(again); } } + /** The panorama canvas, wired for drag, pinch, wheel and context loss. */ + function makeCanvas(doc) { + const element = doc.createElement('canvas'); + element.className = 'sl-kv-pano'; + element.hidden = true; + listen(element, 'pointerdown', onPointerDown); + listen(element, 'pointermove', onPointerMove); + listen(element, 'pointerup', onPointerUp); + listen(element, 'pointercancel', onPointerUp); + listen(element, 'wheel', onWheel, { passive: false }); + listen(element, 'webglcontextlost', onContextLost); + return element; + } + /** * Step along the sequence from the photo showing, or from the one on its * way after an earlier press, and open it through the core. @@ -346,6 +370,7 @@ export function createKartaViewViewer({ function build(host) { const doc = host.ownerDocument; + documentOf = doc; root = doc.createElement('div'); root.className = 'sl-kv-viewer'; root.tabIndex = 0; @@ -359,9 +384,7 @@ export function createKartaViewViewer({ picture.alt = 'KartaView street-level photo'; picture.draggable = false; picture.hidden = true; - canvas = doc.createElement('canvas'); - canvas.className = 'sl-kv-pano'; - canvas.hidden = true; + canvas = makeCanvas(doc); const nav = (label, glyph, delta) => { const button = doc.createElement('button'); button.type = 'button'; @@ -380,12 +403,6 @@ export function createKartaViewViewer({ nextButton = nav('Next photo in this sequence (→)', '›', 1); root.append(picture, canvas, prevButton, nextButton); host.append(root); - listen(canvas, 'pointerdown', onPointerDown); - listen(canvas, 'pointermove', onPointerMove); - listen(canvas, 'pointerup', onPointerUp); - listen(canvas, 'pointercancel', onPointerUp); - listen(canvas, 'wheel', onWheel, { passive: false }); - listen(canvas, 'webglcontextlost', onContextLost); listen(root, 'keydown', onKeyDown); applyRenderMode(); } diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index fd3242ef0..d8a6bfa37 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -69,6 +69,7 @@ const PHOTOS = { function fakeRenderer(canvas) { const view = { yaw: 0, pitch: 0, fov: 80 }; return { + canvas, images: [], hiddenAtSetImage: [], setImage(image) { @@ -435,16 +436,32 @@ test('two fingers pinch the panorama’s zoom', async () => { }); test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => { - const { viewer, host, renderers, poses } = setup(); + const { viewer, host, renderers, poses, stepped } = setup(); await viewer.mount(host); await viewer.open('pano1'); - parts(host).canvas.fire('webglcontextlost'); + const lost = parts(host).canvas; + lost.fire('webglcontextlost'); assert.equal(renderers[0].destroyed, true); + // A lost canvas only ever gives back its lost context: a new element. + assert.notEqual(parts(host).canvas, lost); + assert.deepEqual(stepped, ['pano1'], 'reopened through the core'); assert.ok(await until(() => renderers.length === 2), 'a new renderer'); + assert.equal(renderers[1].canvas, parts(host).canvas); assert.equal(poses.at(-1).imageId, 'pano1'); assert.equal(parts(host).canvas.hidden, false); }); +test('a context lost while a newer photo opens leaves that open alone', async () => { + const { viewer, host, stepped } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const opening = viewer.open('flat1'); + parts(host).canvas.fire('webglcontextlost'); + await opening; + assert.deepEqual(stepped, [], 'no reopen of the old panorama'); + assert.equal(parts(host).picture.hidden, false); +}); + test('a WebGL failure only lasts until the viewer is mounted again', async () => { let fail = true; const built = []; diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index 142b2d39c..a2e6452f6 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -30,6 +30,14 @@ export function fakeElement(tag) { if (list) list.splice(list.indexOf(element), 1); element.parentNode = null; }, + replaceWith(next) { + const parent = element.parentNode; + if (!parent) return; + parent.children[parent.children.indexOf(element)] = next; + next.parentNode = parent; + next.ownerDocument = element.ownerDocument; + element.parentNode = null; + }, setAttribute: (name, value) => (element.attributes[name] = value), removeAttribute: (name) => { delete element.attributes[name];