Set fetch status to 408 on timeout

This allows the caller of the API to detect the timeout occurred.

Fixes: #18658
diff --git a/site/source/docs/api_reference/fetch.rst b/site/source/docs/api_reference/fetch.rst
index 8279207..c45742a 100644
--- a/site/source/docs/api_reference/fetch.rst
+++ b/site/source/docs/api_reference/fetch.rst
@@ -259,12 +259,12 @@
 readyState, status and statusText, which give information about the HTTP loading
 state of the request.
 
-The emscripten_fetch_attr_t object has a timeoutMSecs field which allows
-specifying a timeout duration for the transfer. Additionally,
-emscripten_fetch_close() can be called at any time for asynchronous and waitable
-fetches to abort the download.
-The following example illustrates these fields
-and the onprogress handler.
+The ``emscripten_fetch_attr_t`` object has a ``timeoutMSecs`` field which allows
+specifying a timeout duration for the transfer.  If the operation times out the
+status will be set to 408 (Request Timeout) and the error handler will be
+invoked..  Additionally, ``emscripten_fetch_close()`` can be called at any time
+for asynchronous and waitable fetches to abort the download.
+The following example illustrates these fields and the onprogress handler.
 
 .. code-block:: cpp
 
diff --git a/src/Fetch.js b/src/Fetch.js
index 9110b6b..cc6d902 100644
--- a/src/Fetch.js
+++ b/src/Fetch.js
@@ -271,15 +271,21 @@
   var userNameStr = userName ? UTF8ToString(userName) : undefined;
   var passwordStr = password ? UTF8ToString(password) : undefined;
 
-  var xhr = new XMLHttpRequest();
-  xhr.withCredentials = withCredentials;
 #if FETCH_DEBUG
-  dbg('fetch: xhr.timeout: ' + xhr.timeout + ', xhr.withCredentials: ' + xhr.withCredentials);
+  dbg('fetch: timeoutMsecs: ' + timeoutMsecs + ', withCredentials: ' + withCredentials);
   dbg('fetch: xhr.open(requestMethod="' + requestMethod + '", url: "' + url_ +'", userName: ' + userNameStr + ', password: ' + passwordStr + ');');
 #endif
+  var xhr = new XMLHttpRequest();
+  xhr.withCredentials = withCredentials;
   xhr.open(requestMethod, url_, !fetchAttrSynchronous, userNameStr, passwordStr);
-  if (!fetchAttrSynchronous) xhr.timeout = timeoutMsecs; // XHR timeout field is only accessible in async XHRs, and must be set after .open() but before .send().
-  xhr.url_ = url_; // Save the url for debugging purposes (and for comparing to the responseURL that server side advertised)
+  // XHR timeout field is only accessible in async XHRs, and must be set after
+  // .open() but before .send().
+  if (!fetchAttrSynchronous) {
+    xhr.timeout = timeoutMsecs;
+  }
+  // Save the url for debugging purposes (and for comparing to the responseURL
+  // that server side advertised)
+  xhr.url_ = url_;
 #if ASSERTIONS
   assert(!fetchAttrStreamData, 'streaming uses moz-chunked-arraybuffer which is no longer supported; TODO: rewrite using fetch()');
 #endif
@@ -380,6 +386,8 @@
     if (!(id in Fetch.xhrs)) { 
       return;
     }
+    HEAPU16[fetch + {{{ C_STRUCTS.emscripten_fetch_t.status }}} >> 1] = 408; // Mimic XHR HTTP status code 408 "Request Timeout"
+
 #if FETCH_DEBUG
     dbg('fetch: xhr of URL "' + xhr.url_ + '" / responseURL "' + xhr.responseURL + '" timed out, readyState ' + xhr.readyState + ' and status ' + xhr.status);
 #endif
diff --git a/test/common.py b/test/common.py
index 95fec83..75acd5b 100644
--- a/test/common.py
+++ b/test/common.py
@@ -1515,6 +1515,12 @@
         self.send_response(200)
         self.send_header('Content-type', 'text/html')
         self.end_headers()
+      elif self.path == '/timeout.txt':
+        self.send_response(200)
+        self.send_header('Content-type', 'text/plain')
+        self.send_header('Content-length', '1024')
+        self.end_headers()
+        time.sleep(0.5)
       elif self.path == '/check':
         self.send_response(200)
         self.send_header('Content-type', 'text/html')
diff --git a/test/fetch/test_fetch_timeout.c b/test/fetch/test_fetch_timeout.c
new file mode 100644
index 0000000..fb3bf3a
--- /dev/null
+++ b/test/fetch/test_fetch_timeout.c
@@ -0,0 +1,39 @@
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <assert.h>
+#include <emscripten/fetch.h>
+#include <emscripten/eventloop.h>
+
+void readyStateChange(emscripten_fetch_t *fetch) {
+  printf("readyStateChange: %d\n", fetch->readyState);
+  if (fetch->readyState == 2) { // HEADERS_RECEIVED
+    printf("HEADERS_RECEIVED: numBytes=%llu\n", fetch->numBytes);
+  }
+}
+
+void onerror(emscripten_fetch_t *fetch) {
+  printf("onerror: %d\n", fetch->status);
+  assert(fetch->status == 408);
+  emscripten_fetch_close(fetch);
+}
+
+void success(emscripten_fetch_t *fetch) {
+  printf("unexpected success: numBytes=%llu\n", fetch->numBytes);
+  assert(false);
+  emscripten_fetch_close(fetch);
+}
+
+int main() {
+  emscripten_fetch_attr_t attr;
+  emscripten_fetch_attr_init(&attr);
+  strcpy(attr.requestMethod, "GET");
+  attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY | EMSCRIPTEN_FETCH_REPLACE;
+  attr.onsuccess = success;
+  attr.onerror = onerror;
+  attr.onreadystatechange = readyStateChange;
+  attr.timeoutMSecs = 100;
+  emscripten_fetch(&attr, "timeout.txt");
+  return 0;
+}
diff --git a/test/test_browser.py b/test/test_browser.py
index 8100ccb..fb98480 100644
--- a/test/test_browser.py
+++ b/test/test_browser.py
@@ -4698,6 +4698,10 @@
     shutil.copyfile(test_file('gears.png'), 'gears.png')
     self.btest_exit('fetch/idb_delete.cpp', args=['-sUSE_PTHREADS', '-sFETCH_DEBUG', '-sFETCH', '-sWASM=0', '-sPROXY_TO_PTHREAD'])
 
+  @also_with_wasm64
+  def test_fetch_timeout(self):
+    self.btest_exit('fetch/test_fetch_timeout.c', args=['-sFETCH_DEBUG', '-sFETCH'])
+
   @requires_threads
   def test_pthread_locale(self):
     self.emcc_args.append('-I' + path_from_root('system/lib/libc/musl/src/internal'))