somdoron opened PR #13687 from somdoron:rec-group-builder to bytecodealliance:main:
Implements the embedder API requested in issue #10176.
Embedders can currently only build one-off struct/array/func types, so types that reference
themselves or each other can't be constructed directly. This adds RecGroupBuilder: declare
kind-typed labels, use them as forward references, and build() the whole recursion group at
once. It lowers to module-canonical WasmSubTypes and reuses the existing rec-group
registration path.Also fixes an unrelated pre-existing bug in StorageType::is_val_type (matched I16 instead of
ValType), in its own commit.
somdoron requested pchickey for a review on PR #13687.
somdoron requested wasmtime-core-reviewers for a review on PR #13687.
somdoron requested alexcrichton for a review on PR #13687.
somdoron requested wasmtime-default-reviewers for a review on PR #13687.
somdoron updated PR #13687.
github-actions[bot] added the label wasmtime:api on PR #13687.
github-actions[bot] added the label wasmtime:docs on PR #13687.
fitzgen added the label wasm-proposal:gc on PR #13687.
fitzgen unassigned pchickey from PR #13687 Rec group builder.
fitzgen unassigned alexcrichton from PR #13687 Rec group builder.
fitzgen requested fitzgen for a review on PR #13687.
fitzgen commented on PR #13687:
@somdoron given that you just posted an AI plan verbatim in this PR's associated issue, I am assuming you also used AI to implement this PR. Can you confirm that you have already reviewed the AI tool's work in detail before posting it here and asking reviewers to spend their time on it? And are you taking full responsibility for the code in this PR?
https://github.com/bytecodealliance/governance/blob/main/AI_TOOL_POLICY.md
somdoron commented on PR #13687:
@fitzgen I confirmed that I reviewed the AI tool. I reviewed the code, and I'm taking full responsibility for the code in this PR. The PR is for a real problem I'm suffering from, and is focused and small.
github-actions[bot] commented on PR #13687:
Subscribe to Label Action
cc @fitzgen
<details>
This issue or pull request has been labeled: "wasm-proposal:gc", "wasmtime:api", "wasmtime:docs"Thus the following users have been cc'd because of the following labels:
- fitzgen: wasm-proposal:gc
To subscribe or unsubscribe from this label, edit the <code>.github/subscribe-to-label.json</code> configuration file.
Learn more.
</details>
:memo: fitzgen submitted PR review:
First of all, I want to say that this feature is going to involve a lot of API design, and probably won't land super quickly because of that. If you really need to be able to define rec groups in the host now, you can do that via building
Modules (either via WAT strings or viawasm_encoder) that define the rec groups you need and export a global of each of the rec groups types so you can exfiltrate theGlobalTypeand the global type'sValTypeon the host viaModule::exports. So that should unblock you for the time being, if necessary.Some high-level thoughts on the design:
I don't think we need to distinguish between pending structs versus pending arrays at the type level. I don't think it is buying us much in terms of correctness by construction or whatever, but it makes ramping up on the API that much harder.
I don't love adding
FieldTemplate/HeapTypeTemplate/etc... types to Wasmtime's public API. I think we could avoid that (and also not force callers to construct slices of them, potentially allocating when they otherwise wouldn't) by having a struct type builder that borrows the rec group builder and exposes a method to define fields one at a time, something like this:```rust
impl RecGroupBuilder<'_> {
pub fn define_struct(&mut self, ty: PendingType) -> StructTypeBuilder<'_> { ... }
}pub struct StructTypeBuilder<'a> {
rec_group: &'a mut RecGroupBuilder,
// ...
}impl StructTypeBuilder<'_> {
/// Define a normal, non-forward-reference field.
pub fn field(&mut self, ty: FieldType) { ... }/// Define a field that is a forward reference to another type defined in this rec group. pub fn forward_ref_field(&mut self, ty: PendingType) -> ForwardRefFieldBuilder<'_> { ... }}
pub struct ForwardRefFieldBuilder<'a> {
parent: StructOrArrayTypeBuilder<'a>,
// ...
}impl ForwardRefFieldBuilder<'_> {
pub fn nullable(&mut self, nullable: bool) -> &mut Self { ... }pub fn shared(&mut self, shared: bool) -> &mut Self { ... } pub fn finish(self) -> &mut StructTypeBuilder<'_> { ... }}
```
somdoron updated PR #13687.
somdoron commented on PR #13687:
Thanks, I have a workaround for now (similar to what you suggested).
I applied your suggestion, using a builder API for struct/array/func. I haven't built the ForwardRefFieldBuilder yet, I went with an API similar to StructType::new, taking both nullability and mutability as parameters. It does lack the shared field at the moment. Let me know if you think the field builder API is preferred.
fitzgen commented on PR #13687:
I applied your suggestion, using a builder API for struct/array/func. I haven't built the ForwardRefFieldBuilder yet, I went with an API similar to StructType::new, taking both nullability and mutability as parameters. It does lack the shared field at the moment. Let me know if you think the field builder API is preferred.
Yeah the builder API is preferred because it is less brittle as the Wasm spec evolves (e.g. with the addition of
sharedto reference types).
somdoron updated PR #13687.
somdoron updated PR #13687.
somdoron updated PR #13687.
somdoron commented on PR #13687:
Done, now with the builder API for fields / array elements / function params.
I didn't add
sharedto any of the builders yet, as it isn't supported yet and I think it depends on the shared-everything-threads proposal.
:memo: fitzgen submitted PR review:
Thanks, I think we are on the right track. Some more comments below.
(Don't want to start getting too detailed with the review until the skeleton / overall design is in the final shape.)
:speech_balloon: fitzgen created PR review comment:
This isn't true at the spec level, and is easy to support (just remove this assertion and everything should work, AFAICT) so I don't think we should require it.
:speech_balloon: fitzgen created PR review comment:
Rather than duplicating all these types for the forward-or-not cases, could we reuse the types from
wasmtime_environ::types::*and have the not-forward cases beEngineOrModuleTypeIndex::Engineand references within the rec group beEngineOrModuleTypeIndex::RecGroupor something like that?
:speech_balloon: fitzgen created PR review comment:
Re: my previous comment, we would ideally make this become
inner: WasmRecGroup,Although unfortunately that would require defining all struct vs array vs function types up front for all the members, leading to an awkward API for the builder, so I guess we probably actually want something like this:
members: Vec<Option<WasmSubType>>,Although I guess we could do the first if we had different
RecGroupBuilder::declare_structvsRecGroupBuilder::declare_arrayvsRecGroupBuilder::decalre_func, as you originally formulated it... I didn't realize the implications that change would have here. I think we actually do want to have those separately-typed declare methods so we can have this builder wrap aWasmRecGroup. Sorry for the churn.
:speech_balloon: fitzgen created PR review comment:
This should document that the order of
declarecalls determines the order that the types are defined within the rec group, which has semantically visible implications. In the following example,$f != $f'and$s != $s', but you might otherwise not realize this when using theRecGroupBuilder,declare, andPendingType.(rec (type $f (func)) (type $s (struct))) (rec (type $s' (struct)) (type $f' (func)))
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
I think
Vec<Option<WasmSubType>>might be better. The reason is thatTypeRegistryInner::register_rec_group_typestakes an iterator ofWasmSubType, so we don't get much benefit from wrapping aWasmRecGrouphere.In any case, we can't construct the underlying
WasmSubTypes for theWasmRecGroupuntil the struct/array/func builder finishes. We could create a placeholder of the correct composite kind whendeclare_*is called, but we'd still have to replace it once the struct/array/func builder finishes. Since that overwrite is needed either way, I don't think the separately-typeddeclaremethods are required to wrap aWasmRecGroup— unless you'd prefer them for API reasons.
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
Correction: we do need the separate declare functions — they're required to build the
WasmHeapTypefor a forward reference, whose concrete variant (ConcreteStruct/ConcreteArray/ConcreteFunc) encodes the composite kind. So the kind has to be known atdeclaretime, even though the body isn't filled in yet.
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
Some conclusions after digging in:
- We can use multiple declare functions, one per type.
- We can use
WasmHeapTypewithEngineOrModuleTypeIndex::EngineandEngineOrModuleTypeIndex::Modulefor forward references. That would removeFieldDef,ValDef, andSuperDef.- Using
WasmRecGrouporVec<WasmSubType>for theRecGroupBuilder's internal type isn't very elegant, since they aren't mutable.- We can use
Vec<WasmSubType>: create a placeholder ondeclare_*, and create the real type when we callRecGroupBuilder::buildorArray/Func/StructBuilder::finish.Anyway, I think keeping the
MemberDefenum is more elegant than trying to useWasmRecGrouporVec<WasmSubType>, since it's mutable and allows accumulation.
somdoron updated PR #13687.
somdoron commented on PR #13687:
The new commit stores
Vec<Option<WasmSubType>>instead of aMemberDef. EachStructTypeBuilder/ArrayTypeBuilder/FuncTypeBuildermust have itsfinishmethod called to commit the type to theRecGroupBuilder, consistent withForwardRefFieldBuilder. Iffinishis never called on one of these builders, that type's definition is silently dropped.At the moment only
RecGroupBuilder::buildreports errors: the first error is stored on theRecGroupBuilderand returned whenbuildis called. We could instead haveStructTypeBuilder/ArrayTypeBuilder/FuncTypeBuilder::finishreturn the error directly.For example, two structs where
$ahas a forward reference to$b:```rust
let mut builder = RecGroupBuilder::new(&engine);let a = builder.declare_struct();
let b = builder.declare_struct();builder
.define_struct(a)
.forward_ref_field(b)
.nullable(true)
.finish() // commit the field, back to the struct builder
.finish(); // commit the struct to the rec groupbuilder
.define_struct(b)
.field(FieldType::new(
Mutability::Const,
StorageType::ValType(ValType::I32),
))
.finish();let group = builder.build()?;
let a: StructType = group.get_struct(a).unwrap();
let b: StructType = group.get_struct(b).unwrap();
```
somdoron edited a comment on PR #13687:
The new commit stores
Vec<Option<WasmSubType>>instead of aMemberDef. EachStructTypeBuilder/ArrayTypeBuilder/FuncTypeBuildermust have itsfinishmethod called to commit the type to theRecGroupBuilder, consistent withForwardRefFieldBuilder. Iffinishis never called on one of these builders, that type's definition is silently dropped.At the moment only
RecGroupBuilder::buildreports errors: the first error is stored on theRecGroupBuilderand returned whenbuildis called. We could instead haveStructTypeBuilder/ArrayTypeBuilder/FuncTypeBuilder::finishreturn the error directly.For example, two structs where
$ahas a forward reference to$b:let mut builder = RecGroupBuilder::new(&engine); let a = builder.declare_struct(); let b = builder.declare_struct(); builder .define_struct(a) .forward_ref_field(b) .nullable(true) .finish() // commit the field, back to the struct builder .finish(); // commit the struct to the rec group builder .define_struct(b) .field(FieldType::new( Mutability::Const, StorageType::ValType(ValType::I32), )) .finish(); let group = builder.build()?; let a: StructType = group.get_struct(a).unwrap(); let b: StructType = group.get_struct(b).unwrap();
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
done, added documentation to each declare function and RecGroupBuilder.
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
fixed
:memo: fitzgen submitted PR review.
:speech_balloon: fitzgen created PR review comment:
Using
WasmRecGrouporVec<WasmSubType>for theRecGroupBuilder's internal type isn't very elegant, since they aren't mutable.We can add mutation methods to these types as necessary and change
Box<[T]>toVec<T>where needed. Given that, I'd prefer some combination of (3) and (4) over effectively duplicating the type hierarchy and adding mutation methods to one of the duplicate sets of types.
fitzgen commented on PR #13687:
If
finishis never called on one of these builders, that type's definition is silently dropped.I think this should become an error when
RecGroupBuilder::buildis called, rather than silently dropping types.Also we should probably be consistent with
finishvsbuildforRecGroupBuilderandStructTypeBuilderetc... I'm leaning towardsbuildbecause we use that forMemoryTypeBuilderalready.At the moment only
RecGroupBuilder::buildreports errors: the first error is stored on theRecGroupBuilderand returned whenbuildis called. We could instead haveStructTypeBuilder/ArrayTypeBuilder/FuncTypeBuilder::finishreturn the error directly.This is fine. Intermediate builder errors end up being annoying in practice, and can't cover everything so you need the final error anyways, so its often best to just delay all validation to the end and only have the final method be fallible.
:memo: fitzgen submitted PR review:
Thanks for your ongoing patience with this PR!
:speech_balloon: fitzgen created PR review comment:
Let's share a constant rather than have a comment saying it is / should be the same.
:speech_balloon: fitzgen created PR review comment:
Is there a reason we can't just delay all error checking until
RecGroupBuilder::build? That would be cleaner.
:speech_balloon: fitzgen created PR review comment:
If we have
members: Vec<WasmSubType>inRecGroupBuilderand initialize the entries to the appropriate defaultWasmCompositeTypeInnervariant, then we shouldn't need this anymore. We can just match on theWasmCompositeTypeInnerindefine_structet al instead.
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
The accumulated errors are only the cross-engine checks, and those can't be delayed: the builder immediately lowers everything into
WasmSubType, where an engine reference is a bareVMSharedTypeIndexwith no engine identity — bybuild()time a foreign engine's index is indistinguishable from a valid one in this engine. Delaying the check would mean holding onto theFieldType/ValType/supertype handles untilbuild(), which contradicts building directly into thewasmtime-environtypes instead of keeping our own builder-side representation.
somdoron requested fitzgen for a review on PR #13687.
somdoron requested wasmtime-compiler-reviewers for a review on PR #13687.
somdoron updated PR #13687.
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
done, I added mutation to those types. A separate commit just for those changes:
https://github.com/bytecodealliance/wasmtime/pull/13687/changes/57b22da48968a9f136f606c328405defed8b8511
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
done
:memo: somdoron submitted PR review.
:speech_balloon: somdoron created PR review comment:
done
somdoron commented on PR #13687:
I think this should become an error when RecGroupBuilder::build is called, rather than silently dropping types.
How do you feel about define_struct/forward_ref_field/etc. taking ownership of the builder? Then not calling build would be a compile error rather than a runtime one, and it all reads as a single chain.
The one downside is the config methods become self -> Self, so building fields in a loop needs x = x.field(..).
let mut builder = RecGroupBuilder::new(&engine); let a = builder.declare_struct(); let b = builder.declare_struct(); let group = builder .define_struct(a).forward_ref_field(b).nullable(true).build().build() .define_struct(b).forward_ref_field(a).nullable(false).build().build() .build()?; ``` Anyway, now that the environ types are mutable, `build` actually does nothing — it only returns the parent builder to continue the chain. I didn't apply the ownership change. > Also we should probably be consistent with finish vs build for RecGroupBuilder and StructTypeBuilder etc... I'm leaning towards build because we use that for MemoryTypeBuilder already. I changed it to `build` across the board, but I think there's a small difference: only `RecGroupBuilder` is actually building something. For the intermediate builders, `finish` reads nicer — all they do is return the parent builder, and even if they did add the type/field themselves, they still wouldn't be building the full type yet. Anyway, whatever you prefer. ````
somdoron updated PR #13687.
somdoron updated PR #13687.
Last updated: Jul 29 2026 at 05:03 UTC