Conversation
… exceptions When an external mod behavior throws an unhandled exception during item damage (such as Toolsmith's NRE when an untracked tinkered tool breaks), the exception previously escaped ServerSystemBlockSimulation into DispatchClientPacket_mainthread, causing the dedicated server to forcibly kick the player with an unhandled exception notice while leaving block and inventory state unfinalized. Wrap Collectible.WalkBehaviors and Collectible.OnBlockBrokenWith in try-catch blocks so faulty mod behaviors log their error to the server logger while allowing vanilla default actions (item damage and break handling) to proceed. In addition, wrap TryModifyBlockInWorld item and block break callbacks with a fallback block removal if unhandled exceptions occur, ensuring dedicated servers do not disconnect players when third-party mod item breaking logic fails. Add regression Atlas scenarios covering both failure modes. Fixes #282
Pixnop
left a comment
There was a problem hiding this comment.
The fix works and #282 is closed by it: the branch builds, the suite is green at 22/22 here, both patches apply to a pristine baseline, hunk counters match and every hunk carries a marker. Three things need to change first, and they are all the same question: what happens to the EnumHandling a behavior wrote by ref just before it threw.
1. OnBlockBrokenWith now drops a behavior's veto (patch lines 19-27, generated Collectible.cs:730-736). The if (handled != PassThrough) block and the if (handled == PreventSubsequent) return result; line moved inside the try. The behavior writes handled before it throws, so the throw skips both checks: preventDefault stays false, the loop carries on, and control reaches the vanilla default at the bottom. A protection behavior that sets PreventSubsequent to veto a break and then throws now lets the block break, where before the player was kicked and the block stayed. Move the two checks below the catch. Your second hunk already does it that way, which is what makes the asymmetry stand out.
2. WalkBehaviors keeps the handling the throwing behavior wrote (patch lines 46-58, generated Collectible.cs:3569-3580). The catch swallows the exception, then if (handling == PreventDefault) executeDefault = false; runs on that stale value. For DamageItem the default action is the entire durability update, so a behavior that sets PreventDefault and then throws makes the tool take no damage at all, silently, on every break from then on. That is the exact shape of #282: Toolsmith's OnDamageItem has to set PreventDefault, otherwise vanilla durability would stack on top of its own tinkering. The description says the catch lets "item damage reduction and slot clearing on zero durability complete cleanly", and for the case the issue is about it does the opposite. Reset handling = EnumHandling.PassThrough in the catch, or decide the other way and say so in the marker.
3. The server fallback re-breaks from inside the catch (ServerSystemBlockSimulation patch lines 443-451, generated :883-891). Two separate problems. The retry has no guard of its own, so when the original exception is deterministic and came from that same path, an empty hand on a block whose BlockBehavior.OnBlockBroken throws for instance, and that loop is not one this PR wraps, the second call throws the same exception, it escapes TryModifyBlockInWorld and the player is kicked anyway, this time after the drops already spawned. And the guard GetBlock(pos).BlockId != 0 reads the Default layer, which returns the fluid layer when the solid layer is empty. Break sand under still water with a collectible path that throws after SpawnDropsAndRemoveBlock has run: the solid layer is air, Default hands back the water, the fallback calls OnBlockBroken a second time and the drops are duplicated.
Not blocking, but worth the same pass:
- Hunk 3 has no coverage at all. Revert only that try/catch, rebuild, run the two new scenarios: still 2/2 green, because the two Collectible catches swallow everything before it gets there. Reaching it needs a throwing
BlockBehavioror a throwingGetDrops. - Neither faulty behavior touches
bhHandlingbefore throwing, so both scenarios exercise the one case where the default still runs, and neither reads the pickaxe afterwards. A scenario that setsbhHandling = PreventDefaultbefore the throw and assertsGetRemainingDurabilityactually dropped would have caught point 2. - The behaviors are appended to
pickaxe.CollectibleBehaviorson the shared registry object and never removed. Both scenarios share one server, so whichever runs second carries the first one's faulty behavior too. Restore the array in a finally. isDedicatedField?.SetValue(server, true)quietly does nothing the day that compiler-generated field name changes, and both scenarios then pass with all three safeguards reverted.Assert.NotNullon the FieldInfo is one line.- Logging is unbounded now. The player is no longer kicked, so a deterministic mod exception repeats on every break, two
Logger.Errorcalls with a full stack trace each time. Someone mining at five blocks a second writes ten traces a second for as long as they keep going. Log once per behavior type and collectible, or rate limit. api?.Logger?.Error(...)is a silent swallow wheneverapiis null, and it is only set byOnLoadedNative.OnBlockBrokenWithhasworldin scope and can log throughworld.Logger.
One design question, asked as a question rather than a condition. ServerMain.DispatchClientPacket_mainthread is already ours and is already the single place where a packet-handling exception decides whether to kick. A policy there would be one hunk in code we own, rather than three inside vanilla method bodies, two of them in the vsapi fork that every version bump has to carry forward. What pushed you towards the three?
Summary
Safeguard block breaking and tool damage against unhandled exceptions thrown by collectible behaviors, resolving #282.
When an external mod behavior throws an unhandled exception during item damage (such as Toolsmith's
NullReferenceExceptioninTinkeringUtility.HandleBrokenTinkeredToolwhen an untracked tinkered tool breaks), the exception escapedServerSystemBlockSimulation.TryModifyBlockInWorlddirectly intoServerMain.DispatchClientPacket_mainthread. On dedicated servers, any unhandled exception in client packet dispatch forcibly kicks the player with "Threw an exception at the server" ("An action you or your client did caused an unhandled exception"), aborting block removal and leaving player inventory dirty states out of sync.This change introduces defensive handling across the breaking pipeline:
Collectible.WalkBehaviorswraps behavior invocations in try-catch blocks. If a mod'sOnDamageItemthrows, the exception is logged to the server logger while allowing vanilla default actions (item damage reduction and slot clearing on zero durability) to complete cleanly.Collectible.OnBlockBrokenWithwraps behavior callbacks in try-catch blocks so third-party mod failures log rather than crashing the breaking sequence.ServerSystemBlockSimulation.TryModifyBlockInWorldwrapsOnBlockBrokenWithandblock4.OnBlockBrokenin a try-catch block. If an exception occurs and the target block was not yet removed, a fallback block break runs so the block is not left in an inconsistent state, and the player is not disconnected.ItemBreakResilienceScenarios.csreproducing both failure modes under dedicated server conditions (IsDedicatedServer == true).Type
Checklist
.\scripts\extract-patches.ps1ran clean.dotnet build VintageStory.slnx -c Release -p:EmbedPatchedFiles=trueis green.// Stratummarker.Related issues
Fixes #282