fix(paper): rebuild root commands instead of mutating dispatcher mirror nodes - #172
Closed
steveb05 wants to merge 1 commit into
Closed
fix(paper): rebuild root commands instead of mutating dispatcher mirror nodes#172steveb05 wants to merge 1 commit into
steveb05 wants to merge 1 commit into
Conversation
…or nodes Registering a command under an already registered root added its children to the node returned by Commands#getDispatcher().getRoot().getChild(). That node is not the one the server dispatches from. ApiMirrorRootNode#getChild reads from the server root and then wraps, returning the API side node paired in convertFromPureBrigNode, so the addition never reached the dispatcher and no exception was raised. Rebuild the root from the command tree and register it again through registerWithFlags instead, removing the labels first because registerCopy refuses an alias label while a node carrying apiCommandMeta occupies it. Also null check the getNamedNode result that previously threw a NullPointerException when the handler was driven directly, and clean registeredCommands unconditionally in unregisterRootCommand so a stale entry cannot turn a later registration of the same Command instance into a silent success that registers nothing.
Member
|
The API-mirror diagnosis is correct. I’ve addressed the unsafe-registration issue in #173 with a smaller change that adds updated roots through the dispatcher root, so the change reaches Paper’s server dispatcher. It was manually verified on Paper 26.2 for primary, namespaced, alias, and namespaced-alias labels. Closing in favor of #173. Please report independent concerns as individual issues before bundling them into a PR. A PR should stay scoped to the changes required for its stated fix; grouping unrelated observations makes triage and review harder, and shifts the work of extracting and splitting them onto maintainers. |
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.
registerCommanddoes nothing when the command's root is already registered. The command is inserted into cloud's tree andregisterCommandreturns true, but nothing reaches the server and no exception is raised. New roots register correctly anddeleteRootCommandworks, so only added subcommands are affected. This has been true sinceModernPaperBrigadierwas added.It only affects plugins that set
ManagerSetting.ALLOW_UNSAFE_REGISTRATIONand register after theCOMMANDSlifecycle event.registercallslockRegistrationas its first statement, soCommandManager#commandthrows for everyone else.This was reported on Discord in January 2025 (https://discord.com/channels/766366162388123678/766381452639731722/1330678321700278313). The reproduction there snapshots
commandManager.commands(), callsdeleteRootCommand, then re-registers everything from that snapshot in a loop, and only the first command in the loop ends up registered. The same thread also hit aNullPointerException, attributed there todeleteRootCommand, though the throw is really in the other branch ofregisterCommand, which the delete path never reaches. That second one is a separate missing null check rather than the problem described below.The handler grabs the root's node out of the API dispatcher and adds the new children to it, on the assumption that the node it got back is what the server dispatches from. It isn't.
Commands#getDispatcher()is backed byApiMirrorRootNode, and its accessors aren't symmetric.addChildunwraps and then forwards to the server root, so writes land.getChildreads from the server root and then wraps, so reads come back as something else:convertFromPureBrigNodepairs the two graphs withconverted.wrappedCached = pureNode; pureNode.unwrappedCached = converted;. So for a root cloud registered,getChildhands back cloud's own API sideLiteralCommandNode, which isn't in the server dispatcher at all. Children added to it change nothing that gets parsed, executed, or sent to clients, and nothing throws. Nothing re-syncs the two graphs afterwards either, so the addition is just lost.The loop only ever visits labels cloud registered itself, so that's the case it hits. Another plugin holding the label through the Paper API gives the same result, since anything registered through
ApiMirrorRootNode#addChildgets the pairing set too. You get aShadowBrigNodeback only for a node registered straight onto the server dispatcher with no API counterpart, in practice a vanilla command, and thenaddChildthrowsUnsupportedOperationExceptionout ofCommandManager#commandrather than failing quietly.That accounts for the reported reproduction. After
deleteRootCommandthe root has no entry in the alias bookkeeping, so the first registration in the loop takes the other branch and registers properly. Every one after it finds an entry, takes the graft, and is lost. One command survives, whichever went first.Since a fetched node can't be mutated into the right state, the root has to be rebuilt from cloud's tree and registered again. Paper does the same to its own registrations in
PaperCommands#registerIntoDispatcher, which removes the existing child before adding when it overrides, rather than letting Brigadier merge, under the comment// Avoid merging behavior. Maybe something to look into in the future.Removing first isn't tidiness.
registerWithFlagsInternalpassesoverride = truefor the primary and namespaced labels, so those two would be replaced in place, but alias labels go throughregisterCopywithoverride = falseand get refused outright while a node carryingapiCommandMetastill sits on them. Skip the removal and aliases silently stop updating. I remove every recorded label rather than only the alias ones because a label that has dropped out of the root's alias set won't appear in the new registration at all, so nothing would displace it and it would keep answering for a command that no longer has it.The other branch passed the result of
getNamedNodestraight into the registration without checking it. That only bites when the handler is driven directly rather than throughCommandManager#command, sinceinsertCommandputs the root into the tree beforeverifyAndRegisterruns, so the lookup can't come back null on that route. Drive it directly against a root the tree doesn't hold and you get aNullPointerExceptiononrootNode.component()instead of nothing happening. The lookup is null checked now, which covers the second symptom in the report.Two things in the diff aren't obviously part of the fix, so to save you asking.
registeredCommandslooks like it should go along with the branch it served, and it shouldn't.CommandTree#verifyAndRegistercalls back intoregisterCommandfor every leaf of the entire tree on every insert, and that set is the only thing collapsing the replay into one unit of work per genuinely new command. Delete it and N registrations turn into O(N squared) full root rebuilds, each one followed by a command tree broadcast to every online player.The
unregisterRootCommandrework fixes a second silent failure rather than supporting the first. It cleaned its bookkeeping only after two early returns, so a root deleted before the lifecycle event had fired, or one whose label set came back empty, left entries behind in a set that's keyed by identity, sinceCommanddeclares noequals. Re-registering that same instance later then hit the duplicate check, reported success, and registered nothing.Nothing in cloud core needed to change.
deleteRootCommandanddeleteRecursivelyare fine, andregisteris fine as it stands, since everyCOMMANDSfire is preceded by a fresh dispatcher and clearing the alias bookkeeping without removing anything is therefore safe.Three problems remain.
Labels get removed on the strength of cloud's own bookkeeping, without checking that cloud still owns the node sitting at each one. If another plugin has taken a label over in the meantime, cloud deletes their node and registers its own. The old deletion path did this too, so it's only a question of when it runs.
If the rebuild throws after the labels are gone, the root is left fully unregistered, including commands on it that were working a moment earlier. That one is a regression. The old graft removed nothing, so a failure there left the existing registration intact. It is the price of remove then register and I don't see a way around it. Building the replacement node won't realistically fail, since
LiteralBrigadierNodeFactoryfalls back toStringArgumentType.word()rather than throwing, butApiMirrorRootNode#convertFromPureBrigNoderejects an unrecognised argument type from insideregisterWithFlags, well after the removal. A reflective failure inside the removal loop leaves the labels partly removed, same outcome. Dropping the whole root fromregisteredCommandson failure at least lets a later insert rebuild it. Actually restoring the previous state would mean holding onto the literal from the last good registration, which I don't do, and rebuilding from the tree would just fail again, because the tree already contains the command that caused the failure.Cloud's two name comparisons also don't agree on case.
getNamedNodematches case insensitively, whileCommandComponent#equalsandcheckAmbiguitycompare exactly, so two root literals differing only in case can coexist.CommandComponent#compareToorders literals by name andinsertCommandsorts children, so the lookup resolves both of them to whichever sorts first, deterministically. When that isn't the root the command belongs to, the wrong root gets rebuilt and the new command is silently never registered. Startup avoids it, becauseregisteriterates the root nodes directly and registers both. The permission checker built into each root node resolves the same way and has the same blind spot. Fixing it properly means reconciling those two comparisons in core, which I've left alone.One thing I found while tracing the alias handling that belongs in core rather than here.
insertCommandfeedsLiteralParser#insertAliaswithcomponent.aliases(), which includes the component's own name, andinsertAliasfiles everything it gets underalternativeAliases. So as soon as two commands share a root, that root's own name turns up in its own alias list. Every platform handler readingalternativeAliases()inherits it, not just Paper:VelocityPluginRegistrationHandler,BungeeCommand,BukkitPluginRegistrationHandlerandCloudburstPluginRegistrationHandlerall do.On Paper the effect is small.
registerWithFlagsInternalassignsapiCommandMetaonly after its alias loop, so an alias equal to the primary label sees a child with a nullapiCommandMeta, takes it for a vanilla command, and replaces the node that was just registered with a flattened copy carrying an empty alias list. The copy behaves the same, so what's lost isgetCommandMap().getCommand(root).getAliases()and the help map's alias index. The fix is forinsertCommandto iteratealternativeAliases()instead ofaliases(), though that isn't purely a removal:aliases()is backed by a case insensitive set andalternativeAliasesisn't, so the switch would start propagating case variant aliases that currently get deduplicated away. Happy to open it separately if you want it.