mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
0394a9b908
commit
97a1a667de
7 files changed
+158
-24
No files matched your search
@@ -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 = [];
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user