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>
This commit is contained in:
@@ -7,8 +7,11 @@ const Sync = {
|
||||
currentUsername: null,
|
||||
isOnline: navigator.onLine,
|
||||
isSyncing: false,
|
||||
MAX_RETRIES: 5,
|
||||
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;
|
||||
@@ -96,6 +99,19 @@ const Sync = {
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -104,7 +120,9 @@ const Sync = {
|
||||
const retryCount = photo.retryCount || 0;
|
||||
|
||||
try {
|
||||
console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')');
|
||||
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) {
|
||||
@@ -116,7 +134,7 @@ const Sync = {
|
||||
// Prevent page navigation during upload
|
||||
this._setUploading(true);
|
||||
|
||||
// Only check for duplicates on retries (skip on first attempt to reduce latency)
|
||||
// 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);
|
||||
@@ -134,23 +152,32 @@ const Sync = {
|
||||
}
|
||||
}
|
||||
|
||||
// Resize if over 10MB
|
||||
let uploadBlob = photo.blob;
|
||||
if (uploadBlob.size > 10 * 1024 * 1024) {
|
||||
console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...');
|
||||
uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024);
|
||||
console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB');
|
||||
}
|
||||
const uploadBlob = photo.blob;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadBlob, photo.filename);
|
||||
|
||||
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
// 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';
|
||||
@@ -203,7 +230,7 @@ const Sync = {
|
||||
|
||||
} catch (error) {
|
||||
this._setUploading(false);
|
||||
console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')');
|
||||
console.error('[SYNC] Upload failed:', error.message, '(attempt', retryCount + 1 + ')');
|
||||
await Storage.updatePhoto(photo.id, {
|
||||
status: 'pending',
|
||||
retryCount: retryCount + 1,
|
||||
@@ -213,55 +240,6 @@ const Sync = {
|
||||
}
|
||||
},
|
||||
|
||||
async resizeImage(blob, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let quality = 0.85;
|
||||
let scale = 1.0;
|
||||
|
||||
const attempt = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((result) => {
|
||||
if (!result) {
|
||||
reject(new Error('Failed to resize image'));
|
||||
return;
|
||||
}
|
||||
if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) {
|
||||
resolve(result);
|
||||
} else {
|
||||
// Reduce quality first, then scale down
|
||||
if (quality > 0.5) {
|
||||
quality -= 0.1;
|
||||
} else {
|
||||
scale *= 0.8;
|
||||
quality = 0.7;
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
|
||||
attempt();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image for resize'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
|
||||
async pruneHistory() {
|
||||
// Keep only the last 20 completed (verified/failed) entries per user
|
||||
const completed = await Storage.db.photos
|
||||
|
||||
Reference in New Issue
Block a user