mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(track): read a late arrival frame before timing it out, so a measured wrong angle is never retried away
- observeArrivalRotation reads the sample before the deadline check and keeps a late frame's angle on the timeout result; arrivalAngleViolated then makes it final. Regressions: wrong late reveal and wrong late settle, each followed by a clean second attempt, still fail; a clean late frame stays retryable. - Overpass admission tests: a monotonic per-test clock removes order dependence on the module-level limiters; the proxy test covers ::1 and ::ffff:127.0.0.1 sockets; a new test pins the proxied: upstream bucket. - Retry edges (wrong on-time reveal, maxAttempts, inclusive 10° limit) and a check that every name the track harness imports is exported. - Docs: nginx needs Host, X-Forwarded-Proto and X-Forwarded-For; Docker bridge networking makes every host client one remote peer; list the exact forwarding headers; owner misses can also see 503.
This commit is contained in:
1 parent
8f16d10f7a
commit
01c764d4fd
8 files changed
+187
-50
No files matched your search
@@ -197,7 +197,7 @@ env UID=1026 GID=100 docker compose up -d
|
||||
|
||||
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
|
||||
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). If the proxy runs on the same machine, make it send a forwarding header such as `X-Forwarded-For` (Caddy does by default; nginx needs `proxy_set_header`), so its users are rate-limited apart from your own browser.
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-*`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` or similar), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`; cache hits are unaffected.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||
cache miss can still be rate-limited (asturksever, #1308).
|
||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||
retry only an inconclusive timeout: a measured wrong settle angle is kept
|
||||
through a reveal timeout and fails without retry, so slow software-rendered
|
||||
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||
frames no longer fail the check and a real violation is never retried into a
|
||||
pass (asturksever, #1308).
|
||||
@@ -5125,7 +5125,7 @@ pins and share links already supply complete boxes.
|
||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout that already measured a wrong settle angle keeps it and fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
|
||||
@@ -1073,6 +1073,7 @@
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
|
||||
@@ -8,6 +8,17 @@ import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||
|
||||
// The limiters are module-level, so every test that spends admission mocks a
|
||||
// clock a full day past the previous one in execution order: no test can see
|
||||
// another's window, whatever order (or randomization) they run in.
|
||||
let clockBase = Date.now() + 86_400_000;
|
||||
function freshClock(t) {
|
||||
clockBase += 86_400_000;
|
||||
const clock = { now: clockBase };
|
||||
t.mock.method(Date, 'now', () => clock.now);
|
||||
return clock;
|
||||
}
|
||||
|
||||
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||
for (const query of [
|
||||
'node[',
|
||||
@@ -152,7 +163,8 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async () => {
|
||||
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
|
||||
freshClock(t);
|
||||
const route = handler();
|
||||
for (const headers of [
|
||||
{},
|
||||
@@ -185,8 +197,7 @@ test('same-origin, LAN, proxied and non-browser requests still reach validation;
|
||||
});
|
||||
|
||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||
let now = Date.now() + 120000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -200,16 +211,12 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
||||
assert.equal(denied.reads, 0);
|
||||
assert.equal(denied.headers['retry-after'], '60');
|
||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||
now += 60001;
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||
});
|
||||
|
||||
// Each limiter test mocks Date.now well past the previous test's window so the
|
||||
// module-level limiters start from an empty window.
|
||||
|
||||
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
|
||||
let now = Date.now() + 3_600_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -227,13 +234,12 @@ test('the 900/min global window spans non-loopback peers only; loopback keeps it
|
||||
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
|
||||
assert.equal(owner.reads, 1);
|
||||
}
|
||||
now += 60_001;
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
|
||||
let now = Date.now() + 7_200_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -262,13 +268,12 @@ test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4
|
||||
}
|
||||
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
|
||||
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
|
||||
now += 60_001;
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
|
||||
});
|
||||
|
||||
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
|
||||
let now = Date.now() + 10_800_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
@@ -289,7 +294,7 @@ test('cache hits spend admission budget but not the 90/min upstream budget', asy
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: now,
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
const peer = '10.7.7.7';
|
||||
@@ -322,8 +327,7 @@ test('cache hits spend admission budget but not the 90/min upstream budget', asy
|
||||
});
|
||||
|
||||
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
|
||||
let now = Date.now() + 14_400_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
const hit = 'node(around:20,3,3);out;';
|
||||
@@ -341,34 +345,35 @@ test('a local reverse proxy cannot spend the direct-loopback reserve; owner cach
|
||||
body: '{"elements":[]}',
|
||||
contentType: 'application/json',
|
||||
endpoint: 'https://overpass.example/api/interpreter',
|
||||
cachedAt: now,
|
||||
cachedAt: clock.now,
|
||||
});
|
||||
const route = handler();
|
||||
// The proxy connects over loopback; a rotating forwarded client address
|
||||
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
|
||||
const proxied = (i) => ({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||
body: form(hit),
|
||||
});
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, proxied(i))).statusCode, 200);
|
||||
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
|
||||
const proxied = (i) => ({
|
||||
peer: proxyPeer,
|
||||
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
|
||||
body: form(hit),
|
||||
});
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
|
||||
}
|
||||
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
|
||||
assert.equal(refused.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1']) {
|
||||
const owner = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
clock.now += 60_001;
|
||||
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||
}
|
||||
const refused = await call(route, { ...proxied(300), forbidRead: true });
|
||||
assert.equal(refused.statusCode, 429, 'the proxy bucket is exhausted');
|
||||
assert.equal(refused.reads, 0);
|
||||
for (const peer of ['127.0.0.1', '::1']) {
|
||||
const owner = await call(route, { peer, body: form(hit) });
|
||||
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
|
||||
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
|
||||
}
|
||||
now += 60_001;
|
||||
assert.equal((await call(route, proxied(0))).statusCode, 200);
|
||||
});
|
||||
|
||||
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
|
||||
let now = Date.now() + 18_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const clock = freshClock(t);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
@@ -388,3 +393,34 @@ test('proxied loopback traffic draws on the shared remote global window', async
|
||||
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
|
||||
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
|
||||
});
|
||||
|
||||
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
|
||||
freshClock(t);
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
// Not data: nothing is cached, so every request below is a miss.
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('upstream unavailable', { status: 503 }),
|
||||
);
|
||||
const route = handler();
|
||||
const miss = form('node(around:20,4,4);out;');
|
||||
// The upstream limiter's own refusal is the only miss without this header.
|
||||
const passedLimiter = (response) =>
|
||||
response.headers['x-overpass-cache'] !== undefined;
|
||||
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const response = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
|
||||
}
|
||||
const spent = await call(route, { ...proxied, body: miss });
|
||||
assert.equal(spent.statusCode, 429);
|
||||
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
|
||||
const owner = await call(route, { peer: '127.0.0.1', body: miss });
|
||||
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
|
||||
});
|
||||
@@ -51,13 +51,21 @@ export function observeArrivalRotation({
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
// A reveal timeout keeps the settle angle it already measured, so a wrong
|
||||
// settle frame is still visible to the caller and cannot be retried away.
|
||||
const expired = () => ({
|
||||
// A timeout keeps every angle it measured: the settle angle before a
|
||||
// reveal timeout, and the angle on a late frame itself (a late frame is
|
||||
// read first; the idle refresh can only repair an angle, so a wrong one
|
||||
// seen late was wrong on time too). `arrivalAngleViolated` then makes the
|
||||
// timeout final instead of retryable.
|
||||
const expired = (late = null) => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
...(moveEnd ? { dMoveEnd: moveEnd.rotationError } : {}),
|
||||
...(moveEnd
|
||||
? { dMoveEnd: moveEnd.rotationError }
|
||||
: late
|
||||
? { dMoveEnd: late.rotationError }
|
||||
: {}),
|
||||
...(moveEnd && late?.shown ? { dReveal: late.rotationError } : {}),
|
||||
flipFrame: -1,
|
||||
});
|
||||
const armDeadline = () => {
|
||||
@@ -69,11 +77,11 @@ export function observeArrivalRotation({
|
||||
try {
|
||||
const at = now();
|
||||
const elapsedMs = at - startedAt;
|
||||
const sample = read();
|
||||
if (at - phaseStartedAt >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
finish(expired(sample));
|
||||
return;
|
||||
}
|
||||
const sample = read();
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as arrivalModule from './trackArrivalObservation.mjs';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalAngleViolated,
|
||||
@@ -182,11 +184,12 @@ test('missing moveEnd frames fail without waiting for a render callback', async
|
||||
// Let a retry's next attempt register its postRender listener.
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function retryFixture() {
|
||||
function retryFixture(extra = {}) {
|
||||
const counts = { prepares: 0 };
|
||||
const f = fixture((options) =>
|
||||
observeArrivalRotationWithRetry({
|
||||
...options,
|
||||
...extra,
|
||||
async prepare() {
|
||||
counts.prepares++;
|
||||
},
|
||||
@@ -264,3 +267,92 @@ test('a missing or non-finite angle never passes', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a wrong angle on a late reveal frame is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(100, { shown: true, rotationError: 0 });
|
||||
// Shown at 600 ms (500 ms after settle) still wearing the tampered nose.
|
||||
f.frame(600, { shown: true, rotationError: 1.5 });
|
||||
await settle();
|
||||
f.frame(608, { shown: true, rotationError: 0 });
|
||||
f.frame(688, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.dReveal, 1.5);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a wrong angle on a late settle frame is final, not retried into a pass', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(926, { shown: true, rotationError: 1.5 });
|
||||
await settle();
|
||||
f.frame(934, { shown: true, rotationError: 0 });
|
||||
f.frame(1014, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(result.dMoveEnd, 1.5);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a late frame with correct angles stays an inconclusive, retryable timeout', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(926, { shown: true, rotationError: 0 });
|
||||
await settle();
|
||||
f.frame(934, { shown: true, rotationError: 0 });
|
||||
f.frame(1014, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
});
|
||||
|
||||
test('a wrong first reveal on time is final: no retry', async () => {
|
||||
const f = retryFixture();
|
||||
await settle();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('maxAttempts bounds inconclusive retries', async () => {
|
||||
const f = retryFixture({ maxAttempts: 3 });
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await settle();
|
||||
f.expire(attempt * 500);
|
||||
}
|
||||
const result = await f.observation;
|
||||
assert.equal(result.attempts, 3);
|
||||
assert.equal(f.counts.prepares, 3);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the 10° limit is inclusive', () => {
|
||||
const limit = Math.PI / 18;
|
||||
assert.equal(arrivalAngleViolated({ dMoveEnd: limit }), true);
|
||||
assert.equal(arrivalAngleViolated({ dReveal: limit * 0.999 }), false);
|
||||
});
|
||||
|
||||
test('every name the track harness imports from this module is exported', () => {
|
||||
const harness = readFileSync(
|
||||
new URL('../../scripts/track-regression.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const names = [
|
||||
...harness.matchAll(
|
||||
/(?:import|const)\s*\{([^}]+)\}\s*(?:from\s*|=\s*await import\()[^;]*trackArrivalObservation\.mjs/g,
|
||||
),
|
||||
].flatMap((match) => match[1].split(',').map((name) => name.trim()));
|
||||
assert.ok(names.includes('arrivalRotationPassed'));
|
||||
assert.ok(names.includes('observeArrivalRotationWithRetry'));
|
||||
for (const name of names)
|
||||
assert.equal(typeof arrivalModule[name], 'function', name);
|
||||
});
|
||||
Reference in new issue
Block a user