Skip to content

Improve command error handling with compatibility detection and early validation - #115

Open
packs-packrat wants to merge 7 commits into
CarbonCommunity:mainfrom
packs-packrat:packrat/command-compatibility-errors
Open

Improve command error handling with compatibility detection and early validation#115
packs-packrat wants to merge 7 commits into
CarbonCommunity:mainfrom
packs-packrat:packrat/command-compatibility-errors

Conversation

@packs-packrat

@packs-packrat packs-packrat commented Jul 12, 2026

Copy link
Copy Markdown

Summary

This PR detects version mismatch exceptions in plugin commands and hooks, producing clearer console output and optionally notifying players.

Console command callback exceptions are now handled within the registered command callback rather than propagating to CommandManager.Execute. As a result, Execute completes its normal path and returns true even when the underlying plugin callback fails. For RCON commands with output disabled, the callback places the error in args.Reply, allowing Execute to forward it through the normal RCON reply path instead of its outer exception handler. Generic failures retain the full exception and stack trace, while compatibility failures include both the friendly version-mismatch diagnosis and the resolved compatibility exception details.

Before (generic error for all command failures):

[ERRO] Failed executing chat command 'mycommand' in 'MyPlugin v1.0.0' [callback] (Exception has been thrown by the target of an invocation.)
System.Reflection.TargetInvocationException: ...

  • After (compatibility error detected):

[ERRO] Failed executing chat command 'mycommand' in 'MyPlugin v1.0.0' [callback]: This usually means the Rust/Carbon/plugin versions are out of sync ('BasePlayer.SomeNewMethod()'). Try updating Carbon, the module/plugin, or the Rust server.
System.MissingMethodException: Method not found: 'BasePlayer.SomeNewMethod()'

After (generic error, unchanged behavior):

[ERRO] Failed executing chat command 'mycommand' in 'MyPlugin v1.0.0' [callback] (System.Exception: ...)

If ShowCommandCompatibilityErrors is enabled in config, the player also sees:

That command is temporarily unavailable. Check the server console for details.

Hook target class not found now fails early with:

[ERRO] Error while parsing 'OnPlayerChat' (Hook 'OnPlayerChat' target class 'Chat' was not found in the current game assembly. This usually means the Rust/Carbon versions are out of sync.)

@HunterZ

HunterZ commented Jul 12, 2026

Copy link
Copy Markdown

Honestly Carbon builds should capture which protocol number and staging-or-not flavor of RustDedicated they're intended for, and warn/annoy the hell out of the server owner if there's a mismatch.

Carbon almost always needs a new build for protocol bumps (exceptions like the month deep sea got delayed are rare), so we shouldn't be optimizing for that by having Carbon ignore likely version mismatches.

@packs-packrat

packs-packrat commented Jul 12, 2026

Copy link
Copy Markdown
Author

we shouldn't be optimizing for that by having Carbon ignore likely version mismatches.

That's a good suggestion. The architecture for that behavior already partially exists for the auto updater, so that would have to be a seperate PR. Also maybe I'm misunderstanding but your comment implies that the PR makes Carbon less likely to catch version mismatches. It doesn't, it's the opposite actually.

Examples why this reactive strategy is useful:

  • Carbon doesnt know what Rust version a plugin is compiled against. A plugin compiled on 2345 running on a 2349 server might call a method that got deleted. Theres no way to check that at compile time.
  • Same issue with extensions. You cant scan every loaded assembly when the server boots, that would be a big performance hit
  • A rust version bump might just change one signature method. If we added some logic to detect ANY mismatch you are going to blocking everything potentially, when the plugin actually works fine except for that one call. This catches what is actually breaking.

Thanks again for the feedback, let me know if anything is unclear @HunterZ

@HunterZ

HunterZ commented Jul 12, 2026

Copy link
Copy Markdown

we shouldn't be optimizing for that by having Carbon ignore likely version mismatches.

That's a good suggestion. The architecture for that behavior already partially exists for the auto updater, so that would have to be a seperate PR. Also maybe I'm misunderstanding but your comment implies that the PR makes Carbon less likely to catch version mismatches. It doesn't, it's the opposite actually

What the PR is doing and what I was saying are both trying to address Carbon-RustDedicated mismatches, but in different ways. This PR is more focused on the pointy end, and I was just wondering if there's value in trying to catch it earlier and/or more definitively.

Concerns with this PR:

  • Could be misleading if the addressed errors can have other causes
  • If there aren't other causes, then the other approach I was talking about might avoid the need for this approach

Arguments in favor of this PR:

  • Implementing more user-friendly error messages is a value-add, especially since a lot of server owners aren't developers
  • Addressing things at the point of failure provides maximum visibility, which is important since a lot of server owners don't know/think/bother to look at startup logs during troubleshooting for some reason

@packs-packrat

Copy link
Copy Markdown
Author

These approaches aren't mutually exclusive. A startup check wont catch runtime mismatches from hot reloaded plugins or function level sig changes. Feel free to request me as a PR reviewer if you wanna persue that!

@EthanDelong

Copy link
Copy Markdown
Contributor

Doesn't the existing message already tell you the bad call in the plugin though?

