`importLibrary` loops forever (tab freeze / OOM) when the bootstrap callback receives an error payload

Abierto
#1,304 2 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
3/5
Tiempo estimado
1-2 días
Aptitud para principiantes
72/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
javascript, typescript
Área
frontend, web-dev

Línea de trabajo

Start in dist/index.js by tracing bootstrap(), namespace.ib, and namespace.importLibrary when the callback receives the shown error payload. Run the browser or Node reproduction from the linked gist, then verify that the error path settles without freezing or growing memory while the ?ok control still resolves.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

Environment

  • @googlemaps/js-api-loader 2.1.3 (also 2.0.2, as vendored by vue3-google-map 0.27.2)
  • Maps JS v=weekly (served 3.66.6c), Chrome 154, Electron 138, and Firefox 156.

Bug

maps.googleapis.com/maps/api/js intermittently responds 200 with a body that calls the callback with an error instead of loading the API:

google.maps.__ib__({ "error": { "code": 503, "message": "The service is currently unavailable.", "status": "UNAVAILABLE" } });

We observed it in CI browser runs and measured it with curl from a separate network: ~1–4% of uncached requests, with or without a key, often in bursts. The error response has no caching headers, unlike the normal bootstrap. The same finding has been reported to the Maps JS API issue tracker: https://issuetracker.google.com/issues/566284337.

In dist/index.js, bootstrap() sets:

namespace[PENDING_BOOTSTRAP_KEY] = resolve;          // ignores the callback argument
...
namespace[IMPORT_API_NAME] = (libraryName, ...args) =>
  libraries.add(libraryName) && triggerBootstrap().then(() => namespace[IMPORT_API_NAME](libraryName, ...args));

When the callback carries an error, triggerBootstrap() resolves anyway. The .then then calls namespace.importLibrary, which is still this function, because the API never loaded. That call gets back the same resolved promise and chains again: an infinite microtask loop. The page's main thread never yields, memory grows several hundred MB/s, and the renderer is killed at the V8 heap limit. The importLibrary() promise never settles, so callers can't catch it, and error reporting never sees it.

Reproduction

Runnable repro: https://gist.github.com/mattico/360de14756ae2c96d912ebe475301194. It has a single-file browser page (no install, no API key) and a Node + happy-dom script, each with a control mode. Both replace the network with a stub that answers the bootstrap <script> the way the real endpoint does. The browser version, for reference:

<!doctype html>
<!--
  Open this file in a browser (file:// is fine). No install, no API key.
    repro.html      the fake server returns the 503 error callback -> tab freezes
    repro.html?ok   control: the fake server returns the API -> resolves
-->
<meta charset="utf-8">
<title>js-api-loader error-callback repro</title>
<p id="status">Starting in 1s…</p>
<p>Heartbeat (stops when the main thread is stuck): <b id="beat">0</b></p>
<script type="module">
  import { setOptions, importLibrary } from "https://cdn.jsdelivr.net/npm/@googlemaps/[email protected]/+esm";

  const serverOk = new URLSearchParams(location.search).has("ok");
  const status = (text) => (document.getElementById("status").textContent = text);
  let beats = 0;
  setInterval(() => (document.getElementById("beat").textContent = ++beats), 100);

  // Stand in for maps.googleapis.com: answer the bootstrap <script> the loader
  // appends the way the real endpoint does, either with the API (control) or
  // with the error callback it intermittently serves.
  const append = document.head.append.bind(document.head);
  document.head.append = (node) => {
    if (!(node instanceof HTMLScriptElement && node.src.includes("/maps/api/js"))) return append(node);
    setTimeout(() => {
      if (serverOk) {
        google.maps.importLibrary = async (name) => ({ name }); // the real API replaces the stand-in
        google.maps.__ib__();
      } else {
        google.maps.__ib__({ error: { code: 503, message: "The service is currently unavailable.", status: "UNAVAILABLE" } });
      }
    }, 0);
  };

  setTimeout(() => {
    setOptions({ key: "YOUR_API_KEY", v: "weekly" });
    status(`importLibrary("maps") with the server ${serverOk ? "OK" : "returning the 503 error callback"}…` +
      (serverOk ? "" : " The heartbeat should stop now and the tab freeze."));
    importLibrary("maps").then(
      (lib) => status(`resolved: ${JSON.stringify(lib)}`),
      (err) => status(`rejected: ${err.message}`),
    );
  }, 1000);
</script>
  • repro.html: after 1 s the heartbeat stops and the tab freezes. In Chrome 154 that's 100% CPU with memory climbing until an out-of-memory crash.
  • repro.html?ok: control. The stub returns the API, and importLibrary("maps") resolves.

In Node (node --max-old-space-size=256 repro.mjs from the gist), the control resolves. With the error callback no heartbeat ever prints, and the process dies with Reached heap limit … JavaScript heap out of memory in under a second.

Against the real endpoint: load a page that calls importLibrary("maps") with the cache disabled and reload repeatedly. A few percent of loads freeze (assuming that maps.googleapis.com/maps/api/js continues to return errors at that rate).

Reproduced in desktop browsers by auto-reloading a page containing Google's documented snippet verbatim (the same loop this package vendors) (a unique throwaway URL parameter per load, so the HTTP cache never answers) until the error callback was served:

  • Chrome 154: the tab froze at 100% CPU with memory climbing until it crashed with an OOM.
  • Firefox 156: the web console logged an out-of-memory error; as far as we could tell the tab itself did not crash.

Suggested fix

  1. Treat an error payload as a failed load: namespace.__ib__ = (r) => r?.error ? fail(r.error) : resolve(). Reset bootstrapPromise as onerror does, and remove the failed script, so the next call can retry.
    This is safe: on success __ib__ is called with no arguments, and an invalid key or disallowed referrer still gets the normal bootstrap (those surface later via gm_authFailure, not __ib__). So an error argument only means the transient failure, and a retry can't loop on a configuration error. Retrying with a short backoff recovered 100% of affected loads in our testing (27 of 900 cold loads hit the error, and all loaded after one to three retries).
  2. Independently, never let the stand-in call itself after the bootstrap settles. If namespace.importLibrary is still the stand-in, reject, or wait on a macrotask with a bound, rather than recursing through microtasks. That turns any future mismatch between the callback and the API install into an error instead of a frozen tab.

We're running a patch that does both and can open a PR if that's useful.

Lenguaje dominante
TypeScript
Estrellas
451
Forks
75
Merge medio
2 min
PR fusionados (30 d)
31

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de googlemaps/js-api-loader

Todos los issues de googlemaps/js-api-loader

Issues similares

Más issues de TypeScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.