fix: invoke the main entrypoint on dedicated servers below Minecraft 1.18 - #501
Open
ashwalk33r wants to merge 1 commit into
Open
fix: invoke the main entrypoint on dedicated servers below Minecraft 1.18#501ashwalk33r wants to merge 1 commit into
ashwalk33r wants to merge 1 commit into
Conversation
…1.18 On a dedicated server running Minecraft 1.17.1 or older, the class declared in quilt_loader.entrypoints.main was never invoked — silently, with no crash, no exception and no log line — while preLaunch from the same quilt.mod.json ran and its mixin config applied. The entrypoint machinery was never at fault. EntrypointPatch injected the hook correctly, but the JVM never ran the patched bytecode: pre-1.18 server jars bundle log4j, so the raw remapped game jar is added to the Knot classpath early and restricted to log4j prefixes. finishModLoading later adds the transformed minecraft root behind it, and unlockClassPath then clears the restriction, making the raw jar an unrestricted source for every class it holds. Since getClassUrl returns the first match, the JVM defined vanilla net.minecraft.server.Main (17243 bytes) instead of the patched copy (17479), discarding every game patch without a single error. 1.18+ is unaffected: the Mojang bundler ships log4j separately, so the game jar is never a log jar. Decide precedence where the entry is added: a prefix-restricted entry is a limited-purpose source, so it now enters via classLoader.addURL — searched only after every addPath root — instead of addPath. KnotCompatibilityClassLoader has no second tier and cannot honour that ordering, so it warns rather than silently loading the untransformed copy. Verified end-to-end on real dedicated servers: 1.14.4, 1.15.2, 1.16.5 and 1.17.1 go from silent to invoking the entrypoint; 1.18.2 is unchanged.
Author
|
I found it out while porting my mod to older Quilt MC versions (while tooling with CICD E2E test matrix) - by accident it turned out that it never invokes the said main entry point. Log line that the mod was loaded is missing - for me it means my test assertions fail, for user not that much I guess... |
Author
|
Here, automated tests showing that the fix is actually working (fork) - ashwalk33r#4 |
6 tasks
Contributor
|
It is probably going to take a while for the bug to get fixed so if you want immediate results you can build from your fork, go to .minecraft/libraries/or/quiltmc/quilt-loader/, put the newly built jar into the folder, delete the old jar and rename the new jar using the old jar's name. |
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.
Fixes #500.
Why a mod's
mainentrypoint vanishes on pre-1.18 dedicated serversThis is an explanation of a defect whose symptom looks like an entrypoint bug and whose cause is not one. It is worth reading in that order, because the obvious place to look is the wrong one, and the obvious fix does not work.
The symptom, and why it misleads
On a dedicated server running Minecraft 1.17.1 or older, the class declared in
quilt_loader.entrypoints.mainis never invoked. There is no crash, no exception, no warning, and no log line. MeanwhilepreLaunch, declared in the samequilt.mod.json, runs normally, and the mixin config named in that same file is applied. The loader clearly read the metadata and acted on most of it.That asymmetry is what sends people to
EntrypointPatch. It is a reasonable suspicion —preLaunchreaches its call site directly from Knot, whilemainreaches its call site only through theHooks.startServercall thatEntrypointPatchinjects into Minecraft's own main class. If exactly one of the two stages is missing, the injection is the natural culprit.It is not the culprit.
EntrypointPatchruns, finds the right class and the right method, and emits theINVOKESTATICat the intended offset on a straight-line path with no enclosing try/catch. The patched class is produced correctly. It is simply never the class the JVM ends up defining.The actual mechanism: two copies of one class, and the wrong one wins
Minecraft server jars before 1.18 bundle log4j. That single packaging detail is the root of the whole problem, because it makes the game jar a log jar as well as a game jar.
Because log4j must be available before anything else,
MinecraftGameProvider.initialize()puts the raw, unpatched, remapped game jar on the Knot classpath very early — long before mods exist, and long before a transform cache exists — restricted to the log4j and Mojang prefixes it is there to supply (MinecraftGameProvider.java:473). Later,finishModLoading()adds the transformed minecraft root, which lands behind the raw jar in insertion order. FinallyunlockClassPath()clears the prefix restriction on the raw jar, on the reasonable theory that the game is now free to load from it.Clearing that restriction is what turns a latent ordering problem into a defect. The raw jar becomes an unrestricted source for every class it contains, and it is ahead of the transform cache.
KnotClassDelegate.getClassUrlreturns the first match in search order; nothing merges candidates or prefers a transformed one. So the loader reads bytes from the raw jar and defines vanillanet.minecraft.server.Main, discarding every game patch at once.The class size at the moment of definition is the clearest way to see it:
17243 bytes is the vanilla class. The transform cache held the patched one, at 17479 bytes, carrying the injected call. Nothing failed, nothing threw, and so nothing was logged — which is exactly why the symptom is silent. A discarded patch is indistinguishable from a patch that was never needed.
Why the version boundary is where it is
The gap ends at 1.18, and it is tempting to attribute that to
EntrypointPatch's own<=1.17version predicate, which chooses between a legacy and a modern hook class. The two boundaries very nearly coincide, which makes the coincidence convincing.The real boundary is the Mojang bundler split. From 1.18 onward the server ships log4j as a separate library rather than bundling it, so the game jar is never a log jar, is never added early, and is only put on the classpath at
unlockClassPath()— after the transform cache. The transform cache therefore wins on 1.18+, and the same jar, the same loader and the same JVM behave correctly. Nothing about the entrypoint code differs between the two cases.This matters beyond trivia: it means the defect is a property of how a Minecraft version packages log4j, not of anything in the entrypoint machinery, and it predicts that every pre-bundler version is affected. That prediction was then tested rather than assumed.
What the change actually does
The whole behavioural change is one call, at
Knot.java:324, guarded by one condition atKnot.java:293. A classpath entry added with a non-emptyallowedPrefixesexists to supply those prefixes and nothing more, so it must never outrank a general-purpose entry. It now enters throughclassLoader.addURL(url)rather thanclassLoader.addPath(...).That distinction is the mechanism.
addPathregisters a root inpaths;addURLfeedsminimalLoader.KnotClassLoader.findResourceconsultspathsfirst and only falls back tominimalLoader. Moving the raw game jar to the second tier takes it out from in front of the transform cache, so a lookup ofnet/minecraft/server/Main.classresolves to the patched copy, the injected call executes,Hooks.startServerruns,EntrypointUtils.invoke("main", ModInitializer.class, …)dispatches, and the mod'sonInitialize()finally runs.Nothing is being added here.
EntrypointPatch,HooksandEntrypointUtilsare untouched, and they were already correct — they work on 1.18.2 today with the same loader and the same mod jar. The fix removes a shadowing, it does not implement a missing feature. Reverting line 324 toaddPathmakes the entrypoint silent again on every affected version, which is the cleanest available demonstration that this line and not its neighbours is load-bearing.Log4j still resolves from that jar, which is the reason it was added early in the first place. At
initialize()time nothing else is on the Knot classpath, so the first tier is empty and the second tier is the only source; log4j classes are simply found one tier later. The prefix restriction itself is untouched, so a non-log4j game class requested that early still fails exactly as before.Why this seam, rather than the one the evidence points at
The obvious local fix is to stop
unlockClassPath()from clearing the restriction. It cannot work. By the time that method runs, the raw jar's entries are already ahead of the transform cache inQuiltClassPath's insertion-ordered map, and that structure exposes onlyaddRoot— no removal, no reordering — and resolves collisions first-wins. Ordering can only be decided where an entry is added, which is why the change lives in the genericKnot.addToClassPathrather than in the Minecraft game provider.One consequence should be stated rather than discovered: the invariant is enforced at one of two doors.
Knot.setAllowedPrefixescan still restrict an entry that already went in viaaddPath, and nothing repositions it afterwards. No caller in the tree does that today, but the patch does not make it impossible.KnotCompatibilityClassLoaderis the other honest limit. It has no second search tier —addURLandaddPathare both plainURLClassLoader.addURLthere — so it cannot honour this ordering at all. Rather than let it fail silently in the same way the original bug did, it now logs a warning naming the entry and the consequence. That path is selected only by a ModLoader-era mod or-Dfabric.loader.useCompatibilityClassLoader=true, and it was not booted here; fixing it properly means giving it a two-tier search, which is a separate and unmeasured change.How this is known
Boots of real dedicated servers, each at its own Java floor in Docker, with a mod declaring only a
mainentrypoint:main, exit 0main, exit 0main, exit 0main, exit 0mainmain, exit 0The runner that produces this table is deliberately kept on a separate branch, ashwalk33r#4, so that this diff stays one file:
src/main/java/org/quiltmc/loader/impl/launch/knot/Knot.java, 37 insertions and 1 deletion../gradlew buildsucceeds.Two limits on that evidence, stated plainly. The change is side-agnostic in code but server-only in measurement — no pre-1.18 client was booted, and while the changed path is not environment-specific, a client is not claimed to be fixed. And a mod added with restricted prefixes now throws
IllegalArgumentExceptionnaming the mod id rather than silently landing last; no caller in the tree does this today, so it is a guard against future regression rather than a live check.A step-by-step reproduction, runnable without this repository, is at ashwalk33r#2. The version tables and environment details are at ashwalk33r#1.