Skip to content

Fix double-drop when a component's Drop panics - #471

Merged
Ralith merged 4 commits into
Ralith:masterfrom
tooson9010-spec:fix/panic-safety-double-free
Sep 29, 2026
Merged

Ralith merged 4 commits into
Ralith:masterfrom
tooson9010-spec:fix/panic-safety-double-free

Conversation

@tooson9010-spec

@tooson9010-spec tooson9010-spec commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Found while researching panic-safety in Rust containers.

Summary

Three places destroy or move components before committing the state that says
which slots are live. Component has no bound excluding a panicking Drop, so
a caller only needs to catch the unwind and touch the container again. All three
are double-drops (CWE-415) reachable from safe Rust.

Location Skipped commit Second drop
Archetype::clear self.len = 0 Drop for Archetype calls clear again
Archetype::remove self.len = last the slot already dropped, and the duplicate left by copy_nonoverlapping
CommandBuffer::run_on self.components.clear() CommandBuffer::clear drops components already moved into the world

run_on already carries a comment saying "Wipe out component references so
clear doesn't try to double-free". It just does not run when a command
unwinds.

Fix

clear takes the length before running any destructor. Components not reached
are leaked, which is safe.

remove runs the loop under a guard. On unwind the guard finishes the backfill
for the remaining types without running further destructors, moves the entity
id, and commits the length. The dropped slot is overwritten by the moved entity
and the duplicate falls out of range, so the archetype is left consistent.
Components of the removed entity past the panic are leaked.

run_on tracks how many components the world has taken and splits the two
groups in a guard. Those already handed over are dropped from the list without
running destructors, the rest are destroyed normally. No leak on this path.

World::despawn is not covered. It updates its entity index from the value
remove returns, and that is skipped when the unwind passes through, so the
moved entity keeps a stale index entry. That is a logical inconsistency, not
memory unsafety. I left it alone since the right shape looks like your call.

Verification

Three regression tests count destructor invocations. Against the unpatched
code clear runs 7 destructors where 4 components exist, run_on runs 3
where 2 exist, and despawn runs 8 where 6 exist. With the fix clear
destroys 3 of the 4 and run_on 2 of 2; the despawn test asserts only
that the count stays at or below 6, since how far the loop gets before the
unwind depends on the order the archetype stores its types in.

The double-frees were confirmed earlier with AddressSanitizer (attempting double-free) on the unpatched code, through World::clear,
World::despawn, World::spawn_at and World::spawn_column_batch_at.
Existing tests pass and cargo build --no-default-features still builds.
Confirmed on 0.11.1.

Drop for Archetype calls clear, so if a component's Drop unwinds before
self.len = 0 runs, the archetype is destroyed a second time. Take the
length first; components not reached are leaked, which is safe.
run_on wipes the component list only after the loop, so a panicking
command leaves components owned both by the world and the buffer. Track
how many were consumed and split the two groups in a guard.

@Ralith Ralith left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks!

Comment thread tests/tests.rs Resolved
Comment thread tests/tests.rs Resolved
Comment thread src/archetype.rs Resolved
Comment thread src/archetype.rs Resolved
@Ralith

Ralith commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Each test runs on its own thread, so a thread-local gives the same isolation without allocating.

Is this still true when passing --test-threads=1, or do we need to explicitly reinitialize the thread-locals in every test that uses them?

@Ralith

Ralith commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Also, for easier review, please squash changes from feedback into the commits that were changed.

@tooson9010-spec
tooson9010-spec force-pushed the fix/panic-safety-double-free branch from 910e265 to f4c118e Compare September 14, 2026 09:23
@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

Sorry again for the slow reply.

You're right, it isn't. The tests reset the counters at the start now, and the
suite passes with --test-threads=1.

Also squashed the feedback commits into the ones they changed.

Comment thread src/archetype.rs Resolved
remove destroys the component at index and backfills it from last,
leaving a duplicate until self.len is committed. A panicking destructor
skips that commit, so the removed slot and the duplicate both stay live.

Do all the backfilling in a guard and keep only the destructors on the
straight-line path, so the length is committed on both exits. Components
of the removed entity that a panic did not reach are overwritten by the
backfill and leaked, which is safe.

Tests reset the thread-local counters at the start, since --test-threads=1
runs every test on the same thread. They use thread-locals rather than an
Arc because the leak these paths produce is deliberate and Miri reports a
leaked Arc as an error.
@tooson9010-spec
tooson9010-spec force-pushed the fix/panic-safety-double-free branch from f4c118e to 50a34fb Compare September 14, 2026 23:53
@Ralith

Ralith commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Sorry again for the slow reply.

You're fine, we're all volunteers here, participating as time permits.

Comment thread tests/tests.rs Resolved
Comment thread src/command_buffer.rs Resolved
Comment thread tests/tests.rs Resolved
Comment thread tests/tests.rs Resolved
A panicking Drop during World::insert left already-moved components to be
destroyed again by clear. Commit consumed first; regression test added.
@tooson9010-spec
tooson9010-spec force-pushed the fix/panic-safety-double-free branch from 6d6faa9 to 3f37a91 Compare September 28, 2026 01:13
@Ralith
Ralith merged commit cdc27ea into Ralith:master Sep 29, 2026
5 checks passed
@tooson9010-spec

Copy link
Copy Markdown
Contributor Author

@Ralith
Thanks for taking the time to review this. Once it's released, would it be alright if I file a RustSec advisory so users on older versions are notified?

@Ralith

Ralith commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Sure, if you like. It's difficult to imagine a realistic scenario in which this could be deliberately triggered and present an actual security hazard, so the severity should be recorded accordingly, but it's certainly not technically impossible.

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