Skip to content

Conform injector handler names during config preparation#3

Open
ndellagrotte wants to merge 1 commit into
CleanroomMC:mainfrom
ndellagrotte:fix/conform-injector-handler-names-at-prepare-time
Open

Conform injector handler names during config preparation#3
ndellagrotte wants to merge 1 commit into
CleanroomMC:mainfrom
ndellagrotte:fix/conform-injector-handler-names-at-prepare-time

Conversation

@ndellagrotte

Copy link
Copy Markdown

The bug

When mixins form an inheritance chain and a child mixin plainly @Overrides an @Inject handler
declared in a parent mixin, CleanMix runs the parent's body. sponge-mixin runs the child's.

This is documented behaviour that is being lost — Mixin wiki §7.3, Override Behaviour for Handler
Methods
:

Handler methods are called using the opcode matching their access level [...] if your handler
method is non-private then it will be called using INVOKEVIRTUAL, this will allow you to
@Override the handler in a derived mixin.

If you @Override a handler method in a derived mixin, it will be renamed to match the decoration
of its supermixin counterpart.

The rename does not happen. The parent's handler is decorated to
handler$<classUID><methodUID>$<mod>$<name> and the child's override is left under its original
name, so they stop being an override pair: the injected call site targets the mangled parent name and
the child's method is never called by anything.

The failure is completely silent. Both methods exist on the merged classes, the config applies, every
injector binds, nothing throws or warns. The only symptom is that the wrong body runs.

Reproduction

@Mixin(EntityLivingBase.class)
public abstract class ParentMixin {

    @Inject(method = "onUpdate", at = @At("HEAD"))
    protected void probeHandler(CallbackInfo ci) {
        LOG.info("handler -> ParentMixin");
    }

    /** Control: an ordinary method, overridden the same way. */
    public String probePlain() {
        return "ParentMixin";
    }
}

@Mixin(EntityPlayer.class)
public abstract class ChildMixin extends ParentMixin {

    @Override
    protected void probeHandler(CallbackInfo ci) {
        LOG.info("handler -> ChildMixin");   // never runs
    }

    @Override
    public String probePlain() {
        return "ChildMixin";                  // runs correctly
    }
}

Measured in-game by reflecting over the post-merge method tables and then making both virtual calls
through an EntityLivingBase-typed reference to an EntityPlayerSP. Same mod jar, same mixin
config, two Prism instances differing only in loader version (Minecraft 1.12.2, FML 14.23.5.2864,
Temurin 25, Cleanroom Relauncher):

Cleanroom 0.5.17-alpha, sponge-mixin-0.20.12+mixin.0.8.7 — correct
  EntityLivingBase: handler$zza000$unknown_owner$doABarrelRoll$probeHandler
  EntityPlayer:     handler$zza000$unknown_owner$doABarrelRoll$probeHandler   same name -> child runs
  plain   -> ChildMixin
  handler -> ChildMixin

Cleanroom 0.6.6-alpha, cleanmix-0.6.0 — broken
  EntityLivingBase: handler$zzb000$doabarrelrol$doABarrelRoll$probeHandler
  EntityPlayer:     doABarrelRoll$probeHandler                               not renamed -> parent runs
  plain   -> ChildMixin
  handler -> ParentMixin

The probePlain control is the important half: an ordinary merged method overridden across exactly
the same chain dispatches correctly on both. This is not a general mixin-inheritance or
virtual-dispatch failure — only injector-handler overrides break.

Found while porting a mod whose real chain is EntityMixinLivingEntityMixin
PlayerEntityMixinClientPlayerEntityMixin over EntityEntityLivingBaseEntityPlayer
EntityPlayerSP. LivingEntityMixin injected at EntityLivingBase.onUpdate TAIL and expected
PlayerEntityMixin's override to win for a player. It never ran, so the per-tick state was never
computed and the @Redirect gating the whole feature fell through to vanilla every frame. The mod
loaded, registered, applied all five mixins, fired its events at 60 Hz, and did nothing at all.

Root cause

Handler names are assigned by MixinPreProcessorStandard.conform, which has exactly two call sites:
MixinInfo.State.validate and MixinPreProcessorStandard.createContextFor.

