Skip to content

Commit f82cc27

Browse files
authored
fix(core): handle async op promise id wraparound (#35126)
The async op promise id is incremented with `(id + 1) & 0xffffffff`, which coerces to a signed int32 in JS, so after 2^31 dispatched ops the counter goes negative. From that point the `promiseId < nextPromiseId - RING_SIZE` check in `__resolvePromise()` misfires for ops that started before the wraparound and were evicted from the promise ring to the overflow map while still pending: the lookup goes to the ring instead of the map and the process crashes with "Missing promise in ring @ <id>" (or could resolve a wrong promise). This is what killed long-running servers after billions of ops. The new regression test reproduces that exact error against the old code. The counter now wraps back to 0 instead of going negative (promise ids must stay non-negative i32s for the Rust side), and each ring entry stores its own promise id, so ring-vs-map membership is decided by an identity check instead of arithmetic on the global counter, which is wraparound safe. Fixes #22759
1 parent c5fee2e commit f82cc27

4 files changed

Lines changed: 94 additions & 43 deletions

File tree

libs/core/00_infra.js

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
URIError,
3030
} = window.__bootstrap.primordials;
3131

32+
// Promise ids must be non-negative and fit in an i32 (Rust's `PromiseId`);
33+
// the increment in the async op stubs wraps back to 0 past 2^31 - 1.
3234
let nextPromiseId = 0;
3335
const promiseMap = new SafeMap();
3436
const RING_SIZE = 4 * 1024;
@@ -41,6 +43,11 @@
4143
let isLeakTracingEnabled = false;
4244
let submitLeakTrace;
4345

46+
// Exposed for testing promise id wraparound behavior.
47+
function __setNextPromiseId(promiseId) {
48+
nextPromiseId = promiseId;
49+
}
50+
4451
function __setLeakTracingEnabled(enabled) {
4552
isLeakTracingEnabled = enabled;
4653
}
@@ -140,12 +147,17 @@
140147
// Move old promise from ring to map
141148
const oldPromise = promiseRing[idx];
142149
if (oldPromise !== NO_PROMISE) {
143-
const oldPromiseId = promiseId - RING_SIZE;
144-
MapPrototypeSet(promiseMap, oldPromiseId, oldPromise);
150+
// Keyed on the entry's own id rather than `promiseId - RING_SIZE` so it
151+
// stays correct across the id counter wrapping back to 0. The one
152+
// unavoidable limit: if a single promise stays pending while 2^31 more
153+
// ids are dispatched its id is reused, and this set silently overwrites
154+
// (loses) the older map entry. Not a regression: the old code broke far
155+
// sooner, and no real workload keeps an op pending across 2^31 dispatches.
156+
MapPrototypeSet(promiseMap, oldPromise[2], oldPromise);
145157
}
146158

147159
const promise = new Promise((resolve, reject) => {
148-
promiseRing[idx] = [resolve, reject];
160+
promiseRing[idx] = [resolve, reject, promiseId];
149161
});
150162
const wrappedPromise = PromisePrototypeCatch(
151163
promise,
@@ -160,44 +172,39 @@
160172
}
161173

