Repository: ipython/ipython

Issue:
Event callbacks which register/unregister other callbacks lead to surprising behavior
tl;dr: the loop in [this line](https://github.com/ipython/ipython/blob/1d63c56f6d69d9f4cc47adcb87a7f80744a48260/IPython/core/events.py#L72) isn't robust to `register`/`unregister`. What's the desired behavior?
### The problem

We ran into a funny situation in colaboratory, involving `post_run_cell` callbacks which need to potentially add or remove other `post_run_cell` callbacks. Here's a really simplified version:

``` python
ip = get_ipython()
ev = ip.events

def func1(*unused_args):
    print 'invoking func1'
    ev.unregister('post_run_cell', func1)
    ev.register('post_run_cell', func3)

def func2(*unused_args):
    print 'invoking func2'
    ev.unregister('post_run_cell', func2)

def func3(*unused_args):
    print 'invoking func3'

ev.register('post_run_cell', func1)
ev.register('post_run_cell', func2)
```

In principle, this should invoke the three functions in order. In reality, it invokes `func1` and `func3`, but _not_ `func2`. Even worse, at the end of this cell's execution, the list of registered callbacks is `[func2, func3]`, which is _not_ the list of callbacks we saw execute. So `func2`, the only function that stays in the list of callbacks the whole time, is the only one we don't execute. 😉 

The cause is easy to see after checking out [this line](https://github.com/ipython/ipython/blob/1d63c56f6d69d9f4cc47adcb87a7f80744a48260/IPython/core/events.py#L72): 
- on the first iteration of the loop, `func1` is our callback, and `[func1, func2]` is our list of callbacks. `func1` executes, and our list of callbacks is now `[func2, func3]`.
- the next loop iteration picks up at the second element of the list, namely `func3`. unfortunately, we've now skipped `func2`. sadface.

The lesson is, of course, never mutate a list as you iterate over it.
## Potential solutions

I'm happy to send a PR, but first, I want to make sure we're on the same page about what the **desired** semantics are here. 

I think we want to be flexible: given that the only exposed operations for callbacks are remove and append (and thankfully [**not** reset](https://github.com/ipython/ipython/commit/4043b271fee4f6c36c99c8038018d54cd86b94eb)), we can ensure
1. we execute new callbacks appended to the list, and 
2. we skip removed callbacks if we haven't already executed them.

Other options all involve preventing this behavior. We could freeze state by instead making a copy of the list before we iterate over it, whence any modifications would only be visible on the next triggering of the event. Or we could add code to completely disallow modifications to the currently-executing list of callbacks for the duration of `trigger`, meaning a user would get an error if they tried.
## Our use case

A common pattern we've used is "one-shot callbacks" -- a callback which, when invoked, removes itself from the list of callbacks. We use this for some forms of output manipulation, as well as occasionally to do one-time cleanup that needs to happen after the user's current code completes. 

With the proposed solution, this is easy -- the body of a callback just includes a call to deregister itself. This currently works great, unless the one-shot callback is not the last callback in the list. (The example above was distilled from a variant of this -- a one-shot callback was registering another one-shot callback.) It also allows for forcing a callback to be idempotent -- register as many times as you'd like, and the first one can deregister any copies it finds.

With either of the other solutions, we're stuck basically inventing our own event system, and using it to handle cleanup.



Discussion:
Thanks for the thorough description!

In the abstract, I'd expect that newly registered handlers run the next time the event fires, but not for the event that's already being handled - i.e. we take a copy of the list of callbacks before running any of them.

That would still allow for a one-shot callback, I think - it would remove itself from the original list, and we'd continue iterating over the unchanged copy for the current event. It wouldn't allow one callback to remove another before it executes for the current event, but that seems like the kind of coupling that the event system shouldn't support: callbacks shouldn't depend on the order they're registered in.

Yes. That would probably the most natural behavior though if you notice the current implementation does exactly the opposite. It executes newly added callbacks but completely breaks down if one unregister during the callback. 

If I were writing it from scratch I would probably go for that behavior but if you want to keep backwards compatibility one would need to do something more complicated.

FTR: We are happy to adopt Thomas's semantic, (of freezing the list) 
just wanted to point out that it breaks existing behavior, however i am not aware of any significant use cases that relies on the behavior being broken..  

Thanks Mark. I think I'm happy enough to say that one callback adding another to run on the same event is a corner case. I don't think the event system has seen much use yet, so I'd lean towards doing what makes most sense over striving to preserve compatibility.


Resolving patch:
diff --git a/IPython/core/events.py b/IPython/core/events.py
--- a/IPython/core/events.py
+++ b/IPython/core/events.py
@@ -69,7 +69,7 @@ def trigger(self, event, *args, **kwargs):
         Any additional arguments are passed to all callbacks registered for this
         event. Exceptions raised by callbacks are caught, and a message printed.
         """
-        for func in self.callbacks[event]:
+        for func in self.callbacks[event][:]:
             try:
                 func(*args, **kwargs)
             except Exception:


Test patch:
diff --git a/IPython/core/tests/test_events.py b/IPython/core/tests/test_events.py
--- a/IPython/core/tests/test_events.py
+++ b/IPython/core/tests/test_events.py
@@ -30,3 +30,25 @@ def test_cb_error(self):
         self.em.register('ping_received', cb)
         with tt.AssertPrints("Error in callback"):
             self.em.trigger('ping_received')
+
+    def test_unregister_during_callback(self):
+        invoked = [False] * 3
+        
+        def func1(*_):
+            invoked[0] = True
+            self.em.unregister('ping_received', func1)
+            self.em.register('ping_received', func3)
+
+        def func2(*_):
+            invoked[1] = True
+            self.em.unregister('ping_received', func2)
+
+        def func3(*_):
+            invoked[2] = True
+            
+        self.em.register('ping_received', func1)
+        self.em.register('ping_received', func2)
+
+        self.em.trigger('ping_received')
+        self.assertEqual([True, True, False], invoked)
+        self.assertEqual([func3], self.em.callbacks['ping_received'])