From 52f801675ee8dbdba6ef247dab32724792036f20 Mon Sep 17 00:00:00 2001 From: Shawn Date: Tue, 2 Jun 2026 18:43:17 -0400 Subject: [PATCH] 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 --- static/index.html | 111 ++++++++++++++++++++++++++++++---------------- static/sw.js | 24 +++------- 2 files changed, 78 insertions(+), 57 deletions(-) diff --git a/static/index.html b/static/index.html index 02ff26b..b0a2d9a 100644 --- a/static/index.html +++ b/static/index.html @@ -2028,13 +2028,26 @@ opts.headers['Authorization'] = 'Bearer ' + AppState.authToken; } } - // 15s timeout to prevent hanging lookups on slow/mobile networks - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); - opts.signal = controller.signal; + // 15s timeout via Promise.race instead of AbortController — the SW + // intercepts all API requests and AbortController signals don't properly + // propagate through the Service Worker layer, causing indefinite hangs + // 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 { - const res = await fetch(url, opts); - clearTimeout(timeoutId); + let res; + 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; const text = await res.text(); let data = null; @@ -2046,13 +2059,9 @@ if (returnMeta) return { data, headers: res.headers }; return data; } catch (e) { - clearTimeout(timeoutId); if (e.name === 'TypeError' && e.message === 'Failed to fetch') { throw new Error('Network error — check your connection'); } - if (e.name === 'AbortError') { - throw new Error('Request timed out — check your connection'); - } throw e; } } @@ -3014,11 +3023,22 @@ function stopScanning() { 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) { try { codeReader.reset(); } catch (e) { /* ignore */ } codeReader = null; } - const video = document.getElementById('cameraVideo'); if (video) video.style.display = 'none'; const ph = document.getElementById('cameraPlaceholder'); if (ph) { @@ -3065,36 +3085,49 @@ } async function handleBarcode(machineId) { - if (barcodeDebounce) return; - barcodeDebounce = machineId; - currentScannedMachineId = machineId; - - // Stop the camera scanner before the API call — on mobile browsers - // an active camera stream can throttle concurrent fetch requests. - stopScanning(); - - setScanStatus(`Scanned: ${machineId} — looking up...`, 'working'); - document.getElementById('scanResult').style.display = 'none'; - document.getElementById('checkinCard').style.display = 'none'; - try { - const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); - // Asset exists — show result in-place with scan new button - showToast(`${esc(asset.name)} — found!`, false); - // Auto-checkin if GPS available (background, non-blocking) - if (AppState.gpsLat != null) { - doAutoCheckin(asset.id); - } - showScannedAsset(asset); - } catch (e) { - if (e.message === 'Asset not found' || e.message.includes('404')) { - showNewAssetForm(machineId); - } else { - setScanStatus('Lookup error: ' + e.message, 'error'); - } - } + if (barcodeDebounce) return; + barcodeDebounce = machineId; + currentScannedMachineId = machineId; - setTimeout(() => { barcodeDebounce = null; }, 2000); + // Stop the camera scanner before the API call — on mobile browsers + // an active camera stream can throttle concurrent fetch requests. + 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'); + const sr = document.getElementById('scanResult'); + if (sr) sr.style.display = 'none'; + const cc = document.getElementById('checkinCard'); + if (cc) cc.style.display = 'none'; + + try { + const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); + // Asset exists — show result in-place with scan new button + showToast(`${esc(asset.name)} — found!`, false); + // Auto-checkin if GPS available (background, non-blocking) + if (AppState.gpsLat != null) { + doAutoCheckin(asset.id); + } + showScannedAsset(asset); + } catch (e) { + if (e.message === 'Asset not found' || e.message.includes('404')) { + showNewAssetForm(machineId); + } else { + setScanStatus('Lookup error: ' + e.message, 'error'); + } + } + + 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) { diff --git a/static/sw.js b/static/sw.js index 6b15e44..05d599c 100644 --- a/static/sw.js +++ b/static/sw.js @@ -3,7 +3,7 @@ // 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 const APP_SHELL = [ @@ -73,24 +73,12 @@ self.addEventListener('fetch', (event) => { return; } - // API GET calls: network-first, cache fallback (offline reads) - // API POST/PUT/DELETE: pass through — main thread handles offline queuing + // API requests: pass through — don't intercept at all. + // 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 (event.request.method === 'GET') { - 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; + return; // Don't call event.respondWith — let the request go direct } // Static assets (local + CDN): network-first, cache fallback