fix(street-level): KartaView round-2 follow-ups — context loss, size cap, slow lookups

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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-13 21:03:13 +01:00
1 parent 0394a9b908
commit 97a1a667de
7 files changed
+158 -24

No files matched your search

+6 -2
View File
@@ -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 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 (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 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 `storage` and `filepath*`: the API's `fileurl*` always name storage1, where
photos stored elsewhere 404, and a `fileurl*` fallback must itself be an 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, 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 centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the
canvas re-measures every frame). Without WebGL, or if the proxy fails within 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 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 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 the full-size photo through the image proxy, since kartaview.org's photo
pages fail for signed-out visitors. pages fail for signed-out visitors.
@@ -66,6 +66,11 @@ export const TILE_CONCURRENCY = 3;
export const PAGE_CONCURRENCY = 1; export const PAGE_CONCURRENCY = 1;
export const TILE_TIMEOUT_MS = 20_000; export const TILE_TIMEOUT_MS = 20_000;
export const PHOTO_API_TIMEOUT_MS = 15_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. */ /** A sequence page is about 650 kB of JSON. */
export const PAGE_TIMEOUT_MS = 30_000; export const PAGE_TIMEOUT_MS = 30_000;
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */ /** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
@@ -10,6 +10,7 @@ import {
PHOTO_API_RETRIES, PHOTO_API_RETRIES,
PHOTO_API_RETRY_BASE_MS, PHOTO_API_RETRY_BASE_MS,
PHOTO_API_RETRY_MAX_MS, PHOTO_API_RETRY_MAX_MS,
PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS,
TILE_CONCURRENCY, TILE_CONCURRENCY,
TILE_TIMEOUT_MS, TILE_TIMEOUT_MS,
@@ -144,11 +145,33 @@ export function createKartaViewSource({
{ status: response.status }, { status: response.status },
); );
const length = Number(response.headers?.get?.('content-length')); const length = Number(response.headers?.get?.('content-length'));
if (length > MAX_RESPONSE_BYTES) const tooMuch = () =>
throw new KartaViewSourceError('KartaView sent too much data', { new KartaViewSourceError('KartaView sent too much data', {
status: response.status, 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) { } catch (error) {
if (signal?.aborted) throw signal.reason; if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason; if (controller.signal.aborted) throw controller.signal.reason;
@@ -168,7 +191,11 @@ export function createKartaViewSource({
path, path,
params, params,
{ signal } = {}, { 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 query = params ? `?${new URLSearchParams(params)}` : '';
const url = `${apiHost}${path}${query}`; const url = `${apiHost}${path}${query}`;
@@ -176,7 +203,12 @@ export function createKartaViewSource({
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
const payload = await gate.run( 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 }, { signal },
); );
const code = Number(payload?.status?.apiCode); const code = Number(payload?.status?.apiCode);
@@ -300,7 +332,11 @@ export function createKartaViewSource({
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
{ page: String(page), itemsPerPage: String(itemsPerPage) }, { page: String(page), itemsPerPage: String(itemsPerPage) },
{ signal }, { signal },
{ gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS }, {
gate: pageLimiter,
timeoutMs: PAGE_TIMEOUT_MS,
firstTimeoutMs: PAGE_TIMEOUT_MS,
},
); );
return { return {
photos: Array.isArray(result.data) ? result.data : [], photos: Array.isArray(result.data) ? result.data : [],
@@ -10,6 +10,7 @@ import {
NEAREST_SEARCH_ZOOM, NEAREST_SEARCH_ZOOM,
PHOTO_API_CONCURRENCY, PHOTO_API_CONCURRENCY,
PHOTO_API_RETRIES, PHOTO_API_RETRIES,
PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS,
TILE_CONCURRENCY, TILE_CONCURRENCY,
} from './policy.js'; } 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'); 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'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const asked = []; const asked = [];
const outcome = await onMockClock(t, (stamp) => 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'), }).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.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 () => { 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_SEARCH_ZOOM, 18);
assert.equal(NEAREST_LIMIT, 10); 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: [] });
});
@@ -83,6 +83,8 @@ export function createKartaViewViewer({
let drag = null; let drag = null;
let poseFrame = 0; let poseFrame = 0;
let removeListeners = []; 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. */ /** The photo an open is fetching: ‹ › step on from it, not the old one. */
let openingId = null; let openingId = null;
/** ‹ › chose this photo: keep the panorama's look direction for it. */ /** ‹ › chose this photo: keep the panorama's look direction for it. */
@@ -202,13 +204,35 @@ export function createKartaViewViewer({
} catch { } catch {
/* already gone */ /* 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; const again = photo.id;
show(null); 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 * 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. * way after an earlier press, and open it through the core.
@@ -346,6 +370,7 @@ export function createKartaViewViewer({
function build(host) { function build(host) {
const doc = host.ownerDocument; const doc = host.ownerDocument;
documentOf = doc;
root = doc.createElement('div'); root = doc.createElement('div');
root.className = 'sl-kv-viewer'; root.className = 'sl-kv-viewer';
root.tabIndex = 0; root.tabIndex = 0;
@@ -359,9 +384,7 @@ export function createKartaViewViewer({
picture.alt = 'KartaView street-level photo'; picture.alt = 'KartaView street-level photo';
picture.draggable = false; picture.draggable = false;
picture.hidden = true; picture.hidden = true;
canvas = doc.createElement('canvas'); canvas = makeCanvas(doc);
canvas.className = 'sl-kv-pano';
canvas.hidden = true;
const nav = (label, glyph, delta) => { const nav = (label, glyph, delta) => {
const button = doc.createElement('button'); const button = doc.createElement('button');
button.type = 'button'; button.type = 'button';
@@ -380,12 +403,6 @@ export function createKartaViewViewer({
nextButton = nav('Next photo in this sequence (→)', '›', 1); nextButton = nav('Next photo in this sequence (→)', '›', 1);
root.append(picture, canvas, prevButton, nextButton); root.append(picture, canvas, prevButton, nextButton);
host.append(root); 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); listen(root, 'keydown', onKeyDown);
applyRenderMode(); applyRenderMode();
} }
@@ -69,6 +69,7 @@ const PHOTOS = {
function fakeRenderer(canvas) { function fakeRenderer(canvas) {
const view = { yaw: 0, pitch: 0, fov: 80 }; const view = { yaw: 0, pitch: 0, fov: 80 };
return { return {
canvas,
images: [], images: [],
hiddenAtSetImage: [], hiddenAtSetImage: [],
setImage(image) { 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 () => { 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.mount(host);
await viewer.open('pano1'); await viewer.open('pano1');
parts(host).canvas.fire('webglcontextlost'); const lost = parts(host).canvas;
lost.fire('webglcontextlost');
assert.equal(renderers[0].destroyed, true); 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.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(poses.at(-1).imageId, 'pano1');
assert.equal(parts(host).canvas.hidden, false); 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 () => { test('a WebGL failure only lasts until the viewer is mounted again', async () => {
let fail = true; let fail = true;
const built = []; const built = [];
+8
View File
@@ -30,6 +30,14 @@ export function fakeElement(tag) {
if (list) list.splice(list.indexOf(element), 1); if (list) list.splice(list.indexOf(element), 1);
element.parentNode = null; 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), setAttribute: (name, value) => (element.attributes[name] = value),
removeAttribute: (name) => { removeAttribute: (name) => {
delete element.attributes[name]; delete element.attributes[name];