|
26 | 26 | let uploadFinished = $state(sessionData?.state?.upload_finished || false); |
27 | 27 | let errorMsg = $state(''); |
28 | 28 |
|
| 29 | + // Track chunks that are mid-flight (HTTP GET in progress) and chunks |
| 30 | + // that gave up after all retries. Used by the strict completion check |
| 31 | + // to avoid silently assembling a file with holes. |
| 32 | + let pendingFetches = $state(new Set()); |
| 33 | + let failedChunks = $state(new Set()); |
| 34 | +
|
29 | 35 | // --- File System Access API (disk-based storage) --- |
30 | 36 | const hasFSAccess = typeof globalThis.showSaveFilePicker === 'function'; |
31 | 37 | let fileHandle = $state(null); |
|
173 | 179 | } |
174 | 180 | } |
175 | 181 |
|
| 182 | + function markFetchDone(index) { |
| 183 | + pendingFetches.delete(index); |
| 184 | + pendingFetches = new Set(pendingFetches); |
| 185 | + } |
| 186 | +
|
| 187 | + function markFetchFailed(index, reason) { |
| 188 | + console.warn(`[pip] chunk ${index} failed: ${reason}`); |
| 189 | + failedChunks.add(index); |
| 190 | + failedChunks = new Set(failedChunks); |
| 191 | + markFetchDone(index); |
| 192 | + } |
| 193 | +
|
176 | 194 | async function fetchAndDecryptChunk(index, retries = 3) { |
177 | 195 | // Wait for save file dialog before downloading |
178 | 196 | if (writableReady) await writableReady; |
179 | 197 | if (writable ? writtenChunks.has(index) : chunks.has(index)) return; |
| 198 | + if (pendingFetches.has(index)) return; // another call is already handling it |
| 199 | +
|
| 200 | + pendingFetches.add(index); |
| 201 | + pendingFetches = new Set(pendingFetches); |
180 | 202 |
|
181 | 203 | for (let attempt = 0; attempt < retries; attempt++) { |
182 | 204 | try { |
|
198 | 220 | sendAction('confirm_chunk', { index }); |
199 | 221 | } |
200 | 222 | chunksConfirmed++; |
| 223 | + markFetchDone(index); |
| 224 | + return; |
| 225 | + } |
| 226 | + if (result.status === 404) { |
| 227 | + // Chunk removed on the server before we could fetch it. |
| 228 | + // No retry — keep as failure so the strict completion |
| 229 | + // check can surface the gap instead of a silent skip. |
| 230 | + markFetchFailed(index, `HTTP 404 (chunk gone from server)`); |
201 | 231 | return; |
202 | 232 | } |
203 | | - if (result.status === 404) return; // chunk removed, skip |
| 233 | + console.warn(`[pip] chunk ${index} attempt ${attempt + 1} failed: HTTP ${result.status} ${result.error || ''}`); |
204 | 234 | await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); |
205 | 235 | } catch (err) { |
| 236 | + console.warn(`[pip] chunk ${index} attempt ${attempt + 1} exception: ${err.message}`); |
206 | 237 | if (attempt === retries - 1) { |
207 | 238 | errorMsg = `Chunk ${index}: ${err.message}`; |
208 | 239 | } |
209 | 240 | await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); |
210 | 241 | } |
211 | 242 | } |
| 243 | +
|
| 244 | + markFetchFailed(index, `exhausted ${retries} retries`); |
212 | 245 | } |
213 | 246 |
|
214 | 247 | async function onNewChunk(msg) { |
|
255 | 288 |
|
256 | 289 | function checkIfDone() { |
257 | 290 | if (completeHandled) return; |
258 | | - const hasData = writable ? writtenChunks.size > 0 : chunks.size > 0; |
259 | | - if (uploadFinished && hasData && noMorePendingChunks |
260 | | - && chunksAcknowledged >= chunksConfirmed) { |
| 291 | + if (!uploadFinished) return; |
| 292 | + if (pendingFetches.size > 0) return; |
| 293 | + if (chunksAcknowledged < chunksConfirmed) return; |
| 294 | +
|
| 295 | + const have = writable ? writtenChunks.size : chunks.size; |
| 296 | + const expected = highestKnownChunk; |
| 297 | +
|
| 298 | + if (expected === 0) return; // nothing uploaded yet |
| 299 | + if (have + failedChunks.size < expected) return; // still waiting for new_chunk events or fetches |
| 300 | +
|
| 301 | + if (failedChunks.size === 0 && have >= expected) { |
261 | 302 | finishWithBlob(); |
| 303 | + return; |
262 | 304 | } |
| 305 | +
|
| 306 | + // All indices accounted for, but some permanently failed. Refuse to |
| 307 | + // silently assemble a file with holes — report the gap instead. |
| 308 | + completeHandled = true; |
| 309 | + const missing = [...failedChunks].sort((a, b) => a - b); |
| 310 | + console.error(`[pip] transfer incomplete, ${missing.length} chunk(s) missing:`, missing); |
| 311 | + errorMsg = `Incomplete: ${missing.length} chunk(s) missing`; |
| 312 | + if (writable) { writable.close().catch(() => {}); writable = null; } |
| 313 | + oncomplete?.({ status: 'incomplete', missing }); |
263 | 314 | } |
264 | 315 |
|
265 | 316 | // Track if we're still expecting chunks |
|
0 commit comments