Skip to content

Implement rec groups and struct mutators#12538

Merged
fitzgen merged 9 commits intobytecodealliance:mainfrom
khagankhan:rec-struct-mutators
Feb 10, 2026
Merged

Implement rec groups and struct mutators#12538
fitzgen merged 9 commits intobytecodealliance:mainfrom
khagankhan:rec-struct-mutators

Conversation

@khagankhan
Copy link
Contributor

Mutators added for (rec ...) groups and struct types

  • Define a mutation that adds an empty struct type to an existing (rec ...) group
  • Define a mutation that removes a struct type from an existing (rec ...) group
  • Define a mutation that moves a struct type within an existing (rec ...) group
  • Define a mutation that moves a struct type from an existing (rec ...) group to another
  • Define a mutation that duplicates a (rec ...) group
  • Define a mutation that removes a whole (rec ...) group
  • Define a mutation that merges two (rec ...) groups
  • Define a mutation that splits a (rec ...) group in two, if possible

I have implemented the above mutators. I will keep updating/refining them as we add more features to recursive groups and struct definitions. Ready for the review! Thanks!

cc @fitzgen @eeide

@khagankhan khagankhan requested a review from a team as a code owner February 6, 2026 00:01
@khagankhan khagankhan requested review from fitzgen and removed request for a team February 6, 2026 00:01
@github-actions github-actions bot added the fuzzing Issues related to our fuzzing infrastructure label Feb 6, 2026
@github-actions
Copy link

github-actions bot commented Feb 6, 2026

Subscribe to Label Action

cc @fitzgen

Details This issue or pull request has been labeled: "fuzzing"

Thus the following users have been cc'd because of the following labels:

  • fitzgen: fuzzing

To subscribe or unsubscribe from this label, edit the .github/subscribe-to-label.json configuration file.

Learn more.

Copy link
Member

@fitzgen fitzgen left a comment

Choose a reason for hiding this comment

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

Thanks, just a few little things before merging.

Comment on lines +36 to +51
// Define a mutation that adds an empty struct type to an existing (rec ...) group.
if !c.shrink()
&& !ops.types.rec_groups.is_empty()
&& ops.types.type_defs.len() < ops.limits.max_types as usize
{
c.mutation(|ctx| {
// Pick a random rec group.
if let Some(group_id) = ctx.rng().choose(&ops.types.rec_groups).copied() {
// Insert new struct with next type id in the chosen rec group.
let new_tid = ops.types.next_type_id();
ops.types.insert_empty_struct(new_tid, group_id);
log::debug!("Added empty struct type {new_tid:?} to rec group {group_id:?}");
}
Ok(())
})?;
}
Copy link
Member

Choose a reason for hiding this comment

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

Can we pull each of the mutations out into their own helper functions? This method is way too big after adding all these new mutations inline.

Something like this:

impl GcOpsMutator {
    fn add_new_struct_type_to_rec_group(
        &self,
        c: &mut Candidates<'_>,
        ops: &mut GcOps,
    ) -> mutatis::Result<()> {
        if c.shrink()
            || ops.types.rec_groups.is_empty()
            || ops.types.type_defs.len() >= usize::try_from(ops.limits.max_types).unwrap()
        {
            return Ok(());
        }

        c.mutation(|ctx| {
            let group_id = ctx.rng().choose(&ops.types.rec_groups)
                .expect("already checked for empty rec groups above");
            let new_tid = ops.types.next_type_id();
            ops.types.insert_empty_struct(new_tid, group_id);
            log::debug!("Added empty struct type {new_tid:?} to rec group {group_id:?}");
            Ok(())
        })
    }

    // etc... for the other mutations
}

impl Mutate<GcOps> for GcOpsMutator {
    fn mutate(&mut self, c: &mut Candidates<'_>, ops: &mut GcOps) -> mutatis::Result<()> {
        self.add_gc_op(c, ops)?;
        self.remove_gc_op(c, ops)?;
        self.add_new_struct_type_to_rec_group(c, ops)?;
        // etc...
        Ok(())
    }
}

{
c.mutation(|ctx| {
// Pick a random rec group.
if let Some(group_id) = ctx.rng().choose(&ops.types.rec_groups).copied() {
Copy link
Member

Choose a reason for hiding this comment

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

We've already checked for the case where rec_groups.is_empty() above, so we can just unwrap/expect here, instead of checking that it is non-empty again.

if !ops.types.type_defs.is_empty() {
c.mutation(|ctx| {
// Pick a random struct type.
if let Some(tid) = ctx.rng().choose(ops.types.type_defs.keys()).copied() {
Copy link
Member

Choose a reason for hiding this comment

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

Same thing about double-checking for emptiness. Here and elsewhere.

Comment on lines +55 to +77
/// Returns a fresh rec-group id one past the current maximum.
pub fn next_rec_group_id(&self) -> RecGroupId {
RecGroupId(
self.rec_groups
.iter()
.next_back()
.map(|g| g.0)
.unwrap_or(0)
.saturating_add(1),
)
}

/// Returns a fresh type id one past the current maximum.
pub fn next_type_id(&self) -> TypeId {
TypeId(
self.type_defs
.keys()
.next_back()
.map(|id| id.0)
.unwrap_or(0)
.saturating_add(1),
)
}
Copy link
Member

Choose a reason for hiding this comment

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

I think saturating_add isn't good enough here. The fuzzer could mutate the serialized representation of Types to make a type or rec group have its max id, at which point these methods and their saturating_add will saturate and return that same id.

You could do something like the following to choose either max(keys) + 1 or else the first gap in the keys:

let mut last_id = None;
let mut keys = self.type_defs.keys().rev();

let next_id = 'outer: {
    while let Some(key) = keys.next() {
        match (key.0.checked_add(1), last_key) {
            (Some(x), None) => break 'outer TypeId(x),
            (Some(x), Some(y)) if x != y => break 'outer TypeId(x),
            (Some(_), Some(_)) | (None, _) => {
                last_id = Some(key.0);
            }
        }
    }

    match last_id {
        None => TypeId(0),
        Some(x) => TypeId(x - 1),
    } 
};

debug_assert!(!self.type_defs.contains_key(next_id));
next_id

But this is kinda complicated for what it is doing and does devolve into an $O(n)$ loop, but probably pretty rarely.

The other thing we could do here is just take an rng: &mut mutatis::Rng parameter and do something like

for _ in 0..1000 {
    let id = TypeId(rng.gen_u32());
    if !self.type_defs.contains_key(&id) {
        return id
    }
}
panic!("failed to generate a new TypeId in 1000 iterations; bad RNG?")

which should be good enough.

Comment on lines +177 to +180
if num_types == 0 {
unreachable!(
"typed struct requirement with num_types == 0; op should have been removed"
);
Copy link
Member

Choose a reason for hiding this comment

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

This can be simplified to

debug_assert_ne!(num_types, 0, "...");

@khagankhan
Copy link
Contributor Author

Thanks @fitzgen! Ready for the review!

Copy link
Member

@fitzgen fitzgen left a comment

Choose a reason for hiding this comment

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

Thanks!

@fitzgen fitzgen added this pull request to the merge queue Feb 10, 2026
Merged via the queue into bytecodealliance:main with commit 1865b37 Feb 10, 2026
45 checks passed
@khagankhan khagankhan deleted the rec-struct-mutators branch February 10, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fuzzing Issues related to our fuzzing infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants