fix: replace AbortController with Promise.race to avoid SW hanging fetch bug

Root cause: AbortController signals don't properly propagate through
Service Worker fetch handlers (known Chrome/Safari bug), causing the
fetch to hang indefinitely on mobile browsers instead of timing out.

Fix: replace AbortController-based timeout in api() with Promise.race
against a simple setTimeout. Also:
- Stop all MediaStream tracks explicitly in stopScanning()
- Add 200ms yield point before API call for hardware release
- Bypass SW for API requests entirely (no caching needed for lookups)
- Bump SW cache to v11
- Add outer try/catch safety net in handleBarcode
This commit is contained in:
2026-06-02 18:43:17 -04:00
parent dd63dc97d5
commit 52f801675e
2 changed files with 78 additions and 57 deletions
+46 -13
View File
@@ -2028,13 +2028,26 @@
opts.headers['Authorization'] = 'Bearer ' + AppState.authToken; opts.headers['Authorization'] = 'Bearer ' + AppState.authToken;
} }
} }
// 15s timeout to prevent hanging lookups on slow/mobile networks // 15s timeout via Promise.race instead of AbortController — the SW
const controller = new AbortController(); // intercepts all API requests and AbortController signals don't properly
const timeoutId = setTimeout(() => controller.abort(), 15000); // propagate through the Service Worker layer, causing indefinite hangs
opts.signal = controller.signal; // on mobile browsers (Chrome/Safari known bug).
const TIMEOUT_MS = 10000;
const fetchPromise = fetch(url, opts);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out — check your connection')), TIMEOUT_MS)
);
try { try {
const res = await fetch(url, opts); let res;
clearTimeout(timeoutId); try {
res = await Promise.race([fetchPromise, timeoutPromise]);
} catch (e) {
// timeoutPromise won — the fetch may still complete in the background
// but the user gets a clear timeout message instead of hanging.
if (e.message && e.message.includes('timed out')) throw e;
// If fetchPromise rejects first (network error), re-throw
throw e;
}
if (res.status === 204) return null; if (res.status === 204) return null;
const text = await res.text(); const text = await res.text();
let data = null; let data = null;
@@ -2046,13 +2059,9 @@
if (returnMeta) return { data, headers: res.headers }; if (returnMeta) return { data, headers: res.headers };
return data; return data;
} catch (e) { } catch (e) {
clearTimeout(timeoutId);
if (e.name === 'TypeError' && e.message === 'Failed to fetch') { if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
throw new Error('Network error — check your connection'); throw new Error('Network error — check your connection');
} }
if (e.name === 'AbortError') {
throw new Error('Request timed out — check your connection');
}
throw e; throw e;
} }
} }
@@ -3014,11 +3023,22 @@
function stopScanning() { function stopScanning() {
scanningActive = false; scanningActive = false;
// Explicitly stop all camera tracks BEFORE resetting ZXing — on iOS
// Safari, just setting srcObject=null via codeReader.reset() doesn't
// release the hardware immediately, and an active camera stream can
// block fetch() on mobile browsers.
const video = document.getElementById('cameraVideo');
if (video) {
const stream = video.srcObject;
if (stream && typeof stream.getTracks === 'function') {
stream.getTracks().forEach(t => { try { t.stop(); } catch (_) {} });
}
video.srcObject = null;
}
if (codeReader) { if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ } try { codeReader.reset(); } catch (e) { /* ignore */ }
codeReader = null; codeReader = null;
} }
const video = document.getElementById('cameraVideo');
if (video) video.style.display = 'none'; if (video) video.style.display = 'none';
const ph = document.getElementById('cameraPlaceholder'); const ph = document.getElementById('cameraPlaceholder');
if (ph) { if (ph) {
@@ -3065,6 +3085,7 @@
} }
async function handleBarcode(machineId) { async function handleBarcode(machineId) {
try {
if (barcodeDebounce) return; if (barcodeDebounce) return;
barcodeDebounce = machineId; barcodeDebounce = machineId;
currentScannedMachineId = machineId; currentScannedMachineId = machineId;
@@ -3072,10 +3093,16 @@
// Stop the camera scanner before the API call — on mobile browsers // Stop the camera scanner before the API call — on mobile browsers
// an active camera stream can throttle concurrent fetch requests. // an active camera stream can throttle concurrent fetch requests.
stopScanning(); stopScanning();
// Yield to the event loop so the browser fully releases camera hardware
// before we issue the fetch — critical on iOS Safari where an active
// stream can block concurrent network requests.
await new Promise(r => setTimeout(r, 200));
setScanStatus(`Scanned: ${machineId} — looking up...`, 'working'); setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
document.getElementById('scanResult').style.display = 'none'; const sr = document.getElementById('scanResult');
document.getElementById('checkinCard').style.display = 'none'; if (sr) sr.style.display = 'none';
const cc = document.getElementById('checkinCard');
if (cc) cc.style.display = 'none';
try { try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
@@ -3095,6 +3122,12 @@
} }
setTimeout(() => { barcodeDebounce = null; }, 2000); setTimeout(() => { barcodeDebounce = null; }, 2000);
} catch (e) {
// Safety net — any unexpected error (DOM null, stopScanning fail, etc.)
// gets surfaced rather than being silently swallowed.
setScanStatus('Error: ' + (e.message || e), 'error');
setTimeout(() => { barcodeDebounce = null; }, 2000);
}
} }
function showScannedAsset(asset) { function showScannedAsset(asset) {
+6 -18
View File
@@ -3,7 +3,7 @@
// Caches app shell + CDN deps. Network-first for API, cache fallback for static. // Caches app shell + CDN deps. Network-first for API, cache fallback for static.
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const CACHE_NAME = 'canteen-v10'; const CACHE_NAME = 'canteen-v11';
// App shell — core resources needed to boot the PWA // App shell — core resources needed to boot the PWA
const APP_SHELL = [ const APP_SHELL = [
@@ -73,24 +73,12 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// API GET calls: network-first, cache fallback (offline reads) // API requests: pass through — don't intercept at all.
// API POST/PUT/DELETE: pass through — main thread handles offline queuing // The SW cache doesn't help with real-time lookups, and SW interception
// combined with main-thread timeouts can cause indefinite hangs on mobile
// (AbortController signal doesn't propagate through SW fetch handler).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
if (event.request.method === 'GET') { return; // Don't call event.respondWith — let the request go direct
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
}
// Non-GET API calls: pass through — main thread queues offline writes
return;
} }
// Static assets (local + CDN): network-first, cache fallback // Static assets (local + CDN): network-first, cache fallback