Skip to content

[wasm] Don't emit R2R function types over the wasm parameter limit - #132865

Open
lewing wants to merge 5 commits into
dotnet:mainfrom
lewing:lewing-wasm-r2r-param-limit-fix
Open

[wasm] Don't emit R2R function types over the wasm parameter limit#132865
lewing wants to merge 5 commits into
dotnet:mainfrom
lewing:lewing-wasm-r2r-param-limit-fix

Conversation

@lewing

@lewing lewing commented Aug 27, 2026

Copy link
Copy Markdown
Member

crossgen2 targeting wasm could emit a function type declaring more than 1000 parameters. Every engine rejects such a module (V8, wasmtime, wasm-tools), and CoreCLR responds by catching the module-construction failure and silently interpreting the entire assembly. The app runs, tests pass, and the only trace is a Ready to Run header not found line in DOTNET_ReadyToRunLogFile output, so an assembly can lose all of its R2R coverage with no visible error.

This is the crossgen2 recurrence of a Mono-side bug fixed by #80243 ("Disable llvm for methods/calls with more than 1000 parameters"), and it is triggered by the same test type named in #76719: ClassWithManyConstructorParameters in the System.Text.Json test assets, whose .ctor, Create, and Deconstruct each take 1000 parameters and lower to 1003 wasm parameters.

Fixes #132855

Approach

crossgen2 now declines to compile a method whose lowered wasm signature would exceed the limit, and declines its callers too, since a narrow caller can still contain a call site needing the same oversized type. Declining uses RequiresRuntimeJitException, which is already handled by ReadyToRunCodegenCompilation and leaves the method to the interpreter.

One of these guards sits in getWasmTypeSymbol, a JIT-EE callback reached from RyuJIT codegen rather than from importation, so declining there unwinds a C++ exception through codegen frames. That is safe and was checked rather than assumed: with the earlier recordWasmManagedCallSig guard temporarily disabled so that this one fires alone, crossgen2 still declines the caller and still emits a valid image with the wide methods absent.

The guards are managed-side rather than the existing NYI_WASM mechanism, because JitWasmNyiToR2RUnsupported defaults to 0 and is set only by crossgen-corelib.proj and the browser CoreCLR targets. A JIT-side guard would fall back to NYIRAW and hard-fail under any invocation that does not pass the flag.

A shared WasmLimits holds the constants. WasmTypeNode.GetData throws if an over-limit type still reaches emission, turning a silently unloadable image into a build failure.

The part worth reviewing carefully

Guarding the compiled method was not sufficient, and the second defect is the less obvious one.

ObjectNode.GetStaticDependencies marked a WasmTypeNode for every method code node unconditionally. A declined method publishes empty code and its node is skipped at emission by MethodWithGCInfo.ShouldSkipEmittingObjectNode, but the type dependency had already been marked, so the 1003-parameter function type still landed in the type section with no function referencing it. An unreferenced over-limit type makes the module just as unloadable as one in use.

ObjectNode.cs is shared with NativeAOT. The new condition is a no-op there: NativeAOT's MethodCodeNode does not override ShouldSkipEmittingObjectNode so it inherits => false, and the two DelayLoad*MethodImport implementors of IMethodCodeNodeWithTypeSignature derive from EmbeddedObjectNode, which is not an ObjectNode and never reaches this path. Only crossgen2's MethodWithGCInfo is affected.

A consequence worth noting for anyone auditing the adjacent wasm limits: a check that walks emitted functions cannot find an orphaned invalid type. It has to decode the type section.

Verification

Baseline captured on main before any code change, then re-measured after.

Type section of System.Text.Json.Tests.wasm, read directly from the image with no runtime involved:

before after
function types 133 129
max parameters 1003 68
over-limit types 1, referenced by 4 functions 0

Filtered ConstructorTests_AsyncStream run:

before:  Failed to construct WebAssembly module ... param count of 1003 exceeds internal limit of 1000
         Ready to Run header not found: "System.Text.Json.Tests".
         39 assemblies R2R;  Tests run: 346 Passed: 345 Failed: 1

after:   (no "param count" message)
         Ready to Run initialized successfully: "System.Text.Json.Tests".
         40 assemblies R2R;  Tests run: 346 Passed: 345 Failed: 1

The single failure is identical in both runs (BindingBetweenRefProps, a pre-existing [ActiveIssue]), so there is no test regression. All 128 per-assembly R2R images scan clean, and composite mode was verified separately (max 68, zero over-limit). crossgen2 -v confirms the three methods are not compiled rather than miscompiled.

Tests