Are we just swallowing all the exceptions now so plugins can kind of work around incompatible functionality? I'm a bit confused byt he nature of this. The summary in the PR says it's just adding better logging essentially, but the actual code looks like it's adding a bunch of tries, catching exceptions, and potentially changing behavior entirely? Maybe I am reading it wrong?

@packs-packrat

Copy link
Copy Markdown
Author

All exceptions are still logged via Logger.Error; the only change is that compatibility errors now get a clearer message and an optional, OFF by default notification.

@packs-packrat
packs-packrat force-pushed the packrat/command-compatibility-errors branch from 713113e to d8a874e Compare July 12, 2026 20:36
@packs-packrat
packs-packrat force-pushed the packrat/command-compatibility-errors branch from d0b6dcf to 9502e7b Compare July 12, 2026 20:47
- Refactor ExceptionEx.IsCompatibilityMissingMember to parse the missing member
  from MissingMethodException/MissingFieldException messages using regex, then
  resolve the declaring type via AccessToolsEx.TypeByName and fall back to a
  compatibility assembly type scan.
- Deduplicate missing member extraction into TryExtractMissingMember and reuse
  it in ExtractMissingMember.
- Update Command.AddConsoleCommand RCon callback to set args.Reply to the
  exception message when PrintOutput is false, so compatibility and generic
  callback errors are returned to the caller instead of being swallowed.

@EthanDelong EthanDelong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The main concerns I have are around the cost of the Regex/assembly search in a Mono process. Every time someone executes a command that hits the incompatible point, it looks like it allocates. The fallback assembly/type scan is uncached and the detection re-runs several times per exception, so this possibly introduces a way for users to flood a server without meaning to. A plugin might be running "fine" with broken commands and owners just don't know or care about that specific usage.

Some of the console callbacks throw in existing paths. I understand the chat commands already have the try/catch, but console did not. Those exceptions previously propagated up to CommandManager.Execute, which returns false on failure and forwards the full exception to RCON. With the catch moved inside the callback, Execute now returns true for a command that threw, and the RCON output changes. That might be fine, but it's a behavior change and I think the summary should mention it. Also worth noting the compatibility filter runs first on every failed command, before the generic catch.

I'd also pull the unrelated cleanup (the ?? [] changes) into a separate PR just to keep this one clean.

That said, the HookEx change is a nice improvement. That alone I think is worth keeping. I am just not certain about the extra walks just for a friendlier exception message upstream. If it was limited to HookEx changes I would say it's perfect.

arg.Option = client;
arg.FullString = fullString;
arg.Args = [.. args.Select(x => (StringView)x)];
arg.Args = [.. (args ?? []).Select(x => (StringView)x)];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cleanup unrelated to PR context

try { callback?.Invoke(playerArgs.Player as BasePlayer, command, arg.Arguments.ToStringArray()); }
catch (Exception ex) when (ex.IsCompatibilityError()) { LogCommandCompatibilityError("console", command, plugin, ex, "callback", playerArgs.Player as BasePlayer, isChat: false); }
catch (Exception ex) { LogCommandGenericError("console", command, plugin, ex, "callback"); }
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

existing case that used to throw before, will now be caught and produce a specific ex without re-throwing. have we tested this specific and what the before/after is? does it align with the PR's request which is specifically for producing compatibility messages when it suspects them?

callback?.Invoke(null, command, arg.Arguments.ToStringArray());
try { callback?.Invoke(null, command, args.Arguments.ToStringArray()); }
catch (Exception ex) when (ex.IsCompatibilityError()) { LogCommandCompatibilityError("console", command, plugin, ex, "callback", null, isChat: false, notifyPlayer: false); if (!args.PrintOutput) args.Reply = ex.Message; }
catch (Exception ex) { LogCommandGenericError("console", command, plugin, ex, "callback"); if (!args.PrintOutput) args.Reply = ex.Message; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same as before, swallowing exception previously would've been thrown

arg.Option = option;
arg.FullString = fullString;
arg.Args = args.ToStringViewArray();
arg.Args = (args ?? []).ToStringViewArray();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

unrelated cleanup again

args.PrintOutput = arg.Option.PrintOutput;
}
catch (Exception ex) when (ex.IsCompatibilityError()) { LogCommandCompatibilityError("console", command, plugin, ex, "callback", arg.Player(), isChat: false); }
catch (Exception ex) { LogCommandGenericError("console", command, plugin, ex, "callback"); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

swallowing exception

args.Reply = arg.Reply;
}
catch (Exception ex) when (ex.IsCompatibilityError()) { LogCommandCompatibilityError("console", command, plugin, ex, "callback", arg.Player(), isChat: false, notifyPlayer: false); if (!args.PrintOutput) args.Reply = ex.Message; }
catch (Exception ex) { LogCommandGenericError("console", command, plugin, ex, "callback"); if (!args.PrintOutput) args.Reply = ex.Message; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

swallowing exception

return AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly => IsCompatibilityAssembly(assembly.GetName().Name))
.SelectMany(AccessToolsEx.GetTypesFromAssembly)
.Any(type => type.FullName != null && type.FullName.Replace('+', '.').Equals(declaringTypeName, StringComparison.Ordinal));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this feels really expensive. if a plugin has an incompatible method, and a player spams it, what's the impact of running this over and over?

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.

3 participants