From 1c0c6e1f19a0fa0762bbef3eb454a55b05bb5674 Mon Sep 17 00:00:00 2001 From: Roma Sosnovsky Date: Tue, 22 Sep 2026 13:54:09 +0300 Subject: [PATCH 1/3] #6299 Use native browser APIs for timeout promises --- extension/js/common/api/shared/api.ts | 138 +++++++------------------- extension/manifest.json | 2 +- 2 files changed, 38 insertions(+), 102 deletions(-) diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index 2e490f3e9c1..ed690ba477a 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -5,7 +5,7 @@ import { Attachment } from '../../core/attachment.js'; import { Buf } from '../../core/buf.js'; import { CatchHelper } from '../../platform/catch-helper.js'; -import { Dict, EmailParts, HTTP_STATUS_TEXTS, Url, UrlParams, Value } from '../../core/common.js'; +import { Dict, EmailParts, HTTP_STATUS_TEXTS, Url, UrlParams } from '../../core/common.js'; import { secureRandomBytes } from '../../platform/util.js'; import { ApiErr, AjaxErr } from './api-error.js'; import { Serializable } from '../../platform/store/abstract-store.js'; @@ -52,7 +52,7 @@ export type Ajax = { url: string; headers?: AjaxHeaders; progress?: ProgressCbs; - timeout?: number; // todo: implement + timeout?: number; stack: string; } & AjaxParams; type RawAjaxErr = { @@ -68,23 +68,6 @@ export type ProgressCbs = { upload?: ProgressCb | null; download?: ProgressCb | type FetchResult = T extends undefined ? undefined : T extends 'text' ? string : RT; -export const supportsRequestStreams = (() => { - // temporary disabled because of https://github.com/FlowCrypt/flowcrypt-browser/issues/5612 - return false; - // let duplexAccessed = false; - - // const hasContentType = new Request('https://localhost', { - // body: new ReadableStream(), - // method: 'POST', - // get duplex() { - // duplexAccessed = true; - // return 'half'; - // }, - // } as RequestInit).headers.has('Content-Type'); - - // return duplexAccessed && !hasContentType; -})(); - export class Api { public static async download(url: string, progress?: ProgressCb, timeout?: number): Promise { return await new Promise((resolve, reject) => { @@ -125,17 +108,7 @@ export class Api { } Api.throwIfApiPathTraversalAttempted(req.url); const headersInit: [string, string][] = req.headers ? Object.entries(req.headers) : []; - // capitalize? .map(([key, value]) => { return [Str.capitalize(key), value]; }) - const newTimeoutPromise = (): Promise => { - return new Promise((_resolve, reject) => { - /* error-handled */ setTimeout(() => { - reject(AjaxErr.fromXhr({ readyState, status: -1, statusText: 'timeout' }, reqContext)); // Reject the promise with a timeout error - }, req.timeout ?? 20000); - }); - }; let body: BodyInit | undefined; - let duplex: 'half' | undefined; - let uploadPromise: () => void | Promise = Value.noop; let url: string; if (req.method === 'GET' || req.method === 'DELETE') { if (typeof req.data === 'undefined') { @@ -151,24 +124,7 @@ export class Api { body = JSON.stringify(req.data); headersInit.push(['Content-Type', 'application/json; charset=UTF-8']); } else if (req.dataType === 'TEXT') { - if (supportsRequestStreams && req.progress?.upload) { - const upload = req.progress?.upload; - const transformStream = new TransformStream(); - uploadPromise = async () => { - const transformWriter = transformStream.writable.getWriter(); - for (let offset = 0; offset < req.data.length; ) { - const chunkSize = Math.min(1000, req.data.length - offset); - await Promise.race([transformWriter.write(Buf.fromRawBytesStr(req.data, offset, offset + chunkSize)), newTimeoutPromise()]); - upload((offset / req.data.length) * 100, offset, req.data.length); - offset += chunkSize; - } - await Promise.race([transformWriter.close(), newTimeoutPromise()]); - }; - body = transformStream.readable; - duplex = 'half'; // activate upload progress mode - } else { - body = req.data; - } + body = req.data; if (typeof req.contentType === 'string') { headersInit.push(['Content-Type', req.contentType]); } @@ -178,21 +134,18 @@ export class Api { } } } - const abortController = new AbortController(); - const requestInit: RequestInit & { duplex?: 'half' } = { + const requestInit: RequestInit = { method: req.method, headers: headersInit, body, - duplex, mode: 'cors', - signal: abortController.signal, + signal: AbortSignal.timeout(req.timeout ?? 20000), }; let readyState = 1; // OPENED const reqContext = { url: req.url, method: req.method, data: body, stack: req.stack }; + const isTimeoutError = (e: unknown): boolean => e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError'); try { - const fetchPromise = fetch(url, requestInit); - await uploadPromise(); - const response = await Promise.race([fetchPromise, newTimeoutPromise()]); + const response = await fetch(url, requestInit); if (!response.ok) { let responseText: string | undefined; readyState = 2; // HEADERS_RECEIVED @@ -200,7 +153,10 @@ export class Api { readyState = 3; // LOADING responseText = await response.text(); readyState = 4; // DONE - } catch { + } catch (e) { + if (isTimeoutError(e)) { + throw e; + } // continue processing without reponseText } throw AjaxErr.fromXhr( @@ -213,68 +169,50 @@ export class Api { reqContext ); } - const transformResponseWithProgressAndTimeout = () => { + const transformResponseWithProgress = () => { if (req.progress && response.body) { const contentLength = response.headers.get('content-length'); // real content length is approximately 140% of content-length header value const total = contentLength ? parseInt(contentLength) * 1.4 : 0; - const transformStream = new TransformStream(); - const transformWriter = transformStream.writable.getWriter(); - const reader = response.body.getReader(); const downloadProgress = req.progress.download; - return { - pipe: async () => { - let downloadedBytes = 0; - while (true) { - const { done, value } = await Promise.race([reader.read(), newTimeoutPromise()]); - if (done) { - await transformWriter.close(); - return; - } - downloadedBytes += value.length; + const expectedTransferSize = req.progress.expectedTransferSize; + const operationId = req.progress.operationId; + let downloadedBytes = 0; + const bodyWithProgress = response.body.pipeThrough( + new TransformStream({ + transform: (chunk, controller) => { + downloadedBytes += chunk.length; if (downloadProgress) { downloadProgress(undefined, downloadedBytes, total); - } else if (req.progress?.expectedTransferSize && req.progress.operationId) { + } else if (expectedTransferSize && operationId) { BrowserMsg.send.ajaxProgress('broadcast', { percent: undefined, loaded: downloadedBytes, total, - expectedTransferSize: req.progress.expectedTransferSize, - operationId: req.progress.operationId, + expectedTransferSize, + operationId, }); } - await transformWriter.write(value); - } - }, - response: new Response(transformStream.readable, { - status: response.status, - headers: response.headers, - }), - }; + controller.enqueue(chunk); + }, + }) + ); + return new Response(bodyWithProgress, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); } else { - return { - response, - pipe: async () => { - /* no-op */ - }, - }; // original response + return response; } }; if (resFmt === 'text') { - const transformed = transformResponseWithProgressAndTimeout(); - return (await Promise.all([transformed.response.text(), transformed.pipe()]))[0] as FetchResult; + return (await transformResponseWithProgress().text()) as FetchResult; } else if (resFmt === 'json') { try { - const transformed = transformResponseWithProgressAndTimeout(); - return ( - await Promise.all([ - transformed.response.text().then(text => { - return (text ? JSON.parse(text) : {}) as T; // Handle empty response body - }), - transformed.pipe(), - ]) - )[0] as FetchResult; + const text = await transformResponseWithProgress().text(); + return (text ? JSON.parse(text) : {}) as FetchResult; // Handle empty response body } catch (e) { // handle empty response https://github.com/FlowCrypt/flowcrypt-browser/issues/5601 if (e instanceof SyntaxError && (e.message === 'Unexpected end of JSON input' || e.message.startsWith('JSON.parse: unexpected end of data'))) { @@ -287,8 +225,8 @@ export class Api { } } catch (e) { if (e instanceof Error) { - if (e.name === 'AbortError') { - // we assume there was a timeout + if (isTimeoutError(e)) { + // The request's only abort signal is its timeout signal. throw AjaxErr.fromXhr({ readyState, status: -1, statusText: 'timeout' }, reqContext); } if (e.name === 'TypeError' && ApiErr.isNetErr(e)) { @@ -298,8 +236,6 @@ export class Api { throw e; } throw new Error(`Unknown fetch error (${String(e)}) type when calling ${req.url}`); - } finally { - abortController.abort(); } } diff --git a/extension/manifest.json b/extension/manifest.json index 8e73ad9818a..cba9c90b9ea 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -90,7 +90,7 @@ "matches": ["https://mail.google.com/*", "https://accounts.google.com/*", "https://www.google.com/*"] } ], - "minimum_chrome_version": "96", + "minimum_chrome_version": "106", "content_security_policy": { "extension_pages": "script-src 'self'; default-src 'self'; frame-ancestors 'self' https://mail.google.com; img-src 'self' https://* data: blob:; frame-src 'self' blob:; worker-src 'self'; form-action 'none'; media-src 'none'; font-src 'none'; manifest-src 'none'; object-src 'none'; base-uri 'self'; connect-src 'self' *; style-src 'self' 'unsafe-inline';" } From b0395512c0e24ef198ec398b20e2d63c04d78c1f Mon Sep 17 00:00:00 2001 From: Roma Sosnovsky Date: Wed, 23 Sep 2026 14:24:03 +0300 Subject: [PATCH 2/3] use per-chunk timeout for downloads --- extension/js/common/api/shared/api.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index ed690ba477a..c52e8b7e627 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -5,6 +5,7 @@ import { Attachment } from '../../core/attachment.js'; import { Buf } from '../../core/buf.js'; import { CatchHelper } from '../../platform/catch-helper.js'; +import { Catch } from '../../platform/catch.js'; import { Dict, EmailParts, HTTP_STATUS_TEXTS, Url, UrlParams } from '../../core/common.js'; import { secureRandomBytes } from '../../platform/util.js'; import { ApiErr, AjaxErr } from './api-error.js'; @@ -134,12 +135,19 @@ export class Api { } } } + const abortController = new AbortController(); + const timeout = req.timeout ?? 20000; + let timeoutId = Catch.setHandledTimeout(() => abortController.abort(), timeout); + const restartTimeout = () => { + clearTimeout(timeoutId); + timeoutId = Catch.setHandledTimeout(() => abortController.abort(), timeout); + }; const requestInit: RequestInit = { method: req.method, headers: headersInit, body, mode: 'cors', - signal: AbortSignal.timeout(req.timeout ?? 20000), + signal: abortController.signal, }; let readyState = 1; // OPENED const reqContext = { url: req.url, method: req.method, data: body, stack: req.stack }; @@ -171,6 +179,7 @@ export class Api { } const transformResponseWithProgress = () => { if (req.progress && response.body) { + restartTimeout(); const contentLength = response.headers.get('content-length'); // real content length is approximately 140% of content-length header value const total = contentLength ? parseInt(contentLength) * 1.4 : 0; @@ -181,6 +190,7 @@ export class Api { const bodyWithProgress = response.body.pipeThrough( new TransformStream({ transform: (chunk, controller) => { + restartTimeout(); downloadedBytes += chunk.length; if (downloadProgress) { downloadProgress(undefined, downloadedBytes, total); @@ -195,6 +205,7 @@ export class Api { } controller.enqueue(chunk); }, + flush: () => clearTimeout(timeoutId), }) ); return new Response(bodyWithProgress, { @@ -236,6 +247,8 @@ export class Api { throw e; } throw new Error(`Unknown fetch error (${String(e)}) type when calling ${req.url}`); + } finally { + clearTimeout(timeoutId); } } From b077dd1aebe93bf26d061906e0525f399a61b00f Mon Sep 17 00:00:00 2001 From: Roma Sosnovsky Date: Wed, 23 Sep 2026 15:04:20 +0300 Subject: [PATCH 3/3] fix test --- extension/js/common/api/shared/api.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index c52e8b7e627..6b77853e798 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -5,7 +5,6 @@ import { Attachment } from '../../core/attachment.js'; import { Buf } from '../../core/buf.js'; import { CatchHelper } from '../../platform/catch-helper.js'; -import { Catch } from '../../platform/catch.js'; import { Dict, EmailParts, HTTP_STATUS_TEXTS, Url, UrlParams } from '../../core/common.js'; import { secureRandomBytes } from '../../platform/util.js'; import { ApiErr, AjaxErr } from './api-error.js'; @@ -137,10 +136,10 @@ export class Api { } const abortController = new AbortController(); const timeout = req.timeout ?? 20000; - let timeoutId = Catch.setHandledTimeout(() => abortController.abort(), timeout); + let timeoutId = setTimeout(() => abortController.abort(), timeout); // error-handled: fetch and response errors are handled below const restartTimeout = () => { clearTimeout(timeoutId); - timeoutId = Catch.setHandledTimeout(() => abortController.abort(), timeout); + timeoutId = setTimeout(() => abortController.abort(), timeout); // error-handled: fetch and response errors are handled below }; const requestInit: RequestInit = { method: req.method,