fix: prevent infinite loading screen by adding sweep timeouts

The dashboard would hang indefinitely on the loading screen because:

1. `bls.mjs` used a raw `fetch()` without any timeout/AbortSignal —
   if the BLS API was slow or unresponsive, it would block forever.

2. `runSource()` in `briefing.mjs` had no per-source timeout, so a
   single hanging API could stall the entire sweep indefinitely.

3. `server.mjs` loaded cached `latest.json` via a fire-and-forget
   promise (`.then()`) instead of `await`, meaning the dashboard
   never received the cached data before the sweep started.

4. `loading.html` relied solely on SSE for redirect — if the SSE
   connection missed the update event, the page would never redirect.

Changes:
- Add 15s AbortController timeout to BLS `getSeries()` fetch call
- Add 30s per-source timeout via `Promise.race()` in `runSource()`
- Await `synthesize()` when loading cached data so the dashboard
  serves instantly on restart when `runs/latest.json` exists
- Add 5s fallback polling to loading page alongside SSE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Firdavs
2026-03-17 21:56:08 +03:00
parent 0200e6d9d5
commit d22f36e158
4 changed files with 48 additions and 18 deletions

View File

@@ -43,10 +43,16 @@ import { briefing as space } from './sources/space.mjs';
// === Tier 5: Live Market Data === // === Tier 5: Live Market Data ===
import { briefing as yfinance } from './sources/yfinance.mjs'; import { briefing as yfinance } from './sources/yfinance.mjs';
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
export async function runSource(name, fn, ...args) { export async function runSource(name, fn, ...args) {
const start = Date.now(); const start = Date.now();
try { try {
const data = await fn(...args); const dataPromise = fn(...args);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS)
);
const data = await Promise.race([dataPromise, timeoutPromise]);
return { name, status: 'ok', durationMs: Date.now() - start, data }; return { name, status: 'ok', durationMs: Date.now() - start, data };
} catch (e) { } catch (e) {
return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
@@ -57,7 +63,7 @@ export async function fullBriefing() {
console.error('[Crucix] Starting intelligence sweep — 27 sources...'); console.error('[Crucix] Starting intelligence sweep — 27 sources...');
const start = Date.now(); const start = Date.now();
const results = await Promise.allSettled([ const allPromises = [
// Tier 1: Core OSINT & Geopolitical // Tier 1: Core OSINT & Geopolitical
runSource('GDELT', gdelt), runSource('GDELT', gdelt),
runSource('OpenSky', opensky), runSource('OpenSky', opensky),
@@ -94,7 +100,11 @@ export async function fullBriefing() {
// Tier 5: Live Market Data // Tier 5: Live Market Data
runSource('YFinance', yfinance), runSource('YFinance', yfinance),
]); ];
// Each runSource has its own 30s timeout, so allSettled will resolve
// within ~30s even if APIs hang. Global timeout is a safety net.
const results = await Promise.allSettled(allPromises);
const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message }); const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message });
const totalMs = Date.now() - start; const totalMs = Date.now() - start;

View File

@@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) {
if (apiKey) payload.registrationkey = apiKey; if (apiKey) payload.registrationkey = apiKey;
try { try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const res = await fetch(base, { const res = await fetch(base, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: controller.signal,
}); });
clearTimeout(timer);
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
return { error: e.message }; return { error: e.message };

View File

@@ -130,18 +130,26 @@ fetch('/api/health')
.catch(() => startCountdown(0)); .catch(() => startCountdown(0));
// === SSE — wait for sweep to complete, then redirect === // === SSE — wait for sweep to complete, then redirect ===
let redirected = false;
function goToDashboard() {
if (redirected) return;
redirected = true;
clearInterval(countdownInterval);
clearInterval(pollInterval);
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
}
const es = new EventSource('/events'); const es = new EventSource('/events');
es.onmessage = (e) => { es.onmessage = (e) => {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'update') { if (msg.type === 'update') {
es.close(); es.close();
clearInterval(countdownInterval); goToDashboard();
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
} }
} catch {} } catch {}
}; };
@@ -149,6 +157,13 @@ es.onerror = () => {
es.close(); es.close();
setTimeout(() => location.reload(), 3000); setTimeout(() => location.reload(), 3000);
}; };
// === Fallback polling — in case SSE misses the update ===
const pollInterval = setInterval(() => {
fetch('/api/data').then(r => {
if (r.ok) goToDashboard();
}).catch(() => {});
}, 5000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -408,7 +408,7 @@ async function start() {
process.exit(1); process.exit(1);
}); });
server.on('listening', () => { server.on('listening', async () => {
console.log(`[Crucix] Server running on http://localhost:${port}`); console.log(`[Crucix] Server running on http://localhost:${port}`);
// Auto-open browser // Auto-open browser
@@ -420,17 +420,18 @@ async function start() {
if (err) console.log('[Crucix] Could not auto-open browser:', err.message); if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
}); });
// Try to load existing data first for instant display // Try to load existing data first for instant display (await so dashboard shows immediately)
try { try {
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8')); const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
synthesize(existing).then(data => { const data = await synthesize(existing);
currentData = data; currentData = data;
console.log('[Crucix] Loaded existing data from runs/latest.json'); console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
broadcast({ type: 'update', data: currentData }); broadcast({ type: 'update', data: currentData });
}).catch(() => {}); } catch {
} catch { /* no existing data */ } console.log('[Crucix] No existing data found — first sweep required');
}
// Run first sweep // Run first sweep (refreshes data in background)
console.log('[Crucix] Running initial sweep...'); console.log('[Crucix] Running initial sweep...');
runSweepCycle().catch(err => { runSweepCycle().catch(err => {
console.error('[Crucix] Initial sweep failed:', err.message || err); console.error('[Crucix] Initial sweep failed:', err.message || err);