#3150

Ask the awaited value whether it is a thenable, instead of scanning the source for `then`

Opencramforcewants to merge intofrom

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 prose

Change 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 v where v is checked-dynamic lowers to awaitExpr(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 Promise or as a primitive has no thenable to adopt, and keeps async.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.resolve or Promise.all, or typed PromiseLike is 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 than return await, which is a change to promise resolution rather than to await.
  • Promise resolution with a checked-dynamic thenable outside await (an async return, an executor's resolve, a then callback's result, Promise.resolve, for await entries) 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 adds then, 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 behind unknown; a then that is not callable, no then, a string, null, a promise; a then that 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 rejecting then, a non-callable then, a plain record, and the order of turns.
    • 8923-await-thenable-never-settles.ts: a then that never calls its resolver, typed and dynamic: the await never continues and the process exits 0.
  • 6590-npm-promise-mixin (execa's mixin) and web-stream-callback-thenables.ts still 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 (any or unknown in 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.ts is the one program that gets smaller: its await reader.read() is typed Promise and 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) and 6400-override-any-base.ts (+17.6%). Both read no other dynamic property, so the then lookup is what links the dynamic object model for them. The gate ran with --gate-max-size-growth=100 to 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

10
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 3:00pm UTC
cramforce6d ago

The 56 programs the gate allowed (--gate-allow='await'), with release executable sizes on linux-x64, main 4bd24a1cd against 29757292a:

Programmain (B)this PR (B)change
corpus/2633-island-promise-crossing.js775416774568-0.11%
corpus/2871-optional-promise-to-unknown.ts497328657968+32.30%
corpus/5088-promise-combinator-thenables.js970008969248-0.08%
corpus/5290-blob-dynamic.js865096872648+0.87%
corpus/5338-stream-operators-checks.cjs10813201086792+0.51%
corpus/5521-assert-empty-message.js899560905208+0.63%
corpus/5523-assert-class-options.js10362321041280+0.49%
corpus/5542-fs-promises-parity.cjs12492721256176+0.55%
corpus/5568-fs-cp-errors.js982456988768+0.64%
corpus/5906-array-from-async-generator-js.js750368754920+0.61%
corpus/6146-jsval-operators.js10444081050736+0.61%
corpus/6294-webcrypto-js-values.mjs10224641028608+0.60%
corpus/6295-jose-hs256/main.mjs33605683367824+0.22%
corpus/6331-jose-root-entry/main.ts37243843731200+0.18%
corpus/6400-override-any-base.ts608216715168+17.58%
corpus/6419-untyped-async-package/main.ts10413681049968+0.83%
corpus/6452-promisify-untyped-functions.ts843688852200+1.01%
corpus/6482-node-stream-dyn-helpers/main.ts13998881406496+0.47%
corpus/6495-generic-method-overloads.ts753840761744+1.05%
corpus/6573-semaphore-release-emitter.cjs939048944392+0.57%
corpus/6592-promise-armed-method-values/main.ts13176801325464+0.59%
corpus/7038-js-var-later-writes.mjs834704841944+0.87%
corpus/7081-webcrypto-ed25519.mjs16629041668408+0.33%
corpus/7086-webcrypto-ecdsa-rsa-errors.mjs15001361556664+3.77%
corpus/7125-webcrypto-key-errors.mjs885456943384+6.54%
corpus/7127-webcrypto-aes-errors.mjs889056947144+6.53%
corpus/7129-webcrypto-derive-errors.mjs864360922432+6.72%
corpus/7131-webcrypto-wrap-key-errors.mjs875672933808+6.64%
corpus/7132-jose-pbes2/main.mjs33950643402352+0.21%
corpus/7522-promisify-untyped.cjs866400874296+0.91%
corpus/7710-util-debounce.cjs10738961073512-0.04%
corpus/7711-util-throttle.cjs10479361056344+0.80%
corpus/7740-handle-method-values.js23646962373840+0.39%
corpus/7742-response-clone-tee.ts25133522524976+0.46%
corpus/7748-npm-any-bytes-receiver/main.ts23113282316128+0.21%
corpus/8381-untyped-generator-stages.mjs10439281050816+0.66%
corpus/8382-stackless-async-generator-steps.mjs10182721024528+0.61%
corpus/8412-module-namespace-untyped/main.ts846576852600+0.71%
corpus/8430-dyn-record-promise-field.ts681656740664+8.66%
corpus/8642-dyn-reason-await.ts878288886840+0.97%
corpus/8791-async-gen-delegation.js10059521012184+0.62%
corpus/8899-async-gen-delegation-abrupt.js10291361035528+0.62%
corpus/dynamic-import-not-found.mjs838368844256+0.70%
corpus/fetch-body-web-stream-linked.mjs26217362624592+0.11%
corpus/fetch-body-web-stream-unlinked.mjs24204642422992+0.10%
corpus/generator-checked-protocol.ts724096731080+0.96%
corpus/js-callable-proxy.js889448896256+0.77%
corpus/js-promise-pending-reactions.js822944822928-0.00%
corpus/module-fallback-functions/main.js734776740768+0.82%
corpus/node24-fs-links-promise-errors.cjs705752763864+8.23%
corpus/unknown-collections.ts904848913432+0.95%
corpus/web-stream-callback-thenables.ts19809601908464-3.66%
corpus/web-stream-fetch-interop.mjs21881122243400+2.53%
corpus/web-stream-readable-untyped.mjs23250322328192+0.14%
corpus/web-stream-transform-pipes-untyped.mjs12679601265632-0.18%
corpus/web-stream-writable-untyped.mjs11301841137864+0.68%
vercel[bot]reviewed6d ago
cramforcereviewed6d ago
vercel[bot]reviewed6d ago
cramforce6d ago

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

cramforce5d ago

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%) and 6400-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 thenable or await in 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.