classification
Title: infinite loop resulted by "yield"
Type: crash Stage:
Components: Interpreter Core Versions: Python 3.10, Python 3.9, Python 3.8, Python 3.7
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: Nosy List: Mark.Shannon, rhettinger, stestagg, xxm
Priority: normal Keywords:

Created on 2020-12-28 04:38 by xxm, last changed 2021-01-14 01:32 by xxm.

Messages (11)
msg383882 - (view) Author: Xinmeng Xia (xxm) Date: 2020-12-28 04:38
Let's see the following program:

============================
def foo():
    try:
        yield
    except:
        yield from foo()

for m in foo():
    print(i)
===========================

Expected output:
On line"print(i)",  NameError: name 'i' is not defined


However, the program will fall into infinite loops when running it on Python 3.7-3.10 with the error messages like the following.(no infinite loop on Python 3.5 and Python 3.6)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/xxm/Desktop/nameChanging/report/test1.py", line 160, in <module>
    print(i)
RuntimeError: generator ignored GeneratorExit
Exception ignored in: <generator object foo at 0x7fb30ff639e0>
Traceback (most recent call last):
  File "/home/xxm/Desktop/nameChanging/report/test1.py", line 160, in <module>
    print(i)
RuntimeError: generator ignored GeneratorExit
Exception ignored in: <generator object foo at 0x7fb30ff63a50>
Traceback (most recent call last):
  File "/home/xxm/Desktop/nameChanging/report/test1.py", line 160, in <module>
    print(i)
RuntimeError: generator ignored GeneratorExit
Exception ignored in: <generator object foo at 0x7fb30ff63ac0>
Traceback (most recent call last):
  File "/home/xxm/Desktop/nameChanging/report/test1.py", line 160, in <module>
    print(i)
