Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PluggableMap: avoid crash when multiple interactions are removed #11305

Merged

Conversation

megawac
Copy link
Contributor

@megawac megawac commented Jul 21, 2020

As shown in this codepen it is possible to cause a crash when handling a map browser event. The way to achieve this error is to remove multiple interactions are part of one of the map browser event handlers. Due to the way the for loop is structure, we changes in the interactionsArray can cause inconsistencies.

In my case I ran into this issue when using a draw event -- when the draw was completed the interaction would remove itself and a snap interaction from the map. As the draw was the last interaction in the interactionsArray its drawend handler was called first by PluggableMap, then my event handler removed 2 interactions, the handleMapBrowserEvent continue to the index that had the snap provider which caused interaction = undefined and the following error to occur.

PluggableMap.js:353 Uncaught TypeError: Cannot read property 'getActive' of undefined
    at Map.PluggableMap.handleMapBrowserEvent (PluggableMap.js:353)
    at MapBrowserEventHandler.Target.dispatchEvent (Target.js:71)
    at MapBrowserEventHandler.handlePointerUp_ (MapBrowserEventHandler.js:88)

Can you provide pointers of where the unit test for this should be added?

An alternative fix which may be cleaner may be to slice the interactionsArray prior to looping over it in order to guarantee the collection doesn't change during the event handler.

@mike-000
Copy link
Contributor

I think it has to be done using slice. Working through a collection's array as it is being updated may mean an active interaction is moved up the array and you miss it. e.g. looping through the getLayers() array calling removeLayer only removes half the layers.

@megawac
Copy link
Contributor Author

megawac commented Jul 22, 2020

Sure, I'm happy to swap that. I was leaning towards that approach anyway.

@megawac megawac force-pushed the avoid-crash-when-interaction-removed branch from 5c3ae24 to 225edaa Compare July 22, 2020 00:30
@@ -1045,7 +1045,7 @@ class PluggableMap extends BaseObject {
}
}
mapBrowserEvent.frameState = this.frameState_;
const interactionsArray = this.getInteractions().getArray();
const interactionsArray = this.getInteractions().getArray().slice();
if (this.dispatchEvent(mapBrowserEvent) !== false) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just checking is there a reason interactionArray isnt built in this if condition?

@ahocevar
Copy link
Member

ahocevar commented Jul 22, 2020

Doing slice() on an array on this hot path puts a lot of pressure on the garbage collector. So

That said, the issue could also be easily solved on the application level, by removing the interactions in the next tick. I've done something similar before:

  draw.on("drawend", () => {
    setTimeout(() => {
      map.getInteractions().getArray().slice().forEach(i => map.removeInteraction(i));
    }, 0);
  });

@mike-000
Copy link
Contributor

Yes, I agree it is more an applicable issue, it's not a good idea for an interaction to remove itself directly within its own event.