WasmArgumentLayoutTests gets boundary theories over the lowered parameter count. Managed and unmanaged lowering trip the limit at different managed parameter counts, because unmanaged signatures get neither the shadow stack pointer nor the portable entrypoint.

R2RTestSuites.WasmWideSignatureModule is end to end: it compiles an assembly containing an over-limit method, a narrow method that calls it, and ordinary methods, then asserts that no function type in the type section exceeds the limit, that both wide methods are absent from R2R, and that the ordinary methods are still compiled. It was validated as a negative control: with the guards reverted it fails with Assert.InRange.

Follow-up

Not addressed here, and worth its own issue: the adjacent limits with the same silent-failure shape, namely 1000 results, 50,000 locals, and 65,520 br_table entries. br_table is the most likely to be hit, since wasm R2R already emits it for TypeCode style dispatch.

Note

This pull request description was drafted with GitHub Copilot (AI-generated) and reviewed by the author.

lewing and others added 4 commits August 27, 2026 17:06
crossgen2 targeting wasm could emit a function type with more than 1000
parameters. Every engine rejects such a module, and the runtime responds
by silently interpreting the whole assembly, so the only trace was a
"Ready to Run header not found" line in the ReadyToRun log.

Decline to compile a method whose lowered signature exceeds the limit,
and decline its callers, since a narrow caller can still contain a call
site needing the same oversized type. This is the crossgen2 counterpart
of the Mono fix in dotnet#80243.

Guarding only the compiled method was not sufficient.
ObjectNode.GetStaticDependencies marked a WasmTypeNode for every method
code node unconditionally, so a declined method - whose node is skipped
at emission because its code is empty - still contributed its function
type to the type section with no function referencing it. An unreferenced
over-limit type makes the module just as unloadable as one in use.

The checks are managed-side rather than NYI_WASM because
JitWasmNyiToR2RUnsupported defaults to 0, so a JIT-side guard would fall
back to NYIRAW and hard-fail under any invocation that does not pass it.

