Files
nextsnap/app/static/js/sync.js
kamaji 4491531acb Fix upload failures on iOS Safari (load failed)
iOS Safari kills fetch requests when the app goes to background or
the connection drops during large uploads, producing 'load failed'.
The sync engine was burning through all 5 retries instantly with no
delay, so a transient failure became permanent.

Changes:
- Add AbortController timeout (120s) on upload fetch
- Add exponential backoff between retries (2s, 5s, 10s...60s)
- Increase max retries from 5 to 15 for flaky mobile networks
- Remove 10MB resize step that was re-compressing photos already
  sized at capture time, avoiding extra memory pressure on iOS
- Log photo size with each upload attempt for easier debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:31:54 -06:00

279 lines
9.6 KiB
JavaScript

// NextSnap - Sync Engine
'use strict';
console.log('[SYNC] Loading sync.js...');
const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
MAX_RETRIES: 15,
VERIFY_DELAY_MS: 800,
UPLOAD_TIMEOUT_MS: 120000,
// Backoff delays in ms per retry attempt
BACKOFF_DELAYS: [0, 2000, 5000, 10000, 15000, 30000, 30000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000],
init(username) {
this.currentUsername = username;
this.setupEventListeners();
console.log('[SYNC] Initialized for:', username);
if (this.isOnline) {
this.triggerSync();
}
},
setupEventListeners() {
window.addEventListener('online', () => {
console.log('[SYNC] Network online');
this.isOnline = true;
this.triggerSync();
});
window.addEventListener('offline', () => {
console.log('[SYNC] Network offline');
this.isOnline = false;
this.isSyncing = false;
});
},
// Prevent page navigation during active upload
_setUploading(active) {
if (active) {
this._beforeUnloadHandler = (e) => {
e.preventDefault();
e.returnValue = 'Upload in progress - leaving will cancel it.';
return e.returnValue;
};
window.addEventListener('beforeunload', this._beforeUnloadHandler);
} else {
if (this._beforeUnloadHandler) {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
this._beforeUnloadHandler = null;
}
}
},
async triggerSync() {
if (!this.isOnline || this.isSyncing) {
console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing);
return;
}
console.log('[SYNC] Starting sync...');
this.isSyncing = true;
try {
await this.processQueue();
} catch (error) {
console.error('[SYNC] Error:', error);
} finally {
this.isSyncing = false;
this._setUploading(false);
}
},
async processQueue() {
const pendingPhotos = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
.sortBy('timestamp');
console.log('[SYNC] Found', pendingPhotos.length, 'photos to process');
if (pendingPhotos.length === 0) {
return;
}
for (const photo of pendingPhotos) {
if (!this.isOnline) {
console.log('[SYNC] Lost connection, stopping');
break;
}
// Skip photos that have exceeded max retries
const retryCount = photo.retryCount || 0;
if (retryCount >= this.MAX_RETRIES) {
console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount);
await Storage.updatePhoto(photo.id, { status: 'failed' });
continue;
}
// Backoff delay before retry attempts
if (retryCount > 0) {
const backoff = this.BACKOFF_DELAYS[Math.min(retryCount, this.BACKOFF_DELAYS.length - 1)];
console.log('[SYNC] Waiting', backoff + 'ms before retry', retryCount + 1);
await this.delay(backoff);
// Re-check connectivity after waiting
if (!this.isOnline) {
console.log('[SYNC] Lost connection during backoff, stopping');
break;
}
}
await this.uploadPhoto(photo);
}
},
async uploadPhoto(photo) {
const retryCount = photo.retryCount || 0;
try {
console.log('[SYNC] Uploading:', photo.filename,
'(' + (photo.blob ? (photo.blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')',
'attempt', retryCount + 1, 'of', this.MAX_RETRIES);
// Validate blob before attempting upload
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
}
await Storage.updatePhoto(photo.id, { status: 'uploading' });
// Prevent page navigation during upload
this._setUploading(true);
// Check for duplicates on retries (skip on first attempt to reduce latency)
if (retryCount > 0) {
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
const alreadyExists = await this.checkFileExists(fullPath);
if (alreadyExists) {
console.log('[SYNC] File already exists on server, skipping upload:', fullPath);
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
await this.pruneHistory();
this._setUploading(false);
return;
}
}
const uploadBlob = photo.blob;
const formData = new FormData();
formData.append('file', uploadBlob, photo.filename);
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
// Upload with timeout via AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.UPLOAD_TIMEOUT_MS);
let uploadResponse;
try {
uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
signal: controller.signal
});
} catch (fetchErr) {
clearTimeout(timeoutId);
if (fetchErr.name === 'AbortError') {
throw new Error('Upload timed out (' + (this.UPLOAD_TIMEOUT_MS / 1000) + 's)');
}
throw fetchErr;
}
clearTimeout(timeoutId);
if (!uploadResponse.ok) {
let errorMsg = 'Upload failed';
try {
const errData = await uploadResponse.json();
errorMsg = errData.error || errorMsg;
} catch (e) {
errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')';
}
throw new Error(errorMsg);
}
const uploadResult = await uploadResponse.json();
console.log('[SYNC] Upload successful:', uploadResult.path);
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
// Wait before verifying to allow server-side processing
await this.delay(this.VERIFY_DELAY_MS);
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path);
const verifyResponse = await fetch(verifyUrl);
if (!verifyResponse.ok) {
throw new Error('Verification failed');
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.exists) {
throw new Error('File not found on server');
}
console.log('[SYNC] Verified:', uploadResult.path);
// Keep record but strip blob to save storage
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
console.log('[SYNC] Upload complete:', photo.id);
// Prune old completed entries beyond 20
await this.pruneHistory();
// Clear navigation guard after successful upload
this._setUploading(false);
} catch (error) {
this._setUploading(false);
console.error('[SYNC] Upload failed:', error.message, '(attempt', retryCount + 1 + ')');
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount + 1,
lastError: error.message,
error: error.message
});
}
},
async pruneHistory() {
// Keep only the last 20 completed (verified/failed) entries per user
const completed = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(p => p.status === 'verified' || p.status === 'failed')
.sortBy('timestamp');
if (completed.length > 20) {
const toDelete = completed.slice(0, completed.length - 20);
for (const photo of toDelete) {
await Storage.deletePhoto(photo.id);
}
console.log('[SYNC] Pruned', toDelete.length, 'old history entries');
}
},
async checkFileExists(path) {
try {
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);
const response = await fetch(verifyUrl);
if (!response.ok) return false;
const result = await response.json();
return result.exists === true;
} catch (e) {
// If we can't check, assume it doesn't exist and proceed with upload
return false;
}
},
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
window.SyncEngine = Sync;
console.log('[SYNC] SyncEngine exported successfully');