Conform injector handler names during config preparation#3
Open
ndellagrotte wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
When mixins form an inheritance chain and a child mixin plainly
@Overrides an@Injecthandlerdeclared 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:
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 originalname, 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
Measured in-game by reflecting over the post-merge method tables and then making both virtual calls
through an
EntityLivingBase-typed reference to anEntityPlayerSP. Same mod jar, same mixinconfig, two Prism instances differing only in loader version (Minecraft 1.12.2, FML 14.23.5.2864,
Temurin 25, Cleanroom Relauncher):
The
probePlaincontrol is the important half: an ordinary merged method overridden across exactlythe 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
EntityMixin→LivingEntityMixin→PlayerEntityMixin→ClientPlayerEntityMixinoverEntity→EntityLivingBase→EntityPlayer→EntityPlayerSP.LivingEntityMixininjected atEntityLivingBase.onUpdateTAIL and expectedPlayerEntityMixin's override to win for a player. It never ran, so the per-tick state was nevercomputed and the
@Redirectgating the whole feature fell through to vanilla every frame. The modloaded, 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.validateandMixinPreProcessorStandard.createContextFor.The propagation to the child is in
MixinPreProcessorStandard.attachMethod, and it is conditional onthe parent already having been conformed:
Upstream satisfies that unconditionally, because it calls
mixin.validate()for every mixin inevery config during the prepare pass, before any target class is transformed
(
MixinConfig.postInitialise). Every injector handler in the game is conformed before the firstattachMethodruns, so the propagation is order-independent by construction.CleanMix removed that guarantee:
mixin.validate()out ofMixinConfig.postInitialiseandinto
MixinProcessor.applyMixins— so it now runs lazily, only for the mixins of the classcurrently being transformed.
rest of that loop; it is still in the file behind
// See: MixinInfo#validate.So
ParentMixin's handler is conformed only whenEntityLivingBaseitself is transformed. A JVMloading
EntityPlayerSPtransforms the subclass first —defineClassis what pulls in thesuperclasses, and it runs after the transformer returns — so
ChildMixin.attachMethodgets therewhile the parent's
Methodis 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.forNamedeliberately cannot mixin-transform a superclassmid-transform (
Proxyis excluded from the delegated chain), so the parent's target cannot bebrought forward either.
Two things that were checked and are not the cause, in case they come up:
attachMethod,conform,conformInjector,createContextFor,MethodMapper.remapHandlerMethodand
MethodMapper.getHandlerNameare all behaviourally identical to sponge-mixin 0.20.12 (diffedagainst the sources jar; the only deltas in those methods are the logger name and
FabricUtil→CleanroomUtil). The transformation logic is not what changed.<mixin>->@Inject::<handler> has N override(s) in child classesDEBUG line is unrelated. Itcomes from
CallbackInjector$CallbackviaMixinInheritanceTracker.findOverridesand only decideswhether the callback is passed a
CallbackInfo.The
zza/zzbclass-UID flip visible in the logs above is the same change showing through, not aseparate phenomenon:
MethodMapper.getClassUIDassigns tokens from an insertion-ordered list, so itnow 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.getHandlerNameis a pure function of the declaring mixin and the handler node; itnever reads the
ClassInfoitsMethodMapperbelongs to. Handler names therefore do not depend onthe target class at all, and can be conformed at prepare time with no target resolved — which is
exactly what the lazy-loading design needs.
MixinPreProcessorStandardgains a target-freeconform()alongside the existingconform(TargetClassContext)/conform(ClassInfo), passing the mixin's ownClassInfo.MixinInfogainsconformInjectors(), reusing the validation class node already built in theStateconstructor. It does not run the preparation pass:prepareInnerClassesresolves thedeclared target classes, and nothing in that pass renames injector handlers.
MixinConfig.postInitialisecalls it for each mixin, after the companion-pluginprepareMixinscall 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:
remapHandlerMethodearly-returns onmethod.isRenamed(), so the later per-targetconformreuses the name instead of incrementinggetMethodUIDa second time.ClassInfo.Member.equalsmatches on original or current name, soevery later lookup still finds the method. Net effect on naming is that the class-UID sequence
becomes deterministic again.
Verification
./gradlew --build-cache --stacktrace buildpasses (the CI command): compiles every source set,checkstyle,validateHeaders.sponge-mixin-0.20.12+mixin.0.8.7sources jar.0.5.17-vs-0.6.6 comparison that found the bug. There is no test source set in this fork
(upstream's
src/testand themixin.testsintegration suite are not present), so there isnowhere 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. Happyto open follow-ups if you'd like them tracked:
MixinInheritanceTrackeris populated fromonInit, which is now also lazy, soCallbackInjector'sfindOverridescan 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
onPrepareis not a safe fix on its own — it would resolve a parent mixin'sClassInfobefore that mixin'sMixinInfoexists, poisoning the (never-evicted)ClassInfocachewith a non-mixin entry.
MixinConfig.hasPendingMixinsForis now dead code, so a re-entrant transform of a class whoseconfig 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 stilldepends on the middle mixin's target being transformed first. That one belongs upstream.