......
----------------------------------------------------------------------
msg383921 - (view) Author: Steve Stagg (stestagg) Date: 2020-12-28 19:32
Behaviour was changed in this commit:
---
commit ae3087c6382011c47db82fea4d05f8bbf514265d
Author: Mark Shannon <mark@hotpy.org>
Date:   Sun Oct 22 22:41:51 2017 +0100

    Move exc state to generator. Fixes bpo-25612 (#1773)

    Move exception state information from frame objects to coroutine (generator/thread) object where it belongs.

---

I'm honestly not sure that the older behaviour was 'better' than current.  But I don't know the defined behaviours well enough to be certain
msg383950 - (view) Author: Xinmeng Xia (xxm) Date: 2020-12-29 03:31
Thanks for your kind explanation! My description is a little confusing. Sorry about that. The problem here is that the program should stop when the exception is caught whatever the exception is. (I think bpo-25612 (#1773) fixed exception selection problems and right exception can be caught) The fact is this program will fall into an infinite loop. Error messages are printed in a dead loop. The program doesn't stop. This is not normal. There will be no loops in Python 3.5 and 3.6 (Attached output in Python 3.5 and 3.6). 

 

Output on Python 3.5,3.6 (all errors are printed without any loop )
-----------------------------------------------------
Exception ignored in: <generator object foo at 0x7f4cde5b8b48>
RuntimeError: generator ignored GeneratorExit
Traceback (most recent call last):
  File "/home/xxm/Desktop/nameChanging/report/test1.py", line 248, in <module>
    print(i)
NameError: name 'i' is not defined
Exception ignored in: <generator object foo at 0x7f4cde5b8bf8>
RuntimeError: generator ignored GeneratorExit
Exception ignored in: <generator object foo at 0x7f4cde5b8ca8>
RuntimeError: generator ignored GeneratorExit
--------------------------------------------------------
msg384013 - (view) Author: Steve Stagg (stestagg) Date: 2020-12-29 16:06
I'm sorry, I did get a bit confused earlier, I'd mentally switched to context managers.

I agree this is a bug, and a kinda weird one!

I've narrowed it down to this:

If an exception causes flow to exit a for-loop that's powered by a generator, then when the generator object is deleted, GeneratorExit() is incorrectly raised in the generator.

This can be shown with the following example (easier to debug without the infinite loop):

---
def foo():
    try:
        yield
    except:
        print("!!! WE SHOULDN'T BE HERE!!!")

x = foo()
try:
    for _ in x:
       print(i)
except NameError:
    pass 

print("LOOP DONE")
del x   # <--- We shouldn't be here printed on this line.
print("FINAL")
---

As you discovered, if you change print(i) to print(1), then the "shouldn't be here" line is NOT printed, but if you leave it as print(i) then the exception is printed.

You can see that the error doesn't happen until after LOOP DONE, which is because `del x` is finalizing the generator object, and the invalid exception logic happens then.

I'm trying to get more info here, if I don't by the time you come online, I'd recommend creating a *new* issue, with the non-loop example above, and explanation because I think on this issue, I've caused a lot of noise (sorry again!).
msg384014 - (view) Author: Steve Stagg (stestagg) Date: 2020-12-29 16:43
Ok, so I now understand a bit more, and think it's not a bug!  But explaining it involves some fairly deep thinking about generators.

I'll try to explain my reasoning.

Let's take a simple example:

---

def foo():
    try:
        yield
    except:
        print("ERROR")

for x in foo():
    print(1)
---


It's convenient to think of it as python adding an implicit throw StopIteration() at the end of the generator function, I'll also rewrite the code to be roughly equivalent:

---
 1. def foo():
 2.     try:
 3.         yield None
 4.     except:
 5.         print("ERROR")
 6.     #throw StopIteration():
 7. 
 8. foo_gen = foo()
 9. while True:
10.     try:
11.         x = next(foo_gen)
12.    except StopIteration:
13.         break
14.    print(1)
15. del foo_gen
---

Now, if we step through how python runs the code starting at line 8.:

8. Create foo() <- this just creates a generator object
9. Enter while loop (not interesting for now)
10. try:
11. call next() on our generator to get the next value
2. try: <- we're running the generator until we get a yield now
3. yield None <- yield None to parent code
11. x = None <- assign yielded value to x
12. except StopIteration <- no exception raised so this doesn't get triggered
14. print(1) <- Print 1 to the output
9. Reached end of while loop, return to the start
10. try:
11. Re-enter the generator to get the next value, starting from where we left it..
4. except: <- there was no exception raised, so this doesn't get triggered
6. (implicit) throw StopIteration because generator finished
12. `except StopIteration` <- this is triggered because generator threw StopIteration
13. break <- break out of while loop
15. remove foo_gen variable, and clean up generator.

<- Deleting `foo_gen` causes `.close()` to be called on the generator which causes a GeneratorExit exception to be raised in the generator, BUT generator has already finished, so the GeneratorExit does nothing.

--

This is basically how the for-loop in your example works, and you can see that there's no generator exception, BUT if we change the print(1) to print(i) and try again:

8. Create foo() <- this just creates a generator object
9. Enter while loop (not interesting for now)
10. try:
11. call next() on our generator to get the next value
2. try: <- we're running the generator until we get a yield now
3. yield None <- yield None to parent code
11. x = None <- assign yielded value to x
12. except StopIteration <- no exception raised so this doesn't get triggered
*** CHANGED BIT ***
14. print(i) <- i doesn't exist, so throw NameError
14. The exception made us exit the current stack frame, so start cleaning up/deleting local variables

<- Deleting `foo_gen` causes `.close()` to be called on the generator which causes GeneratorExit() to be raised within the generator, but the generator is currently paused on line 3. so raise exception as-if we're currently running line 3:

4. except: <- this broad except catches the GeneratorExit exception because it appears to have happened on line 3.
5. print("ERROR") <- We only get here if the above steps happened.

---

So, if you don't let a generator naturally finish itself, but stop consuming the generator before it's raised its final StopIteration, then when the variable goes out-of-scope, a GeneratorExit will be raised at the point of the last yield that it ran.

If you then catch that GeneratorExit, and enter a new un-consumed loop (as in your `yield from foo()` line), then that line will also create the same situation again in a loop..

I understand that this used to "work" in previous python versions, but actually, having dug into things a lot more, I think the current behaviour is correct, and previous behaviors were not correct.

The "bug" here is in the example code that is catching GeneratorExit and then creating a new generator in the except:, rather than anything in Python
msg384018 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2020-12-29 18:05
Even weirder, the old behavior returns if "except:" is replaced by "except BaseException as e:".

============================
def foo():
    try:
        yield
    except BaseException as e:
        yield from foo()

for m in foo():
    print(i)
msg384027 - (view) Author: Steve Stagg (stestagg) Date: 2020-12-29 20:08
That /is/ weird.  I tried a few variations, and it looks like something is being stored on the exception that is stopping the loop behaviour.

"except BaseException:"      <- infinite loop
"except BaseException as e:" <- NO loop
"except BaseException as e:
   del e
   yield from foo()"         <- infinite loop

So is this a reference being retained somewhere?
msg384028 - (view) Author: Steve Stagg (stestagg) Date: 2020-12-29 20:17
(sorry for spam!)

So, this is a retained reference issue.
If I change the script to be this:


---
import gc

DEPTH = 100

def foo():
    global DEPTH
    try:
        yield
    except BaseException as e:
        DEPTH -= 1
        if DEPTH < 1:
            return
        gc.collect()
        yield from foo()


def x():
    for m in foo():
        print(i)

try:
    x()
except:
    pass


ges = [o for o in gc.get_objects() if isinstance(o, GeneratorExit)]
if ges:
    ge, = ges
    print(gc.get_referrers(ge))
---

Then there's a reference to the GeneratorExit being retained (I guess from the exception frames, althought I thought exception frames were cleared up these days?):

[[GeneratorExit()], {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7fac8899ceb0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/home/sstagg/tmp/fuzztest/tx.py', '__cached__': None, 'gc': <module 'gc' (built-in)>, 'DEPTH': 99, 'foo': <function foo at 0x7fac887dc5e0>, 'x': <function x at 0x7fac887dc700>, 'ges': [GeneratorExit()], 'ge': GeneratorExit()}, <frame at 0x7fac8894d400, file '/home/sstagg/tmp/fuzztest/tx.py', line 14, code foo>]
Exception ignored in: <generator object foo at 0x7fac88851740>
RuntimeError: generator ignored GeneratorExit.

Given the infinite loop happens during the finalization of the generator, I think this reference is stopping the loop from going forever.

I tried removing the "as e" from the above script, and no references are retained.
msg384631 - (view) Author: Xinmeng Xia (xxm) Date: 2021-01-08 03:52
I get a little confused. So is it a bug in Python 3.5 and 3.6?
msg384911 - (view) Author: Steve Stagg (stestagg) Date: 2021-01-12 10:33
I don't believe this is a bug.

You've discovered a nasty corner-case, but I think it's expected behaviour.  There is a PEP open to make this behaviour a bit nicer:  https://www.python.org/dev/peps/pep-0533/

The fact that older Python 3.5/6 versions don't get the infinite loop could be considered a bug, but this was fixed a while ago (bpo-25612), and it seems unlikely that this fix would be back-ported.
msg385061 - (view) Author: Xinmeng Xia (xxm) Date: 2021-01-14 01:32
I see,Thank you!
History
Date User Action Args
2021-01-14 01:32:44xxmsetmessages: + msg385061
2021-01-12 10:33:20stestaggsetmessages: + msg384911
2021-01-08 03:52:18xxmsetmessages: + msg384631
2020-12-29 20:17:09stestaggsetmessages: + msg384028
2020-12-29 20:08:52stestaggsetmessages: + msg384027
2020-12-29 18:05:10rhettingersetnosy: + Mark.Shannon, rhettinger
messages: + msg384018
2020-12-29 16:43:35stestaggsetmessages: + msg384014
2020-12-29 16:06:13stestaggsetmessages: + msg384013
2020-12-29 03:31:14xxmsetmessages: + msg383950
2020-12-28 19:33:00stestaggsetnosy: + stestagg
messages: + msg383921
2020-12-28 04:38:15xxmcreate