Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/src/display_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,14 @@ impl<'gc> DisplayObjectBase<'gc> {
self.set_flag(DisplayObjectFlags::AVM1_PENDING_REMOVAL, value);
}

pub fn being_removed(&self) -> bool {
self.contains_flag(DisplayObjectFlags::BEING_REMOVED)
}

pub fn set_being_removed(&self, value: bool) {
self.set_flag(DisplayObjectFlags::BEING_REMOVED, value);
}

fn scale_rotation_cached(&self) -> bool {
self.contains_flag(DisplayObjectFlags::SCALE_ROTATION_CACHED)
}
Expand Down Expand Up @@ -2033,6 +2041,19 @@ pub trait TDisplayObject<'gc>:
self.base().set_avm1_pending_removal(value)
}

#[no_dynamic]
/// Whether this object is currently dispatching its `removed`/`removedFromStage` events.
/// Used as a reentrancy guard in `dispatch_removed_event` to prevent infinite recursion
/// when an AS3 event listener calls `remove_child` again during event dispatch.
fn being_removed(self) -> bool {
self.base().being_removed()
}

#[no_dynamic]
fn set_being_removed(self, value: bool) {
self.base().set_being_removed(value)
}

/// Whether this display object is visible.
/// Invisible objects are not rendered, but otherwise continue to exist normally.
/// Returned by the `_visible`/`visible` ActionScript properties.
Expand Down Expand Up @@ -2995,6 +3016,12 @@ bitflags! {
/// (they need to be instantiated "manually" by
/// `Sprite.constructChildren`).
const MANUAL_FRAME_CONSTRUCT = 1 << 16;

/// Whether this object is currently having its `removed`/`removedFromStage`
/// events dispatched. Used to prevent re-entrant calls to `remove_child`
/// during event dispatch (which would cause infinite recursion), matching
/// Flash Player behaviour.
const BEING_REMOVED = 1 << 17;
}
}

Expand Down
20 changes: 20 additions & 0 deletions core/src/display_object/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,34 @@

/// Dispatch the `removed` event on a child and log any errors encountered
/// whilst doing so.
///
/// This function is protected against re-entrant calls: if an AS3 event listener
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How does the recursion happen when the event is dispatched after removing the child? There's 3 call sites: AVM1 button, AVM2 button, and MC. Looks like in all of them the child is removed first, and then the event is being dispatched.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hi, I asked Claude to do an analysis of the problem and the solution in a PDF that I attached to the issue here:
https://github.com/user-attachments/files/26325924/claude_bugfix_report.pdf

I bring you the highlights of the PDF:

3.1 How display-list removal works in Flash
When ActionScript 3 removes a child from a display container, the Flash Player runtime fires two events on
the removed object:
• removed — bubbles up the display tree (dispatched while the child is still technically attached, so the
listener can inspect its parent).
• removedFromStage — does not bubble; dispatched on the child and every one of its descendants.
Adobe Flash Player dispatches these events synchronously but protects against reentrancy: if a listener
attempts to remove the same child again while its events are being dispatched, Flash silently ignores the
second request. This guard was never implemented in Ruffle."

3.3 Why ArcGIS Flex triggers the loop
During a zoom operation the ArcGIS Flex SDK rebuilds the tile layer: it removes all existing tiles
DisplayObjects and adds fresh ones for the new extent. The tile container listens for removed events on its
children to perform housekeeping (clearing bitmaps, canceling pending HTTP requests, etc.). That listener
itself calls removeChild on sibling tiles — perfectly legal in Flash, because Flash silently skips the second
removal for any child already being removed. Without the guard, Ruffle dispatches the event, the listener
runs, Ruffle dispatches the event again, ad infinitum

/// reacts to `removed`/`removedFromStage` by calling `remove_child` again on the
/// same object (or one of its ancestors), the recursive invocation will find the
/// `BEING_REMOVED` flag set and return immediately, matching Flash Player behaviour
/// and preventing the infinite recursion / "too much recursion" crash.
pub fn dispatch_removed_event<'gc>(child: DisplayObject<'gc>, context: &mut UpdateContext<'gc>) {
// Reentrancy guard: if we are already dispatching the removed event for
// this child (e.g. because an event listener called remove_child again),
// bail out immediately to avoid infinite recursion.
if child.being_removed() {
return;

Check warning on line 58 in core/src/display_object/container.rs

View workflow job for this annotation

GitHub Actions / Coverage Report

Coverage

Uncovered line (58)
}

if let Some(object) = child.object2() {
child.set_being_removed(true);

let removed_evt = Avm2EventObject::bare_event(context, "removed", true, false);
Avm2::dispatch_event(context, removed_evt, object.into());

if child.is_on_stage(context) {
dispatch_removed_from_stage_event(child, context)
}

// Reset the flag so that the object can be correctly removed/re-added
// in future operations (e.g. if it is added back to the display list
// and then removed again).
child.set_being_removed(false);
}
}

Expand Down
Loading