From fffea9717c020d41bfbced84858b23d2ad1f3f95 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Mon, 26 Oct 2026 11:37:14 -0400 Subject: [PATCH] Treat dropped download connections as status 0 again Since HTTP.download() switched to fetch() in 10.0, a connection that dropped mid-download threw a TypeError ("Error in input stream"), so ZFS sync no longer retried interrupted S3 downloads and failed the file sync immediately. https://forums.zotero.org/discussion/134446/sync-error (cherry picked from commit d85ec125bec287b7dcf6f6c4dd28fdd579c7db1e) --- chrome/content/zotero/xpcom/http.js | 14 +++++++++++-- test/tests/httpTest.js | 32 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/chrome/content/zotero/xpcom/http.js b/chrome/content/zotero/xpcom/http.js index b12891eb9d..3ab0acf374 100644 --- a/chrome/content/zotero/xpcom/http.js +++ b/chrome/content/zotero/xpcom/http.js @@ -727,6 +727,14 @@ Zotero.HTTP = new function () { }, timeout); } + // Report a dropped connection as status 0, as request() does, so that callers and + // _retryOnServerError() handle it the same way + function connectionError(e) { + let msg = "HTTP GET " + ctx.dispURL + " failed: " + e; + Zotero.debug(msg, 1); + return new Zotero.HTTP.UnexpectedStatusException({ status: 0 }, url, msg); + } + let response; try { response = await fetch(url, fetchOptions); @@ -737,7 +745,7 @@ Zotero.HTTP = new function () { if (controller.signal.aborted) { throw new Zotero.HTTP.TimeoutException(timeout); } - throw e; + throw connectionError(e); } clearTimeout(connectTimerID); @@ -777,7 +785,9 @@ Zotero.HTTP = new function () { await IOUtils.write(path, new Uint8Array()); while (true) { resetInactivityTimer(); - let { done, value } = await reader.read(); + let { done, value } = await reader.read().catch((e) => { + throw connectionError(e); + }); if (done) break; await IOUtils.write(path, value, { mode: 'append' }); totalBytes += value.byteLength; diff --git a/test/tests/httpTest.js b/test/tests/httpTest.js index 4c0c00d0d0..b5b480cf24 100644 --- a/test/tests/httpTest.js +++ b/test/tests/httpTest.js @@ -424,6 +424,20 @@ describe("Zotero.HTTP", function () { } } ); + httpd.registerPathHandler( + '/download/truncated', + { + handle: function (request, response) { + response.seizePower(); + response.write("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Length: 10000\r\n" + + "\r\n" + + "x".repeat(100)); + response.finish(); + } + } + ); httpd.registerPathHandler( '/download/redirect-to-file', { @@ -564,6 +578,24 @@ describe("Zotero.HTTP", function () { assert.isFalse(await IOUtils.exists(dest)); }); + it("should throw UnexpectedStatusException with status 0 if the connection is refused", async function () { + let dest = PathUtils.join(tmpDir, "refused.bin"); + let e = await getPromiseError( + Zotero.HTTP.download("http://127.0.0.1:1/", dest) + ); + assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException); + assert.equal(e.status, 0); + }); + + it("should throw UnexpectedStatusException with status 0 if the connection drops during the download", async function () { + let dest = PathUtils.join(tmpDir, "truncated.bin"); + let e = await getPromiseError( + Zotero.HTTP.download(baseURL + "download/truncated", dest) + ); + assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException); + assert.equal(e.status, 0); + }); + it("should allow non-success status with successCodes", async function () { let dest = PathUtils.join(tmpDir, "404.bin"); let req = await Zotero.HTTP.download(