(Also to make sure all interactions were removed would you need map.getInteractions().getArray().slice().forEach( as with layers?)

@megawac
Copy link
Contributor Author

megawac commented Jul 22, 2020

Doing slice() on an array on this hot path puts a lot of pressure on the garbage collector.

Yeah, that's why the first implementation was changing the if condition as below to avoid creating any performance differences

-        if (!interaction.getActive()) {
+        if (!interaction || !interaction.getActive()) {
           continue;
         }

(Also to make sure all interactions were removed would you need map.getInteractions().getArray().slice().forEach( as with layers?)

That wouldn't be necessary as the sliced array won't be affected by interactions being removed. That said I'm not entirely sure what happens when you remove an interaction from the map and then attempt to call interaction.handleEvent()

That said, the issue could also be easily solved on the application level, by removing the interactions in the next tick. I've done something similar before:

That solution actually didn't work in my application when I tried it (after some minor changes I got this workaround working). The solution I was hoping would work was to simply do this

  draw.on("drawend", () => {
    map.getInteractions().getArray().slice().forEach(i => map.removeInteraction(i)); // or whatever
    return false; // Avoid continue propogating the event in PluggableMap
  });

@ahocevar
Copy link
Member

@megawac In your application, it would be better to use setActive(false) and setActive(true) on interactions, instead of removing and re-adding them.

The first version of your pull request had the problem that it might result in an interaction's handleEvent() getting called more than once.

@ahocevar
Copy link
Member

ahocevar commented Jul 23, 2020

An acceptable change I could think of would be to change removeInteraction (and maybe also addInteraction) to remove the interaction in the next tick. Something like this:

diff --git a/src/ol/PluggableMap.js b/src/ol/PluggableMap.js
index b5818bd71..d2c5f0e06 100644
--- a/src/ol/PluggableMap.js
+++ b/src/ol/PluggableMap.js
@@ -156,6 +156,12 @@ class PluggableMap extends BaseObject {
     /** @private */
     this.boundHandleBrowserEvent_ = this.handleBrowserEvent.bind(this);
 
+    /**
+     * @type {boolean}
+     * @private
+     */
+    this.handlingMapBrowserEvent_ = false;
+
     /**
      * @type {number}
      * @private
@@ -1022,6 +1028,7 @@ class PluggableMap extends BaseObject {
     mapBrowserEvent.frameState = this.frameState_;
     const interactionsArray = this.getInteractions().getArray();
     if (this.dispatchEvent(mapBrowserEvent) !== false) {
+      this.handlingMapBrowserEvent_ = true;
       for (let i = interactionsArray.length - 1; i >= 0; i--) {
         const interaction = interactionsArray[i];
         if (!interaction.getActive()) {
@@ -1032,6 +1039,7 @@ class PluggableMap extends BaseObject {
           break;
         }
       }
+      this.handlingMapBrowserEvent_ = false;
     }
   }
 
@@ -1329,7 +1337,18 @@ class PluggableMap extends BaseObject {
    * @api
    */
   removeInteraction(interaction) {
-    return this.getInteractions().remove(interaction);
+    const interactions = this.getInteractions();
+    const foundInteraction = interactions.getArray().indexOf(interaction) !== -1;
+    if (this.handlingMapBrowserEvent_) {
+      setTimeout(
+        function () {
+          interactions.remove(interaction);
+        }.bind(this)
+      );
+    } else {
+      interactions.remove(interaction);
+    }
+    return foundInteraction ? interaction : undefined;
   }
 
   /** 

@megawac
Copy link
Contributor Author

megawac commented Jul 23, 2020

There is a slight chance that if the same isn't done for addInteraction as well there could be minor inconsistencies from the current behaviour. Honestly I don't think anyone would ever do this but this could break :)

draw.on("drawend", () => {
  map.removeInteraction(interaction);
  map.addInteraction(interaction); 
});

@ahocevar
Copy link
Member

You're right @megawac, addInteraction() should get the same protection.

@ahocevar
Copy link
Member

However, it might be better to refactor all interactions to not fire events during MapBrowserEvent handling. This would make the behavior much more predictable and avoid unnecessary setTimeout() calls elsewhere.

@megawac megawac force-pushed the avoid-crash-when-interaction-removed branch from 225edaa to d7a8e45 Compare August 10, 2020 14:33
@ahocevar
Copy link
Member

I still think what I suggested in #11305 (comment) would be a safer solution.

@megawac
Copy link
Contributor Author

megawac commented Aug 10, 2020

I agree, I would need to look into it further to take a crack at such an implementation. Regarding unit testing for this, in which suite would it be best to implement a regression test for either of these changes?

@ahocevar
Copy link
Member

@megawac I would say in the suite for the respective interaction. Thanks for willing to address this!

@ahocevar
Copy link
Member

@megawac I just took a closer look at this, and I think firing interaction events in the next tick when they occur in a map browser event handler will be hard to achieve because at least for Modify and Draw, all the geometry modifications would then be in a different sequence with the events.

That said, I'm inclined to accept your solution here.

@ahocevar ahocevar merged commit aa6d6b4 into openlayers:main Aug 18, 2020
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.

None yet

3 participants