Skip to content

fix(npc): don't respawn an NPC which was disposed while waiting to respawn - #1249

Merged
sven-n merged 1 commit into
MUnique:masterfrom
ezequielzacca:fix/respawn-after-dispose
Oct 19, 2026
Merged

sven-n merged 1 commit into
MUnique:masterfrom
ezequielzacca:fix/respawn-after-dispose

Conversation

@ezequielzacca

Copy link
Copy Markdown
Contributor

Problem

A killed NPC schedules its respawn (Task.Delay(RespawnDelay) → RespawnAsync). When the NPC is disposed while it waits — e.g. its spawn area is deleted in the admin panel (the MapInitializer delete handler disposes every NPC of the area, dead ones included), or its map/mini game is torn down — the pending task still calls Initialize() and GameMap.RespawnAsync, putting the disposed object back into the map's area of interest manager. In game it shows up as a monster which can't be attacked and stays until the server restarts; its object id may already be reused.

The check needed for this didn't work either: AsyncDisposable.DisposeAsync called Dispose(true) directly, so IsDisposed stayed false after an async dispose (and a second DisposeAsync released the resources again).

Changes

  • AttackableNpcBase.RespawnAsync returns early when the NPC is disposed or disposing.
  • AsyncDisposable.DisposeAsync calls Dispose(), which disposes the managed resources and sets IsDisposed.
  • Tests: a killed monster disposed before its respawn delay doesn't come back (and a killed, not disposed one still respawns); IsDisposed is set after DisposeAsync, and a second DisposeAsync doesn't dispose again.

Note: since IsDisposed is now true after DisposeAsync, existing IsDisposed checks on AsyncDisposable subclasses (e.g. in MiniGameManager, BotManager) now see disposed instances, which seems to be what they intended.

Testing

  • All test projects pass (MUnique.OpenMU.Tests 1897, Persistence.Initialization, Network, Web, PlugIns, ...).
  • Verified in game: killed the monsters of a spawn area, deleted the area right away; before the fix they reappeared after their respawn delay, now none come back.

…spawn

A killed NPC schedules its respawn after the respawn delay. When it got
disposed in the meantime (e.g. its spawn area was deleted, or the map or
mini game was torn down), the pending respawn still initialized it and
put it back into the area of interest manager of the map.

The respawn is now skipped when the NPC is disposed or disposing. For this
check to work, AsyncDisposable.DisposeAsync now marks the instance as
disposed, too - before, IsDisposed stayed false after DisposeAsync, so a
second DisposeAsync released the resources again.

sven-n commented Oct 19, 2026

Copy link
Copy Markdown
Member

Review: looks good. You found and fixed the AsyncDisposable root cause, not just the symptom.

Verified locally (head a0d72b4, merged with current master):

  • src/Startup builds with 0 errors.
  • MUnique.OpenMU.Tests: 1897/1897 pass, including MonsterRespawnTest and AsyncDisposableTest.
  • MUnique.OpenMU.Web.Tests: 278/278 pass.

Notes:

  • DisposeAsync → Dispose() works with the two separate IsDisposing flags: AsyncDisposable shadows IsDisposing with its own field, so the base Dispose() still runs Dispose(true) once and then sets IsDisposed. A second DisposeAsync is now a no-op, as intended.
  • I looked at the existing IsDisposed checks that now see true after an async dispose. BotManager, MiniGameStartBasePlugIn, MagicEffectsList and InvasionGameServerState all treat it as "skip". MagicEffect.ResetTimer throws on a disposed effect, but its only caller (MagicEffectsList) already checks IsDisposed || IsDisposing first. So nothing starts throwing.
  • The early return in RespawnAsync covers both "disposed" and "disposing", which also covers a teardown that's still running.

Merging.


Generated by Claude Code

@sven-n
sven-n merged commit 3e1a063 into MUnique:master Oct 19, 2026
1 check passed
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.

2 participants