Fixes dotnet#132855

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use ' - ' rather than ' -- ' for parenthetical asides, matching
CorInfoImpl.ReadyToRun.cs ('Should be unreachable - couldn't find a
TypeSpec'); repo-wide ' -- ' is predominantly a definition separator.
Fold the <param> rationale into <summary>, since that file documents
private members with <summary> only and has no <param> tags.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The added comments ran at a 0.60 comment-to-code ratio against 0.05-0.28
in the files being modified. Trimmed to 0.36, cutting restatement and
narrative while keeping the non-obvious invariants: that types[0] is the
return type, that the import thunk is emitted only when marked, and that
an unreferenced over-limit type is enough to make the module unloadable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
recordCallSite is never reached on wasm: emitRecordCallSite's body is
entirely inside #if defined(DEBUG) and is called only from the xarch
emitter, so the guard there was unreachable. Removed it and moved the
Import.OnMarked note to recordWasmManagedCallSig, which is the live path.

Managed calls reach recordWasmManagedCallSig before getWasmTypeSymbol, so
the end-to-end test was exercising only the former. Verified the latter in
isolation by disabling the former: the test still passes, which also shows
a RequiresRuntimeJitException thrown from a JIT-EE callback unwinds safely
mid-codegen. Added unit coverage for unmanaged lowering, which omits the
shadow stack pointer and portable entrypoint and so trips the limit at a
different managed parameter count.

Also softened the WasmLimits wording: 1000 is a widely adopted
implementation limit, not a conformance requirement of the core spec.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the wasm ReadyToRun (crossgen2) pipeline against emitting WebAssembly function types that exceed common engine/tool implementation limits (notably the 1000-parameter cap), by declining compilation of offending methods/call-sites and preventing orphaned invalid type-section entries from being emitted.

Changes:

  • Introduces a shared WasmLimits helper and uses it to decline R2R compilation for methods and call sites whose lowered wasm signature exceeds the parameter/result limits.
  • Prevents ObjectNode.GetStaticDependencies from marking wasm type-signature nodes for methods that will be skipped at emission (avoiding invalid orphaned types).
  • Adds unit and end-to-end tests that validate boundary behavior and ensure no over-limit function types appear in the emitted wasm type section.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmTypeNode.cs Fails the build if an over-limit wasm function type still reaches emission.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs Adds WasmLimits constants/utility for shared limit checks.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/ObjectNode.cs Skips adding wasm type-signature dependencies for nodes that won’t be emitted.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs Declines compilation for over-limit lowered signatures and for over-limit call-site function types.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs Adds boundary tests asserting when lowering crosses the wasm parameter limit.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs Adds type-section scanning helper to detect over-limit orphaned functypes.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWideSignatureModule.cs New test input module with an over-limit signature and a narrow caller that invokes it.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs Adds an end-to-end test suite validating the wide-signature scenario and type-section constraints.

getWasmTypeSymbol allocated the CorInfoWasmType[] and narrowed typesSize
to int before validating the arity. Check typesSize first so an over-limit
signature neither allocates nor reaches the narrowing cast.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@AndyAyersMS

Copy link
Copy Markdown
Member

I had a fix for this in the JIT a while back but backed it out, see #129555 (comment).

@lewing

lewing commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

I had a fix for this in the JIT a while back but backed it out, see #129555 (comment).

Do we want to be able to compile and run json tests? right now the module is emitted and will fail to validate, the only reason it doesn't break more things harder is that we currently reject the r2r module if fails to validate and the whole assembly falls back to the interpreter. My change lets nativeaot continue to fail to compile these cases. We can ifdef out the tests but I think that is strictly less valuable.

As for the number of limits https://github.com/v8/v8/blob/main/src/wasm/wasm-limits.h are the main ones we are likely to hit

@lewing
lewing marked this pull request as ready for review August 28, 2026 01:09
Copilot AI review requested due to automatic review settings August 28, 2026 01:09
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@SingleAccretion

Copy link
Copy Markdown
Contributor

change lets nativeaot continue to fail to compile these cases

There is no RequiresRuntimeJitException on NAOT. At best you can compile a throwing body. Eventually this test will need to be excluded on NAOT in one way or another.

We can ifdef out the tests but I think that is strictly less valuable.

I view these kinds of tests as similar to what we have with "allocate a 4 GB array" tests (I think we have more of those that 1000 parameter methods). They are excluded on 32 bit platforms, and that's ok. WASM is quirky, some things don't work on WASM as well as on other platforms, and that's also ok.

I agree it is concerning that we have an "all or nothing" plus silent fallback behavior which is a performance trap. It can be a justification for trying to make 1000 parameters work with R2R. At the same time, this is about methods with one thousand parameters. It is almost by definition test code only scenario.

@MichalPetryka

Copy link
Copy Markdown
Contributor

Should we just pack all params after 999 into a struct to handle this? Matching ABI shouldn't be a concern since native can't define it.

@lewing

lewing commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

change lets nativeaot continue to fail to compile these cases

There is no RequiresRuntimeJitException on NAOT. At best you can compile a throwing body. Eventually this test will need to be excluded on NAOT in one way or another.

Agreed, but since the interpreter can run this code my preference is that we treat this as not AOT able rather than generate code most engines will reject.

We can ifdef out the tests but I think that is strictly less valuable.

I view these kinds of tests as similar to what we have with "allocate a 4 GB array" tests (I think we have more of those that 1000 parameter methods). They are excluded on 32 bit platforms, and that's ok. WASM is quirky, some things don't work on WASM as well as on other platforms, and that's also ok.

The test was written because of json source generators creating huge parameter counts, and that is where the real danger for this sort of thing lives in practive however unlikely (I'm sure Jared would be happy to tell stories). I agree that we don't need to go to extremes to support these cases as a rule but experience says making it work when we can leads to fewer issues getting filed.

I agree it is concerning that we have an "all or nothing" plus silent fallback behavior which is a performance trap. It can be a justification for trying to make 1000 parameters work with R2R. At the same time, this is about methods with one thousand parameters. It is almost by definition test code only scenario.

I fixed the fallback for the tests in #132870 so at least this sort of problem will surface correctly while testing. I'm ok with ifdefing the test for all coreclr wasm if that is preferred but I think for the r2r case rejecting is genuinely better regardless.

@SingleAccretion

Copy link
Copy Markdown
Contributor

experience says making it work when we can leads to fewer issues getting filed

I will leave that up to you then.

For me the "bright line" between the decision of "making it work" and "not trying to make it work" is the purpose. We have a test that tests 1000 parameters. It happens to trip the limit. We could have a test with 800 parameters. Or the limit could have been 2000. We wouldn't have noticed then. Would anyone else? It is possible. It is possible they'd also notice their 1000 parameter method is now 50x slower because it is interpreted. It is again possible they wouldn't care.

As you note there are other limits. We will likely never trip most of them because we don't have tests that would trip them. A limit we actually hit in a "real world" scenario in NAOT-LLVM is 50k locals-per-method.

In other words, adding product code to fix test code I don't see as valuable. Adding product code to fix problems that we think users may actually encounter and be unable to work around easily (by splitting their methods / classes) is a different matter.

@lewing lewing self-assigned this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wasm][R2R] crossgen2 emits function types exceeding the 1000-parameter wasm limit, silently disabling R2R for the assembly

5 participants