// MixinInfo.java
void validate(SubType type, List<ClassInfo> targetClasses) {
    MixinClassNode classNode = this.getValidationClassNode();
    MixinPreProcessorStandard preProcessor = type.createPreProcessor(classNode).prepare(...);
    for (ClassInfo target : targetClasses) {
        preProcessor.conform(target);          // <- mints handler$… and marks the Method renamed
    }

The propagation to the child is in MixinPreProcessorStandard.attachMethod, and it is conditional on
the parent already having been conformed:

Method parentMethod = this.mixin.getClassInfo().findMethodInHierarchy(mixinMethod, SearchType.SUPER_CLASSES_ONLY);
if (parentMethod != null && parentMethod.isRenamed()) {
    mixinMethod.name = method.renameTo(parentMethod.getName());
}

Upstream satisfies that unconditionally, because it calls mixin.validate() for every mixin in
every config during the prepare pass, before any target class is transformed
(MixinConfig.postInitialise). Every injector handler in the game is conformed before the first
attachMethod runs, so the propagation is order-independent by construction.

CleanMix removed that guarantee:

  • 0d5df68 ("This should work ig") moved mixin.validate() out of MixinConfig.postInitialise and
    into MixinProcessor.applyMixins — so it now runs lazily, only for the mixins of the class
    currently being transformed.
  • f4d773a ("Allow mixins to delay some init stuff successfully, for lazyloading") commented out the
    rest of that loop; it is still in the file behind // See: MixinInfo#validate.

So ParentMixin's handler is conformed only when EntityLivingBase itself is transformed. A JVM
loading EntityPlayerSP transforms the subclass first — defineClass is what pulls in the
superclasses, and it runs after the transformer returns — so ChildMixin.attachMethod gets there
while the parent's Method is still un-renamed, and the override keeps its original name.

Nothing in the transformer package can fix that by reordering: all sorting is
priority-then-registration, there is no hierarchy ordering anywhere, and target-class transform order
belongs to the classloader. ClassInfo.forName deliberately cannot mixin-transform a superclass
mid-transform (Proxy is excluded from the delegated chain), so the parent's target cannot be
brought forward either.

Two things that were checked and are not the cause, in case they come up:

  • attachMethod, conform, conformInjector, createContextFor, MethodMapper.remapHandlerMethod
    and MethodMapper.getHandlerName are all behaviourally identical to sponge-mixin 0.20.12 (diffed
    against the sources jar; the only deltas in those methods are the logger name and
    FabricUtilCleanroomUtil). The transformation logic is not what changed.
  • The <mixin>->@Inject::<handler> has N override(s) in child classes DEBUG line is unrelated. It
    comes from CallbackInjector$Callback via MixinInheritanceTracker.findOverrides and only decides
    whether the callback is passed a CallbackInfo.

The zza/zzb class-UID flip visible in the logs above is the same change showing through, not a
separate phenomenon: MethodMapper.getClassUID assigns tokens from an insertion-ordered list, so it
now records class-load order where it used to record config-prepare order.

The fix

Restore the invariant — handler names assigned before any target class is transformed — without
restoring eager validate(), which was deferred on purpose.

MethodMapper.getHandlerName is a pure function of the declaring mixin and the handler node; it
never reads the ClassInfo its MethodMapper belongs to. Handler names therefore do not depend on
the target class at all, and can be conformed at prepare time with no target resolved — which is
exactly what the lazy-loading design needs.

  • MixinPreProcessorStandard gains a target-free conform() alongside the existing
    conform(TargetClassContext) / conform(ClassInfo), passing the mixin's own ClassInfo.
  • MixinInfo gains conformInjectors(), reusing the validation class node already built in the
    State constructor. It does not run the preparation pass: prepareInnerClasses resolves the
    declared target classes, and nothing in that pass renames injector handlers.
  • MixinConfig.postInitialise calls it for each mixin, after the companion-plugin prepareMixins
    call at the top of the same method so plugin-supplied mixins are covered too. A failure is logged
    and skipped rather than removing the mixin — a naming problem should not silently drop it.

Idempotent, and safe against the double-mint hazard: remapHandlerMethod early-returns on
method.isRenamed(), so the later per-target conform reuses the name instead of incrementing
getMethodUID a second time. ClassInfo.Member.equals matches on original or current name, so
every later lookup still finds the method. Net effect on naming is that the class-UID sequence
becomes deterministic again.

Verification

  • ./gradlew --build-cache --stacktrace build passes (the CI command): compiles every source set,
    checkstyle, validateHeaders.
  • Analysis above is from reading this repo at 0.6.0 against its own Fabric merge base and against the
    sponge-mixin-0.20.12+mixin.0.8.7 sources jar.
  • Not yet re-run in-game against a patched jar. The in-game A/B in this report is the unpatched
    0.5.17-vs-0.6.6 comparison that found the bug. There is no test source set in this fork
    (upstream's src/test and the mixin.tests integration suite are not present), so there is
    nowhere to add a regression test CI would run — happy to add one if you want a harness stood up.

Related, not fixed here

Two more consequences of the same lazy-validate() change, left out to keep this reviewable. Happy
to open follow-ups if you'd like them tracked:

  • MixinInheritanceTracker is populated from onInit, which is now also lazy, so
    CallbackInjector's findOverrides can miss a child override and compile the parent's callback as
    "CallbackInfo unused" even though the child uses it. Order-dependent in the opposite direction.
    Moving registration to onPrepare is not a safe fix on its own — it would resolve a parent mixin's
    ClassInfo before that mixin's MixinInfo exists, poisoning the (never-evicted) ClassInfo cache
    with a non-mixin entry.
  • dae80b5 dropped the "pending config has mixins for this class" re-entrance check;
    MixinConfig.hasPendingMixinsFor is now dead code, so a re-entrant transform of a class whose
    config has not been promoted proceeds silently un-mixed instead of erroring.

And one pre-existing limitation this patch does not remove, present in 0.20.12 too: in a three-deep
chain where the middle mixin also plainly overrides the root's handler,
findMethodInHierarchy(SUPER_CLASSES_ONLY) stops at the middle link, so the deepest child still
depends on the middle mixin's target being transformed first. That one belongs upstream.

A derived mixin's plain @OverRide of an @Inject handler declared in a parent
mixin is renamed to match the parent's conformed name in
MixinPreProcessorStandard#attachMethod, but only if the parent was conformed
first. Conforming happens in MixinInfo#validate, which 0d5df68 and f4d773a
made lazy, so it now runs when the parent's own target class is transformed.
When the child's target subclass is transformed first - which is the normal
order, since defineClass pulls in superclasses only after the transformer
returns - the override keeps its original name, silently stops being an
override pair, and the injected call site always reaches the parent's body.

Handler names are a pure function of the declaring mixin and the handler
method; MethodMapper never reads the ClassInfo it belongs to. So conform them
during config preparation, which restores the ordering guarantee upstream got
from validating every mixin in postInitialise, without resolving any target
ClassInfo and so without giving up lazy mixin loading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants