Stacked on #3133 (the registry of whole-program facts); this PR removes one entry from it. It is the only one of the four spelling PRs that changes lowering.
The cliff, and a silent divergence under it
Whether await v adopts a thenable was decided for the whole program by a regular expression over the raw text of its sources (programMayDefineThenables): if some file's text looked like it defined a then member, every await of a checked-dynamic value took PromiseResolve; otherwise none did.
A comment is enough to flip it:
async function settle(value: unknown): Promise<unknown> {
return await value;
}
settle({ n: 1 }).then((v) => console.log(JSON.stringify(v)));
// a note that mentions them: in proseChange them: to then: in the comment and, on main, the IR of the await changes (18,065 to 24,272 bytes of IR for this file, and a different executable). And because the text decides, a thenable the text does not spell is silently not adopted:
const key = ["th", "en"].join("");
const box: any = {};
box[key] = (resolve: (v: number) => void) => resolve(42);
async function settle(value: unknown): Promise<unknown> {
return await value;
}
settle(box).then((v) => console.log(typeof v, String(v)));Node prints number 42; main prints object [object Object], with no diagnostic.
The fact, and the rule now
Whether a value is a thenable is a fact about that value when the await runs, however its then got there (a literal key, a computed one, a class method, a proxy trap). So the rule is per site and there is no program-wide switch:
await vwherevis checked-dynamic lowers toawaitExpr(dyn.promiseResolve(v, thenReader)), the form thenable programs already used, and the value decides at run time.- Unless its static type already answers: a value typed as the standard library's
Promiseor as a primitive has no thenable to adopt, and keepsasync.awaitDyn, which awaits a promise and hops once for anything else.any,unknown, type parameters and object types ask the value.
The same fact read from the static type closes the typed counterpart (finding 5 of the silent-divergence scout): await t where t's static type has a callable then (an object literal, a class instance, a union arm) now calls it, whether or not the awaited value is used. On main the statement form skipped the call silently:
const t = { then(r: (v: number) => void): void { console.log("then called"); r(42); } };
await t; // Node: "then called". main: nothing.
const x = await t; // main: SC2002. Now 42, checked into `number`.The typed value is boxed and takes the same PromiseResolve; what the thenable resolves with is checked into the awaited type where it is used. A type whose then cannot be called ({ then: 5 }), and a type with none, keep exactly their lowering. The call goes through the value's checked-dynamic form, so the form has to carry the member: records and class instances do (Error and Date subclasses and an inherited then included). An instance of a class that extends Array boxes as the array, and a function with a then property as the function, so those two are refused by name at compile time (SC1090, pinned by tests/diagnostics/await-thenable-fences.ts).
programMayDefineThenables, its lowerer cache and its registry entry are deleted.
What is deliberately not in this PR
- A typed thenable returned from an async function, resolved by an executor, passed to
Promise.resolveorPromise.all, or typedPromiseLikeis still refused at compile time (SC2002 / SC2020), as on main. None of these is silent. Exact adoption there means resolving the function's own promise with the thenable, one turn earlier thanreturn await, which is a change to promise resolution rather than toawait. - Promise resolution with a checked-dynamic thenable outside
await(an asyncreturn, an executor'sresolve, athencallback's result,Promise.resolve,for awaitentries) fulfils with the object where Node adopts it. This is silent on main with or without the text scan and is unchanged here; it is logged in FOLLOWUPS.md with repros as the next slice. It needs the runtime's resolution function, which every async program links, so it needs its own gated change. - A thenable behind a static type without
then(a subclass that addsthen, awaited through its base type) is still not adopted. Logged.
Proof
- Both pairs above compile to byte-identical IR and executables in either spelling, and the second prints
number 42. - New corpus programs, matching Node on both backends, plain and sanitized:
8921-await-dynamic-thenable.ts: a computed key, a proxy trap and a class instance behindunknown; athenthat is not callable, nothen, a string, null, a promise; athenthat throws, one that rejects, one that settles twice, a thenable resolving with a thenable; and the order of turns against a promise chain.8922-await-typed-thenable.ts: the two scout repros (r07, r07b) and their neighbours: value used and unused, a class instance, a union with a thenable arm, nesting, throwing and rejectingthen, a non-callablethen, a plain record, and the order of turns.8923-await-thenable-never-settles.ts: athenthat never calls its resolver, typed and dynamic: the await never continues and the process exits 0.
6590-npm-promise-mixin(execa's mixin) andweb-stream-callback-thenables.tsstill match.- IR sweep of every corpus, diagnostics and benchmark program (5,507), main against this change: 60 programs change IR, no diagnostics change, nothing starts or stops compiling. Each awaits a checked-dynamic value whose static type does not rule a thenable out (
anyorunknownin every one I read). The 56 that build without a Node island pack were run against Node on main's runtime: all match on stdout, stderr and exit code.web-stream-callback-thenables.tsis the one program that gets smaller: itsawait reader.read()is typedPromiseand no longer takes the adoption path the text scan switched on. - Zero-cost gate,
--gate-corpus=all --gate-allow='\bawait\b': PASS, 5127 identical, 56 allowed, 96 both-fail, 8 shards; no benchmark differs and there is no runtime-only row. This is the one-time cost the text scan was written to avoid, recorded in DECISIONS.md. Executable size of the 56: 44 grow under 1%, median +0.63%, six grow 6.5 to 8.7%, and two TypeScript programs grow more:2871-optional-promise-to-unknown.ts(+32.3%, 497,328 to 657,968 B) and6400-override-any-base.ts(+17.6%). Both read no other dynamic property, so thethenlookup is what links the dynamic object model for them. The gate ran with--gate-max-size-growth=100to measure this. Without the static-type refinement 92 programs differed and eight otherwise typed programs grew 12 to 40%; that is why it is there. The full list with sizes is in the PR comment below. - The four PRs of the stack were also validated merged together (both test lanes, the plain and sanitized native bootstraps, and a pi build with unchanged diagnostics): the numbers are in a comment on #3133.
🤖 Generated with Claude Code
Activity
10The 56 programs the gate allowed (--gate-allow='await'), with release executable sizes on linux-x64, main 4bd24a1cd against 29757292a:
| Program | main (B) | this PR (B) | change |
|---|---|---|---|
corpus/2633-island-promise-crossing.js | 775416 | 774568 | -0.11% |
corpus/2871-optional-promise-to-unknown.ts | 497328 | 657968 | +32.30% |
corpus/5088-promise-combinator-thenables.js | 970008 | 969248 | -0.08% |
corpus/5290-blob-dynamic.js | 865096 | 872648 | +0.87% |
corpus/5338-stream-operators-checks.cjs | 1081320 | 1086792 | +0.51% |
corpus/5521-assert-empty-message.js | 899560 | 905208 | +0.63% |
corpus/5523-assert-class-options.js | 1036232 | 1041280 | +0.49% |
corpus/5542-fs-promises-parity.cjs | 1249272 | 1256176 | +0.55% |
corpus/5568-fs-cp-errors.js | 982456 | 988768 | +0.64% |
corpus/5906-array-from-async-generator-js.js | 750368 | 754920 | +0.61% |
corpus/6146-jsval-operators.js | 1044408 | 1050736 | +0.61% |
corpus/6294-webcrypto-js-values.mjs | 1022464 | 1028608 | +0.60% |
corpus/6295-jose-hs256/main.mjs | 3360568 | 3367824 | +0.22% |
corpus/6331-jose-root-entry/main.ts | 3724384 | 3731200 | +0.18% |
corpus/6400-override-any-base.ts | 608216 | 715168 | +17.58% |
corpus/6419-untyped-async-package/main.ts | 1041368 | 1049968 | +0.83% |
corpus/6452-promisify-untyped-functions.ts | 843688 | 852200 | +1.01% |
corpus/6482-node-stream-dyn-helpers/main.ts | 1399888 | 1406496 | +0.47% |
corpus/6495-generic-method-overloads.ts | 753840 | 761744 | +1.05% |
corpus/6573-semaphore-release-emitter.cjs | 939048 | 944392 | +0.57% |
corpus/6592-promise-armed-method-values/main.ts | 1317680 | 1325464 | +0.59% |
corpus/7038-js-var-later-writes.mjs | 834704 | 841944 | +0.87% |
corpus/7081-webcrypto-ed25519.mjs | 1662904 | 1668408 | +0.33% |
corpus/7086-webcrypto-ecdsa-rsa-errors.mjs | 1500136 | 1556664 | +3.77% |
corpus/7125-webcrypto-key-errors.mjs | 885456 | 943384 | +6.54% |
corpus/7127-webcrypto-aes-errors.mjs | 889056 | 947144 | +6.53% |
corpus/7129-webcrypto-derive-errors.mjs | 864360 | 922432 | +6.72% |
corpus/7131-webcrypto-wrap-key-errors.mjs | 875672 | 933808 | +6.64% |
corpus/7132-jose-pbes2/main.mjs | 3395064 | 3402352 | +0.21% |
corpus/7522-promisify-untyped.cjs | 866400 | 874296 | +0.91% |
corpus/7710-util-debounce.cjs | 1073896 | 1073512 | -0.04% |
corpus/7711-util-throttle.cjs | 1047936 | 1056344 | +0.80% |
corpus/7740-handle-method-values.js | 2364696 | 2373840 | +0.39% |
corpus/7742-response-clone-tee.ts | 2513352 | 2524976 | +0.46% |
corpus/7748-npm-any-bytes-receiver/main.ts | 2311328 | 2316128 | +0.21% |
corpus/8381-untyped-generator-stages.mjs | 1043928 | 1050816 | +0.66% |
corpus/8382-stackless-async-generator-steps.mjs | 1018272 | 1024528 | +0.61% |
corpus/8412-module-namespace-untyped/main.ts | 846576 | 852600 | +0.71% |
corpus/8430-dyn-record-promise-field.ts | 681656 | 740664 | +8.66% |
corpus/8642-dyn-reason-await.ts | 878288 | 886840 | +0.97% |
corpus/8791-async-gen-delegation.js | 1005952 | 1012184 | +0.62% |
corpus/8899-async-gen-delegation-abrupt.js | 1029136 | 1035528 | +0.62% |
corpus/dynamic-import-not-found.mjs | 838368 | 844256 | +0.70% |
corpus/fetch-body-web-stream-linked.mjs | 2621736 | 2624592 | +0.11% |
corpus/fetch-body-web-stream-unlinked.mjs | 2420464 | 2422992 | +0.10% |
corpus/generator-checked-protocol.ts | 724096 | 731080 | +0.96% |
corpus/js-callable-proxy.js | 889448 | 896256 | +0.77% |
corpus/js-promise-pending-reactions.js | 822944 | 822928 | -0.00% |
corpus/module-fallback-functions/main.js | 734776 | 740768 | +0.82% |
corpus/node24-fs-links-promise-errors.cjs | 705752 | 763864 | +8.23% |
corpus/unknown-collections.ts | 904848 | 913432 | +0.95% |
corpus/web-stream-callback-thenables.ts | 1980960 | 1908464 | -3.66% |
corpus/web-stream-fetch-interop.mjs | 2188112 | 2243400 | +2.53% |
corpus/web-stream-readable-untyped.mjs | 2325032 | 2328192 | +0.14% |
corpus/web-stream-transform-pipes-untyped.mjs | 1267960 | 1265632 | -0.18% |
corpus/web-stream-writable-untyped.mjs | 1130184 | 1137864 | +0.68% |
Native bootstraps of the merged stack (tests/harness/self-hosting-native-driver.test.ts, 32 vCPU Vercel Sandboxes), on the tree with this PR's static-type refinement (5005e5788, the merge of #3133, #3150 at 29757292a, #3151, #3152 and #3153 at its first head):
- plain: passed in 2,282 s (Xeon 2.90GHz)
- sanitized (
SCRIPTC_SAN=1): passed in 6,534 s (Xeon 2.50GHz, 64 GB)
The two commits pushed after that tree was built (c8f467fa5 here, the review fixes on #3153) add a compile-time refusal and change when the Temporal declarations are loaded; neither changes how the compiler's own sources compile. Both lanes on the stack before the refinement, including that tree's sanitized bootstrap, are in the comment on #3133.
The zero-cost gate was run again on the current head c8f467fa5: PASS, 5127 identical, 56 allowed, 96 both-fail, the same rows as before.
🤖 Generated with Claude Code
Merged main (50bcfe1b3) into this branch: 49476df41. It also merges cleanly with main as it is now (5755f347b).
What conflicted. thenables.ts only. Main's #3251 changed programMayDefineThenables to report its raw-text test to the build cache (sourceTextMatches); this branch deletes that function, which is its point. The branch's side is kept: there is no text scan left to report, and nothing else on main calls the function or reads thenablesMayBeDefined.
What merged without a conflict, and was read. lower-exprs.ts, lowerer.ts and program-facts.ts: this branch's hunks are what they were (the await lowering, the removed lowerer field, the removed registry row and scan). program-facts.test.ts passes on the merged tree.
Re-run on the merged head (Linux x64 Sandboxes, Intel Xeon 2.50 GHz, against main 50bcfe1b3):
- Zero-cost gate,
--gate-corpus=all --gate-allow='\bawait\b': 5,239 identical, 56 allowed, 108 failing on both sides, none differ, no runtime-only row, no benchmark differs. The same count of allowed programs as in the description. I ran it without--gate-max-size-growth=100, so the round's verdict is FAIL by 50 size lines, every one of them on an allowed program: of the 56, 43 change under 1% (6 shrink), median +0.64%, and the two largest are the description's two,2871-optional-promise-to-unknown.ts(+26.8%) and6400-override-any-base.ts(+13.1%). Compile time: benchmarks −0.41% [−1.34%, +0.79%], fixtures +0.19% [−0.56%, +0.93%], the compiler's own build −0.66% [−1.77%, +2.52%] (interval spans zero). - Corpus programs with
thenableorawaitin their name (the three new ones included), differential and LLVM-differential: 134 passed plain, 134 sanitized. Diagnostics with those words: 6 passed.program-facts.test.ts: 5 passed. pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only: clean.
Not run on this head: the full lanes, the native bootstraps, and the IR sweep of the description.
The latest updates on your projects. Learn more about Vercel for GitHub.