diff --git a/chrome/content/zotero/HiddenBrowser.mjs b/chrome/content/zotero/HiddenBrowser.mjs index 57f265c2ff..7ba5e93b2d 100644 --- a/chrome/content/zotero/HiddenBrowser.mjs +++ b/chrome/content/zotero/HiddenBrowser.mjs @@ -266,12 +266,22 @@ export class HiddenBrowser { /** * @param {String[]} props - 'characterSet', 'title', 'bodyText', 'documentHTML', 'cookie', 'channelInfo' + * @param {Object} [options] + * @param {Number} [options.timeout=30000] - Time to wait for each property in milliseconds. + * The queries wait for the document to be ready, so a page that never finishes loading + * would otherwise hang the query forever. */ - async getPageData(props) { + async getPageData(props, { timeout = 30000 } = {}) { var actor = this.browsingContext.currentWindowGlobal.getActor("PageData"); var data = {}; for (let prop of props) { - data[prop] = await actor.sendQuery(prop); + let timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`Timed out getting '${prop}' from hidden browser`)), + timeout + ); + }); + data[prop] = await Promise.race([actor.sendQuery(prop), timeoutPromise]); } return data; } diff --git a/test/tests/HiddenBrowserTest.js b/test/tests/HiddenBrowserTest.js index 5ef5d1a500..4b21d0ee8a 100644 --- a/test/tests/HiddenBrowserTest.js +++ b/test/tests/HiddenBrowserTest.js @@ -182,6 +182,55 @@ describe("HiddenBrowser", function() { }); }); + describe("#getPageData() timeout", function () { + var httpd; + var port = 16216; + var heldResponses = []; + + before(function () { + httpd = new HttpServer(); + httpd.start(port); + // Same-origin page whose parser-blocking script never finishes loading, so the + // document never reaches 'interactive' + httpd.registerPathHandler('/hangpage', { + handle(request, response) { + response.setHeader('Content-Type', 'text/html', false); + response.setStatusLine(null, 200, 'OK'); + response.write('' + + 'x'); + } + }); + httpd.registerPathHandler('/hang.js', { + handle(request, response) { + response.processAsync(); + response.setStatusLine(null, 200, 'OK'); + response.setHeader('Content-Type', 'text/javascript', false); + heldResponses.push(response); + } + }); + }); + + after(async function () { + for (let r of heldResponses) { + try { r.finish(); } catch (e) {} + } + await new Promise(resolve => httpd.stop(resolve)); + }); + + it("getPageData should reject instead of hanging on a never-ready document", async function () { + this.timeout(20000); + let browser = new HiddenBrowser(); + await browser.load(`http://127.0.0.1:${port}/hangpage`); + let start = Date.now(); + let e = await getPromiseError(browser.getPageData(['bodyText'], { timeout: 2000 })); + let elapsed = Date.now() - start; + browser.destroy(); + assert.ok(e, 'getPageData rejected'); + assert.include(e.message, 'Timed out'); + assert.isBelow(elapsed, 10000, 'rejected around the timeout, not after a long hang'); + }); + }); + describe("#getDocument()", function () { it("should provide a Document object", async function () { let path = OS.Path.join(getTestDataDirectory().path, 'test-hidden.html');