diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 833573c2b..7b6cec57d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -140,6 +140,7 @@ jobs:
node test-packet-filter-time.js
node test-confidence-indicator.js
node test-1659-analytics-warmup.js
+ node test-issue-120-distance-building.js
node test-app-api-inflight-cleanup-rejection.js
node test-channels-merge-1498-unit.js
node test-issue-1518-home-url.js
diff --git a/public/analytics.js b/public/analytics.js
index e73d91403..7d55addad 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -35,6 +35,29 @@
function _stopForeignTrafficRefresh() {
if (_foreignTrafficRefreshTimer) { clearInterval(_foreignTrafficRefreshTimer); _foreignTrafficRefreshTimer = null; }
}
+ // #120 — the distance tab's lazy index answers 202 {status:"building"}
+ // until it is built. The tab shows a building state and retries on the
+ // server's interval. Every render, tab switch away and destroy() bumps
+ // _distanceGen, so a response or retry from an older render can never
+ // write into a newer render, another tab or a left page, and at most one
+ // retry timer exists.
+ var _distanceRetryTimer = null;
+ var _distanceGen = 0;
+ function _leaveDistanceTab() {
+ _distanceGen++;
+ if (_distanceRetryTimer) { clearTimeout(_distanceRetryTimer); _distanceRetryTimer = null; }
+ }
+ function _distanceIsBuilding(data) {
+ return !!(data && data.status === 'building' && !data.summary);
+ }
+ // Retry-After header (passed on by api()), else the body's
+ // retry_after_seconds, else 5s; clamped to 1..30s.
+ function _distanceRetryDelayMs(data) {
+ var s = Number(data && data.retryAfterSeconds);
+ if (!(isFinite(s) && s > 0)) s = Number(data && data.retry_after_seconds);
+ if (!(isFinite(s) && s > 0)) s = 5;
+ return Math.min(Math.max(s, 1), 30) * 1000;
+ }
var _wardrivingRefreshTimer = null;
function _stopWardrivingRefresh() {
if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; }
@@ -196,6 +219,7 @@
if (_currentTab !== 'foreign-traffic') _stopForeignTrafficRefresh();
if (_currentTab !== 'wardriving') _stopWardrivingRefresh();
if (_currentTab !== 'areas') _stopAreasRefresh();
+ if (_currentTab !== 'distance') _leaveDistanceTab();
_updateAnalyticsUrl();
renderTab(_currentTab);
});
@@ -2977,10 +3001,25 @@
// === REPEATER METRICS BLOCK END (test harness slices the renderer block to here) ===
async function renderDistanceTab(el) {
+ // A new render supersedes any earlier one and its pending retry (#120).
+ _leaveDistanceTab();
+ const gen = _distanceGen;
try {
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
+ if (gen !== _distanceGen) return; // re-rendered, switched tab or left meanwhile
+ if (_distanceIsBuilding(data)) {
+ const ms = _distanceRetryDelayMs(data);
+ el.innerHTML = '
${s.totalHops.toLocaleString()}
Total Hops Analyzed
@@ -3057,11 +3096,12 @@
});
});
} catch (e) {
+ if (gen !== _distanceGen) return;
el.innerHTML = `
Failed to load distance analytics: ${esc(e.message)}
`;
}
}
-function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
+function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _leaveDistanceTab(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
// Expose for testing
if (typeof window !== 'undefined') {
@@ -3083,6 +3123,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
window._analyticsRenderWardrivingTab = renderWardrivingTab;
window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh;
window._analyticsRenderAreasTab = renderAreasTab;
+ window._analyticsRenderDistanceTab = renderDistanceTab;
+ window._analyticsLeaveDistanceTab = _leaveDistanceTab;
window._analyticsStopAreasRefresh = _stopAreasRefresh;
window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope;
window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope;
diff --git a/public/app.js b/public/app.js
index e6cd4e8dd..149798478 100644
--- a/public/app.js
+++ b/public/app.js
@@ -190,7 +190,19 @@ async function api(path, { ttl = 0, bust = false } = {}) {
_apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() });
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`);
- if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl });
+ if (res.status === 202) {
+ // #120: 202 Accepted is "not ready yet" (the lazy distance index
+ // answers {status:"building"} until it is built), never data to
+ // keep: caching it would serve the placeholder back for the whole
+ // TTL. A valid Retry-After travels to the caller as a
+ // non-enumerable property, so the JSON body itself is unchanged.
+ const ra = parseInt(res.headers.get('Retry-After'), 10);
+ if (data && typeof data === 'object' && isFinite(ra) && ra > 0) {
+ Object.defineProperty(data, 'retryAfterSeconds', { value: ra, enumerable: false });
+ }
+ } else if (ttl > 0) {
+ _apiCache.set(path, { data, expires: Date.now() + ttl });
+ }
return data;
}
} finally {
diff --git a/test-all.sh b/test-all.sh
index 884db1880..7c977c4ce 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -15,6 +15,7 @@ node test-aging.js
node test-issue-1065-gesture-hints-gates.js
node test-frontend-helpers.js
node test-app-api-inflight-cleanup-rejection.js
+node test-issue-120-distance-building.js
node test-privacy-page.js
node test-nav-dynamic-link-lifecycle.js
node test-nav-first-load-fit.js
diff --git a/test-issue-120-distance-building.js b/test-issue-120-distance-building.js
new file mode 100644
index 000000000..59e2ef4a0
--- /dev/null
+++ b/test-issue-120-distance-building.js
@@ -0,0 +1,352 @@
+/* test-issue-120-distance-building.js — the lazy distance index's
+ * 202 Accepted is a transient "building" state, not data (#120).
+ *
+ * /api/analytics/distance answers 202 {status:"building",
+ * retry_after_seconds:5} with a Retry-After header until its lazy index is
+ * built (cmd/server/routes.go, handleAnalyticsDistance).
+ *
+ * Part A loads the REAL public/app.js into a vm with a fake fetch and checks
+ * api(): a 202 body is never cached, a 200 still is, and the Retry-After
+ * header of a 202 reaches the caller without changing the JSON body.
+ *
+ * Part B loads the REAL public/analytics.js and drives renderDistanceTab with
+ * a stubbed api() and a fake clock: the building state, the retry delay
+ * (valid, malformed and absent Retry-After), 202 -> 200, destroy and tab
+ * switch, re-entry, out-of-order responses, and at most one retry timer.
+ * Unhandled rejections fail the run.
+ */
+'use strict';
+
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+let unhandled = [];
+process.on('unhandledRejection', (e) => { unhandled.push(e); });
+
+let passed = 0, failed = 0;
+async function test(name, fn) {
+ unhandled = [];
+ try {
+ await fn();
+ await new Promise((r) => setImmediate(r));
+ if (unhandled.length) throw new Error('unhandled rejection: ' + (unhandled[0] && unhandled[0].message || unhandled[0]));
+ passed++; console.log(' ✅ ' + name);
+ } catch (e) {
+ failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e));
+ }
+}
+const flush = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); };
+
+// A fake clock: setTimeout only queues; advance(ms) runs what is due.
+function fakeClock() {
+ let now = 0, nextId = 1;
+ const timers = new Map();
+ return {
+ setTimeout: (fn, ms) => { const id = nextId++; timers.set(id, { fn, at: now + (Number(ms) || 0), ms: Number(ms) || 0 }); return id; },
+ clearTimeout: (id) => { timers.delete(id); },
+ pending: () => [...timers.values()],
+ async advance(ms) {
+ now += ms;
+ for (const [id, t] of [...timers]) {
+ if (t.at <= now) { timers.delete(id); t.fn(); }
+ }
+ await flush();
+ },
+ };
+}
+
+// ---------------------------------------------------------------- part A
+
+function appCtx(fetchImpl) {
+ const ctx = {
+ console, Date, Math, Promise, Error, isFinite, parseInt, JSON, Object,
+ performance: { now: () => 0 },
+ window: {},
+ document: { readyState: 'complete', body: { appendChild: () => {} }, createElement: () => ({ style: {} }) },
+ fetch: fetchImpl,
+ setTimeout: (fn) => { fn(); return 0; },
+ setInterval: () => 0, clearInterval: () => {},
+ Map, Set,
+ };
+ vm.createContext(ctx);
+ try { vm.runInContext(fs.readFileSync('public/app.js', 'utf8'), ctx); } catch (e) { /* DOM-only tail */ }
+ return ctx;
+}
+function res(status, body, retryAfter) {
+ return {
+ ok: status >= 200 && status < 300, status,
+ headers: { get: (k) => (String(k).toLowerCase() === 'retry-after' && retryAfter !== undefined ? retryAfter : null) },
+ json: async () => JSON.parse(JSON.stringify(body)),
+ };
+}
+const BUILDING = { status: 'building', retry_after_seconds: 5, detail: 'distance index is being computed' };
+
+// ---------------------------------------------------------------- part B
+
+function analyticsCtx(apiStub, clock) {
+ const pages = {};
+ const ctx = {
+ window: { addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {} },
+ document: {
+ readyState: 'complete', createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
+ head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {},
+ querySelectorAll: () => [], querySelector: () => null,
+ },
+ console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
+ parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
+ setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout,
+ setInterval: () => 0, clearInterval: () => {},
+ requestAnimationFrame: () => 0, cancelAnimationFrame: () => {},
+ fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), // app.js's /api/config/cache
+ performance: { now: () => 0 },
+ localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
+ location: { hash: '#/analytics?tab=distance' },
+ getHashParams: () => new URLSearchParams(''),
+ CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams,
+ getComputedStyle: () => ({ getPropertyValue: () => '' }),
+ registerPage: (name, obj) => { pages[name] = obj; },
+ timeAgo: () => 'x ago',
+ RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' },
+ onWS: () => {}, offWS: () => {}, connectWS: () => {}, invalidateApiCache: () => {},
+ makeColumnsResizable: () => {}, initTabBar: () => {}, IATA_COORDS_GEO: {},
+ };
+ vm.createContext(ctx);
+ const load = (f) => { vm.runInContext(fs.readFileSync(f, 'utf8'), ctx); for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; };
+ load('public/payload-labels.js');
+ load('public/roles.js');
+ try { load('public/app.js'); } catch (e) { /* DOM-only tail */ }
+ ctx.fetchAllNodes = async () => ({ nodes: [] });
+ ctx.api = apiStub;
+ ctx.registerPage = (name, obj) => { pages[name] = obj; }; // app.js defines its own
+ try { load('public/analytics.js'); } catch (e) { for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; }
+ ctx.__pages = pages;
+ return ctx;
+}
+function fakeEl() { return { innerHTML: '', querySelector: () => null, querySelectorAll: () => [] }; }
+const READY = {
+ summary: { totalHops: 1234, totalPaths: 56, avgDist: 3.2, maxDist: 40 },
+ catStats: { 'R↔R': { count: 3, avg: 2, median: 2, min: 1, max: 4 } },
+ distHistogram: { bins: [{ x: 1, count: 2 }] }, distOverTime: [], topHops: [], topPaths: [],
+};
+// A 202 body as api() hands it to the page: the Retry-After header, when
+// valid, travels as a non-enumerable property (it is not part of the JSON).
+function accepted(body, retryAfterSeconds) {
+ const d = JSON.parse(JSON.stringify(body));
+ if (retryAfterSeconds !== undefined) Object.defineProperty(d, 'retryAfterSeconds', { value: retryAfterSeconds, enumerable: false });
+ return d;
+}
+// api() stub fed from a queue of deferreds, so tests control ordering.
+function queuedApi() {
+ const q = [];
+ const calls = [];
+ const fn = (path) => {
+ calls.push(path);
+ if (!q.length) return Promise.reject(new Error('unexpected api call ' + path));
+ return q.shift().promise;
+ };
+ fn.calls = calls;
+ fn.push = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); const d = { promise, resolve, reject }; q.push(d); return d; };
+ fn.reply = (value) => { const d = fn.push(); d.resolve(value); return d; };
+ return fn;
+}
+
+(async () => {
+ console.log('\n=== #120 part A: api() and 202 ===');
+
+ await test('a 202 body is not cached: the next call refetches and gets the 200', async () => {
+ let n = 0;
+ const ctx = appCtx(async (url) => {
+ if (!/distance/.test(url)) return res(200, {});
+ n++;
+ return n === 1 ? res(202, BUILDING, '5') : res(200, READY);
+ });
+ const first = await ctx.api('/analytics/distance', { ttl: 300000 });
+ assert.strictEqual(first.status, 'building');
+ const second = await ctx.api('/analytics/distance', { ttl: 300000 });
+ assert.strictEqual(n, 2, 'second call must reach the server');
+ assert.ok(second.summary, 'second call returns the 200 body');
+ });
+
+ await test('a 200 is still cached for its TTL', async () => {
+ let n = 0;
+ const ctx = appCtx(async (url) => { if (/distance/.test(url)) n++; return res(200, READY); });
+ await ctx.api('/analytics/distance', { ttl: 300000 });
+ await ctx.api('/analytics/distance', { ttl: 300000 });
+ assert.strictEqual(n, 1);
+ });
+
+ await test('a valid Retry-After on a 202 reaches the caller; the JSON body is unchanged', async () => {
+ const ctx = appCtx(async (url) => (/distance/.test(url) ? res(202, BUILDING, '7') : res(200, {})));
+ const d = await ctx.api('/analytics/distance', { ttl: 300000 });
+ assert.strictEqual(d.retryAfterSeconds, 7);
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(d)), BUILDING, 'body as served');
+ });
+
+ await test('a malformed or absent Retry-After is not passed on', async () => {
+ for (const h of ['abc', '0', '-3', '', undefined]) {
+ const ctx = appCtx(async (url) => (/distance/.test(url) ? res(202, BUILDING, h) : res(200, {})));
+ const d = await ctx.api('/analytics/distance');
+ assert.strictEqual(d.retryAfterSeconds, undefined, 'header ' + JSON.stringify(h));
+ }
+ });
+
+ console.log('\n=== #120 part B: renderDistanceTab ===');
+
+ await test('202 shows a building state instead of an error, and schedules one retry', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const render = ctx.window._analyticsRenderDistanceTab;
+ assert.strictEqual(typeof render, 'function', 'renderDistanceTab test hook');
+ const el = fakeEl();
+ api.reply(accepted(BUILDING, 5));
+ await render(el);
+ assert.ok(/Building the distance index/.test(el.innerHTML), 'building notice: ' + el.innerHTML.slice(0, 120));
+ assert.ok(!/Failed to load/.test(el.innerHTML), 'no error: ' + el.innerHTML.slice(0, 120));
+ assert.strictEqual(clock.pending().length, 1, 'exactly one retry timer');
+ assert.strictEqual(clock.pending()[0].ms, 5000, 'retry after the 5s Retry-After');
+ });
+
+ await test('retry delay: header, then body, then a 5s fallback, clamped to 1..30s', async () => {
+ const cases = [
+ [accepted(BUILDING, 7), 7000],
+ [accepted({ status: 'building', retry_after_seconds: 9 }), 9000],
+ [accepted({ status: 'building', retry_after_seconds: 'soon' }), 5000],
+ [accepted({ status: 'building' }), 5000],
+ [accepted({ status: 'building', retry_after_seconds: 0 }), 5000],
+ [accepted(BUILDING, 3600), 30000],
+ [accepted({ status: 'building', retry_after_seconds: 0.2 }), 1000],
+ ];
+ for (const [body, want] of cases) {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ api.reply(body);
+ await ctx.window._analyticsRenderDistanceTab(fakeEl());
+ assert.strictEqual(clock.pending().length, 1);
+ assert.strictEqual(clock.pending()[0].ms, want, JSON.stringify(body) + ' ' + body.retryAfterSeconds);
+ }
+ });
+
+ await test('202 then 200: the retry replaces the building state and stops', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ api.reply(accepted(BUILDING, 5));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ api.reply(READY);
+ await clock.advance(5000);
+ assert.ok(/Total Hops Analyzed/.test(el.innerHTML), 'rendered data');
+ assert.ok(/1,234/.test(el.innerHTML), 'the 200 summary');
+ assert.strictEqual(clock.pending().length, 0, 'no further retries');
+ assert.strictEqual(api.calls.length, 2);
+ });
+
+ await test('destroy (navigation away) cancels the retry and ignores a late response', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ api.reply(accepted(BUILDING, 5));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ ctx.__pages.analytics.destroy();
+ assert.strictEqual(clock.pending().length, 0, 'timer cleared on destroy');
+ // a render whose response arrives after destroy must not write
+ const late = api.push();
+ const p = ctx.window._analyticsRenderDistanceTab(el);
+ ctx.__pages.analytics.destroy();
+ el.innerHTML = 'OTHER PAGE';
+ late.resolve(accepted(BUILDING, 5));
+ await p;
+ assert.strictEqual(el.innerHTML, 'OTHER PAGE', 'late response wrote into a destroyed view');
+ assert.strictEqual(clock.pending().length, 0, 'late 202 scheduled a retry');
+ });
+
+ await test('switching tab invalidates the pending retry and response', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const leave = ctx.window._analyticsLeaveDistanceTab;
+ assert.strictEqual(typeof leave, 'function', 'tab-switch invalidation hook');
+ const el = fakeEl();
+ api.reply(accepted(BUILDING, 5));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ leave();
+ assert.strictEqual(clock.pending().length, 0);
+ const late = api.push();
+ const p = ctx.window._analyticsRenderDistanceTab(el);
+ leave();
+ el.innerHTML = 'RF TAB';
+ late.resolve(READY);
+ await p;
+ assert.strictEqual(el.innerHTML, 'RF TAB');
+ // the tab bar calls it on every switch away from Distance
+ const src = fs.readFileSync('public/analytics.js', 'utf8');
+ assert.ok(/if \(_currentTab !== 'distance'\) _leaveDistanceTab\(\);/.test(src), 'tab click handler calls _leaveDistanceTab()');
+ });
+
+ await test('re-entry after leaving starts a fresh chain that still works', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ api.reply(accepted(BUILDING, 5));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ ctx.__pages.analytics.destroy();
+ api.reply(accepted(BUILDING, 2));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ assert.strictEqual(clock.pending().length, 1);
+ assert.strictEqual(clock.pending()[0].ms, 2000);
+ api.reply(READY);
+ await clock.advance(2000);
+ assert.ok(/Total Hops Analyzed/.test(el.innerHTML));
+ });
+
+ await test('out of order: an older 202 arriving after a newer 200 changes nothing', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ const older = api.push();
+ const p1 = ctx.window._analyticsRenderDistanceTab(el); // e.g. before a region change
+ api.reply(READY);
+ await ctx.window._analyticsRenderDistanceTab(el); // newer render wins
+ const after = el.innerHTML;
+ assert.ok(/Total Hops Analyzed/.test(after));
+ older.resolve(accepted(BUILDING, 5));
+ await p1;
+ assert.strictEqual(el.innerHTML, after, 'older response overwrote the newer render');
+ assert.strictEqual(clock.pending().length, 0, 'older response started a retry chain');
+ });
+
+ await test('repeated renders while building keep at most one retry timer', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ for (let i = 0; i < 4; i++) {
+ api.reply(accepted(BUILDING, 5));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ assert.strictEqual(clock.pending().length, 1, 'after render ' + (i + 1));
+ }
+ api.reply(accepted(BUILDING, 5));
+ await clock.advance(5000);
+ assert.strictEqual(clock.pending().length, 1, 'the retry re-arms exactly one timer');
+ });
+
+ await test('a failed request still shows the error state, with no retry', async () => {
+ const clock = fakeClock();
+ const api = queuedApi();
+ const ctx = analyticsCtx(api, clock);
+ const el = fakeEl();
+ api.push().reject(new Error('API 500: /analytics/distance'));
+ await ctx.window._analyticsRenderDistanceTab(el);
+ assert.ok(/Failed to load distance analytics: API 500/.test(el.innerHTML));
+ assert.strictEqual(clock.pending().length, 0);
+ });
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed > 0 ? 1 : 0);
+})();