162174
function __resolvePromise(promiseId, res, isOk) {
163-
// Check if out of ring bounds, fallback to map
164-
const outOfBounds = promiseId < nextPromiseId - RING_SIZE;
165-
if (outOfBounds) {
166-
const promise = MapPrototypeGet(promiseMap, promiseId);
175+
// A promise stays in the ring until its slot is reclaimed by a newer
176+
// promise, at which point it moves to the map. The entry stores its own
177+
// promise id, so a slot reused after the id counter wrapped around is
178+
// never mistaken for an older promise that is now in the map.
179+
const idx = promiseId % RING_SIZE;
180+
let promise = promiseRing[idx];
181+
if (promise !== NO_PROMISE && promise[2] === promiseId) {
182+
promiseRing[idx] = NO_PROMISE;
183+
} else {
184+
promise = MapPrototypeGet(promiseMap, promiseId);
167185
if (!promise) {
168-
throw "Missing promise in map @ " + promiseId;
186+
throw "Missing promise @ " + promiseId;
169187
}
170188
MapPrototypeDelete(promiseMap, promiseId);
171-
if (isOk) {
172-
promise[0](res);
173-
} else {
174-
promise[1](res);
175-
}
189+
}
190+
if (isOk) {
191+
promise[0](res);
176192
} else {
177-
// Otherwise take from ring
178-
const idx = promiseId % RING_SIZE;
179-
const promise = promiseRing[idx];
180-
if (!promise) {
181-
throw "Missing promise in ring @ " + promiseId;
182-
}
183-
promiseRing[idx] = NO_PROMISE;
184-
if (isOk) {
185-
promise[0](res);
186-
} else {
187-
promise[1](res);
188-
}
193+
promise[1](res);
189194
}
190195
}
191196

192197
function hasPromise(promiseId) {
193-
// Check if out of ring bounds, fallback to map
194-
const outOfBounds = promiseId < nextPromiseId - RING_SIZE;
195-
if (outOfBounds) {
196-
return MapPrototypeHas(promiseMap, promiseId);
197-
}
198-
// Otherwise check it in ring
199198
const idx = promiseId % RING_SIZE;
200-
return promiseRing[idx] != NO_PROMISE;
199+
// Loose comparison on purpose: `promiseId` can be `undefined` (e.g.
200+
// `unrefOpPromise()` with a promise from an op that completed eagerly and
201+
// has no promise id attached), making `idx` NaN and the ring lookup
202+
// return `undefined` instead of the `NO_PROMISE` (null) sentinel.
203+
const promise = promiseRing[idx];
204+
if (promise != NO_PROMISE && promise[2] === promiseId) {
205+
return true;
206+
}
207+
return MapPrototypeHas(promiseMap, promiseId);
201208
}
202209

203210
function setUpAsyncStub(opName, originalOp, maybeProto) {
@@ -223,7 +230,7 @@
223230
if (isLeakTracingEnabled) {
224231
submitLeakTrace(id);
225232
}
226-
nextPromiseId = (id + 1) & 0xffffffff;
233+
nextPromiseId = (id + 1) & 0x7fffffff;
227234
return setPromise(id);
228235
};
229236
break;
@@ -243,7 +250,7 @@
243250
if (isLeakTracingEnabled) {
244251
submitLeakTrace(id);
245252
}
246-
nextPromiseId = (id + 1) & 0xffffffff;
253+
nextPromiseId = (id + 1) & 0x7fffffff;
247254
return setPromise(id);
248255
};
249256
break;
@@ -263,7 +270,7 @@
263270
if (isLeakTracingEnabled) {
264271
submitLeakTrace(id);
265272
}
266-
nextPromiseId = (id + 1) & 0xffffffff;
273+
nextPromiseId = (id + 1) & 0x7fffffff;
267274
return setPromise(id);
268275
};
269276
break;
@@ -283,7 +290,7 @@
283290
if (isLeakTracingEnabled) {
284291
submitLeakTrace(id);
285292
}
286-
nextPromiseId = (id + 1) & 0xffffffff;
293+
nextPromiseId = (id + 1) & 0x7fffffff;
287294
return setPromise(id);
288295
};
289296
break;
@@ -303,7 +310,7 @@
303310
if (isLeakTracingEnabled) {
304311
submitLeakTrace(id);
305312
}
306-
nextPromiseId = (id + 1) & 0xffffffff;
313+
nextPromiseId = (id + 1) & 0x7fffffff;
307314
return setPromise(id);
308315
};
309316
break;
@@ -323,7 +330,7 @@
323330
if (isLeakTracingEnabled) {
324331
submitLeakTrace(id);
325332
}
326-
nextPromiseId = (id + 1) & 0xffffffff;
333+
nextPromiseId = (id + 1) & 0x7fffffff;
327334
return setPromise(id);
328335
};
329336
break;
@@ -343,7 +350,7 @@
343350
if (isLeakTracingEnabled) {
344351
submitLeakTrace(id);
345352
}
346-
nextPromiseId = (id + 1) & 0xffffffff;
353+
nextPromiseId = (id + 1) & 0x7fffffff;
347354
return setPromise(id);
348355
};
349356
break;
@@ -363,7 +370,7 @@
363370
if (isLeakTracingEnabled) {
364371
submitLeakTrace(id);
365372
}
366-
nextPromiseId = (id + 1) & 0xffffffff;
373+
nextPromiseId = (id + 1) & 0x7fffffff;
367374
return setPromise(id);
368375
};
369376
break;
@@ -383,7 +390,7 @@
383390
if (isLeakTracingEnabled) {
384391
submitLeakTrace(id);
385392
}
386-
nextPromiseId = (id + 1) & 0xffffffff;
393+
nextPromiseId = (id + 1) & 0x7fffffff;
387394
return setPromise(id);
388395
};
389396
break;
@@ -403,7 +410,7 @@
403410
if (isLeakTracingEnabled) {
404411
submitLeakTrace(id);
405412
}
406-
nextPromiseId = (id + 1) & 0xffffffff;
413+
nextPromiseId = (id + 1) & 0x7fffffff;
407414
return setPromise(id);
408415
};
409416
break;
@@ -524,6 +531,7 @@
524531
setUpAsyncStub,
525532
hasPromise,
526533
promiseIdSymbol,
534+
__setNextPromiseId,
527535
});
528536

