cljs: settling a pending promise runs its callbacks inside resolve(), so a 2000-link then chain overflows the stack (Promises/A+ 2.2.4)

Aperta
#175 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
54/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
clojure
Ambito
backend, testing

Direzione di ricerca

Start in src/promesa/impl/promise.js and trace how transition drains callbacks compared with how process schedules callbacks for an already-settled promise. Reproduce the pending-promise behavior using the provided Node script, then check the project’s promise tests for coverage of callback ordering and long chains. Done when resolving a pending promise defers callbacks as required by Promises/A+ 2.2.4 and long chains settle without overflowing the stack.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

In the ClojureScript implementation (src/promesa/impl/promise.js), settling a pending promise runs its queued callbacks synchronously, inside resolve() / reject(): transition calls processNextTick(p) directly. Adding a callback to an already-settled promise is deferred (process goes through nextTick), so only the "pending, then settled" path is affected.

Promises/A+ 2.2.4 says onFulfilled or onRejected must not be called until the execution context stack contains only platform code, and native Promise behaves that way.

Because each link settles inside the frame of the link before it:

  • a chain of 2000 callbacks on a pending promise overflows the stack when the promise is resolved; 1000 still settles (Node 24.14.0, default stack size);
  • the RangeError is thrown out of resolve() into the caller, and the rest of the chain stays pending;
  • code after resolve() runs after callbacks that A+ would run later.

From ClojureScript this is (p/deferred), a chain of p/then, then (p/resolve d v). Those call the same deferred, .then and .resolve as the script below.

Reproduction

Plain JS against promesa/impl/promise.js from the 12.0.1 jar (identical to master at 9b38ac3), loaded with a minimal goog shim:

// node repro.js path/to/promesa/impl/promise.js
const fs = require("fs"), vm = require("vm");
let uid = 0;
globalThis.goog = {
  global: globalThis,
  provide(ns) { let o = globalThis; for (const k of ns.split(".")) o = (o[k] ??= {}); },
  scope(f) { f.call(globalThis); },
  isObject(v) { const t = typeof v; return (t === "object" && v !== null) || t === "function"; },
  getUid(o) { return (o.__uid ??= ++uid); },
};
vm.runInThisContext(fs.readFileSync(process.argv[2], "utf8"));
const P = promesa.impl.promise;

const promesaPending = () => { const d = P.deferred(); return [d, (v) => d.resolve(v)]; };
const nativePending = () => { let r; const p = new Promise((res) => (r = res)); return [p, r]; };
const tick = () => new Promise((r) => setTimeout(r, 50));

// 1. Order: does a callback run inside resolve()?
async function order(pending) {
  const [p, resolve] = pending(), log = [];
  p.then(() => log.push("callback"));
  resolve(1);
  log.push("resolve() returned");
  await tick();
  return JSON.stringify(log);
}

// 2. Depth: N callbacks chained on a pending promise, then resolved.
async function chain(N, pending) {
  const [head, resolve] = pending();
  let tail = head, outcome = "still pending";
  for (let i = 0; i < N; i++) tail = tail.then((x) => x + 1);
  tail.then((v) => (outcome = `settled ${v}`), (e) => (outcome = `rejected: ${e.message}`));
  let threw = "";
  try { resolve(0); } catch (e) { threw = `resolve() threw ${e.name}, `; }
  await tick();
  return threw + outcome;
}

(async () => {
  console.log(`order    promesa: ${await order(promesaPending)}   native: ${await order(nativePending)}`);
  for (const N of [1000, 2000, 20000])
    console.log(`N=${N}  promesa: ${await chain(N, promesaPending)}   native: ${await chain(N, nativePending)}`);
})();

node repro.js promesa/impl/promise.js on Node 24.14.0:

order    promesa: ["callback","resolve() returned"]   native: ["resolve() returned","callback"]
N=1000  promesa: settled 1000   native: settled 1000
N=2000  promesa: resolve() threw RangeError, still pending   native: settled 2000
N=20000  promesa: resolve() threw RangeError, still pending   native: settled 20000
A possible fix

Defer in transition the way process already does:

     p[STATE] = state;
     p[VALUE] = value;
 
-    return processNextTick(p);
+    nextTick(processNextTick, p);
   }

With only that change, the same script prints ["resolve() returned","callback"] and settles all three chains. It adds a microtask per settled promise, and I haven't run promesa's own test suite with it. If running callbacks in place is intended (then's docstring says they run "in the completion thread by default"), deferring only past some nesting depth would keep that and still bound the stack.

We hit this as RangeErrors in a cljc project on Node, and now avoid long chains in our own code. #172 is a separate bug in the same drain loop.

— commented by mydeck bot (mydeck:d0dea098)

Lingua principale
Clojure
Stelle
547
Fork
66
Metriche di merge delle PR
Nessuna PR unita negli ultimi 30g

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di funcool/promesa

Tutte le issue di funcool/promesa

Issue simili

Altre issue su Clojure

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.