Skip to content

Log armor stand breaks from the retired callback on Folia - #1010

Merged
Intelli merged 4 commits into
PlayPro:masterfrom
tricrotism:for-upstream/folia-armor-stand-rows
Oct 2, 2026
Merged

Intelli merged 4 commits into
PlayPro:masterfrom
tricrotism:for-upstream/folia-armor-stand-rows

Conversation

@tricrotism

Copy link
Copy Markdown
Contributor

Summary

On Folia, an armor stand killed by anything other than a player's direct hit (a cactus, a mob, an arrow, an explosion) is never logged: no block row, no rows for its armor and hand items. A rollback cannot bring it back. This change logs the break from the entity scheduler's retired callback as well as the normal one.

The problem

The armor stand's contents can only be read before it dies, so both listeners capture them and schedule the actual logging on the stand's own scheduler, to run once the stand is dead:

  • listener/entity/EntityDamageByBlockListener.java:71 (damage from a block, for example a cactus or magma)
  • listener/entity/EntityDamageByEntityListener.java:130 (damage from an entity, for example a zombie, a skeleton's arrow or TNT)
Scheduler.runTask(CoreProtect.getInstance(), () -> {
    if (armorStand.isDead()) {
        ...containerBreakCheck(...)
        ...queueBlockBreak(...)
    }
}, armorStand);

Scheduler.runTask(plugin, task, entity) is scheduleSyncDelayedTask(plugin, task, null, entity, 0). On Folia that becomes entity.getScheduler().run(plugin, task, retired) with a null retired callback. A lethal hit removes the stand in the same tick, so when the entity scheduler gets to the task the entity is gone. Folia then runs the retired callback instead of the task, and since that is null, nothing is logged. Paper and Spigot run the task on the main thread one tick later and log normally.

In my testing on Folia 1.21.11 this dropped four rows per stand (one block row and three container rows for armor and hand items), and /co rollback left the stand missing.

The fix

Both listeners build the runnable once and pass it as both the task and the retired callback, through the scheduleSyncDelayedTask(plugin, task, retiredTask, entity, 0) overload that already exists in thread/Scheduler.java.

Folia runs exactly one of the two callbacks, so the break is logged once. The runnable still checks isDead(), which is true for a removed entity, so a stand that survived (for example a cancelled hit) is still not logged.

Behaviour change

  • Folia: these breaks are now logged, which is what Paper and Spigot already do.
  • Paper and Spigot: none. The retired callback is never used there.

Risk

The retired callback runs when the entity is removed, on the thread that owns it at that point, which also owns the stand's block location. That is the same ownership the normal task has, so block.getState() and the queue calls are safe there.

Testing

Build: mvn package passes.

Live, the 47-step scenario on Folia 1.21.11 and Paper 26.2 with SQLite, compared against upstream run the same way. The scenario gives an armor stand an iron helmet, a leather chestplate and a diamond sword, and has a zombie kill it.

  • Folia, upstream 3af1079: no rows for the stand.
  • Folia, this branch: exactly four more rows than upstream, #zombie breaking the armor_stand plus one container row each for the helmet, the chestplate and the sword. Every other row matches.
  • Paper: identical to upstream, row for row.

When an armor stand dies from non-player damage, the break and its equipment are logged from a task on the stand's scheduler. On Folia the stand is removed in the same tick, so the task is retired instead of run and nothing is logged. The same runnable is now passed as the retired callback, so exactly one of them logs the break.
@netlify

netlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

❌ Deploy Preview for coreprotect failed. Why did it fail? →

Name Link
🔨 Latest commit 36d321d
🔍 Latest deploy log https://app.netlify.com/projects/coreprotect/deploys/6ab3ecb9c263e900083e8665

@Intelli

Intelli commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Thanks -- automated review is requesting the following changes:

  • Before merging, please distinguish an actual armor-stand death from scheduler retirement caused by unloading or another removal. The entity-damage listener schedules on nonlethal hits too, and isDead() also becomes true for removed entities, so a surviving stand that unloads before the callback runs can incorrectly generate break and equipment-removal records.
  • Please also keep the retired callback safe for Folia’s critical removal context: the current runnable reads block state directly and through container logging. Capture the required state beforehand or defer world access to the appropriate location scheduler.

The damage listeners now only capture the attacker and the stand's
contents, and EntityDeathEvent logs the break when the stand actually
dies. The scheduled task and Folia's retired callback just discard a
capture that was never used, so a stand that survived the hit and was
unloaded or otherwise removed before the callback ran no longer
produces break and container rows, and the retired callback never
touches the world.
@Intelli
Intelli merged commit 4274f88 into PlayPro:master Oct 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants