fix: (regression 0.57.9) resume-upload broken on firefox and safari

Massimo Melina committed Aug 7, 2025 at 14:57 UTC ee76fb434bd3071acb32b0ce18aafb17d4cec3e4
2 files changed +21 -5
frontend/src/uploadQueue.ts
+16 -3
@@ -101,14 +101,26 @@ export async function startUpload(toUpload: ToUpload, to: string, resume=0) {
101 const uriPath = to + pathEncode(toUpload.path)
102 let stopLooping = false // allow callbacks to stop the loop
103 do { // at least one iteration, even for empty files
104 - const req = currentReq = new XMLHttpRequest()
104 + let req = currentReq = new XMLHttpRequest()
105 const requestIsOver = pendingPromise()
106 stuckSince = Date.now()
107 // beware of 'abort' event: it isn't triggered if connection isn't established yet
108 - req.onloadend = async () => {
108 + req.onloadend = async () => { // loadend = fired for both success and error. Safari doesn't always fire this on disconnections, leaving readyState = 3. The problem is mitigated by the abort-when-stuck mechanism above.
109 try {
110 currentReq = undefined
111 strictResume = true // reset at each request
112 + if (!userAborted && !req.status) { // we were disconnected, possibly with a status that we couldn't read, so we give it another chance without the body
113 + /* Browsers are unreliable when it comes to read the status before the request is fully sent.
114 + - chrome139 works for most of the cases. It seems it doesn't when the disconnection happens a bit late (like for file system errors).
115 + - safari18 is inconsistent and it seems random.
116 + - firefox141 basically never works.
117 + */
118 + req = new XMLHttpRequest()
119 + req.open('PUT', uriPath + queryString + '&simulating=' + body.size, false) // not async this time
120 + try { req.send() }
121 + catch(e) { console.log(e) }
122 + await wait(500) // on a fast connection (localhost) firefox is aborting next request (without the delay), reporting NS_BINDING_ABORTED. Still don't know why
123 + }
124 const { status } = req
125 if (status === HTTP_RANGE_NOT_SATISFIABLE)
126 return stopLooping = true
@@ -171,7 +183,8 @@ export async function startUpload(toUpload: ToUpload, to: string, resume=0) {
183 existing: with_(state.uploadOnExisting, x => x !== 'rename' ? x : undefined), // rename is the default
184 })
185 req.open('PUT', uriPath + queryString, true)
174 - req.send(toUpload.file.slice(resume, splitSize ? resume + splitSize : undefined))
186 + const body = toUpload.file.slice(resume, splitSize ? resume + splitSize : undefined)
187 + req.send(body)
188 await requestIsOver
189 } while (!stopLooping)
190
src/upload.ts
+5 -2
@@ -71,7 +71,8 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
71 const dir = dirname(fullPath)
72 // enforce minAvailableMb
73 const min = minAvailableMb.get() * (1 << 20)
74 - const contentLength = Number(ctx.headers["content-length"])
74 + const {simulate} = ctx.query // `simulate` is used to get the same error but with an empty body, so that the request is processed quickly
75 + const contentLength = Number(simulate || ctx.headers["content-length"])
76 const isPartial = ctx.query.partial !== undefined // while the presence of "partial" conveys the upload is split...
77 const stillToWrite = Math.max(contentLength, Number(ctx.query.partial) || 0) // ...the number is used to tell how much space we need (fullSize - offset)
78 if (isNaN(stillToWrite)) {
@@ -155,6 +156,8 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
156 try {
157 ctx.state.uploadSize = bytesGot() // in case content-length is not specified
158 await new Promise(res => fileStream.close(res)) // this only seems necessary on Windows
159 + if (simulate)
160 + return rm(tempName).catch(() => {})
161 if (ctx.isAborted()) { // in the very unlikely case the connection is interrupted between last-byte and here, we still consider it unfinished, as the client had no way to know, and will resume, but it would get an error if we finish the process
162 const sec = deleteUnfinishedUploadsAfter.get()
163 return _.isNumber(sec) && delayedDelete(tempName, sec)
@@ -243,7 +246,7 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
246 expires: Date.now() + secs * 1000,
247 timeout: setTimeout(() => {
248 delete waitingToBeDeleted[path]
246 - void rm(path)
249 + rm(path).catch(() => {})
250 }, secs * 1000)
251 }
252 }