529537
const infra = {

libs/core/core.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export namespace core {
3030
* if there are only "unref" promises left. */
3131
function unrefOpPromise<T>(promise: Promise<T>): void;
3232

33+
/** Set the id assigned to the next async op promise. Exposed solely for
34+
* testing promise id wraparound behavior. */
35+
function __setNextPromiseId(promiseId: number): void;
36+
3337
/**
3438
* Enables collection of stack traces for sanitizers. This allows for
3539
* debugging of where a given async op was started. Deno CLI uses this for

libs/core/rebuild_async_stubs.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function __TEMPLATE__(__ARGS_PARAM__) {
2121
if (isLeakTracingEnabled) {
2222
submitLeakTrace(id);
2323
}
24-
nextPromiseId = (id + 1) & 0xffffffff;
24+
nextPromiseId = (id + 1) & 0x7fffffff;
2525
return setPromise(id);
2626
}
2727

libs/core_testing/unit/ops_async_test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,42 @@ test(async function promiseId() {
6060
assert(typeof id === "number");
6161
assert(id > 0);
6262
});
63+
64+
// Promise ids are i32s that wrap back to 0 past 2^31 - 1. Test that an op
65+
// that is still pending when the wraparound happens (and whose ring slot has
66+
// been reclaimed in the meantime, moving it to the overflow map) resolves
67+
// correctly. https://github.com/denoland/deno/issues/22759
68+
test(async function testPromiseIdWraparound() {
69+
const distanceToWrap = 5000;
70+
Deno.core.__setNextPromiseId(2 ** 31 - distanceToWrap);
71+
72+
barrierCreate("wraparound_barrier", 2);
73+
const pending = barrierAwait("wraparound_barrier");
74+
75+
// Churn through enough ops that `pending` is evicted from the promise ring
76+
// (RING_SIZE == 4096 ids later) and the id counter wraps past 2^31 - 1.
77+
for (let i = 0; i < distanceToWrap + 1000; i++) {
78+
await asyncYield();
79+
}
80+
81+
// The counter must have wrapped back to a small non-negative id.
82+
const id = await asyncPromiseId();
83+
assert(id >= 0);
84+
assert(id < distanceToWrap);
85+
86+
// Releasing the barrier resolves `pending`, which by now lives in the
87+
// overflow map on the far side of the wraparound.
88+
await Promise.all([pending, barrierAwait("wraparound_barrier")]);
89+
});
90+
91+
// Ops that complete eagerly return a plain resolved promise with no promise
92+
// id attached, so ref/unref of such a promise must be a silent no-op.
93+
// `hasPromise(undefined)` reads `promiseRing[NaN]`, which would throw on the
94+
// `promise[2]` identity check if the sentinel comparison were strict; the
95+
// loose `!=` against `NO_PROMISE` keeps it a no-op.
96+
test(async function testRefUnrefPromiseWithoutId() {
97+
const eager = Promise.resolve("eager");
98+
Deno.core.refOpPromise(eager);
99+
Deno.core.unrefOpPromise(eager);
100+
await eager;
101+
});

0 commit comments

Comments
 (0)