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
(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.
@@ -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. */
@@ -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 : [],
@@ -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: [] });
});
@@ -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();
}
@@ -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 = [];
+8
View File
@@ -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];