Skip to content

Commit

Permalink
Avoid asyncio.sleep() hanging forever when time is frozen
Browse files Browse the repository at this point in the history
The following code:

    async def test():
        with freeze_time("2020-01-01"):
            await asyncio.sleep(0.01)

hangs forever since FreezeGun 1.1.0 because 1.1.0 started patching
`time.monotonic()` (see #369) which is used internally by `asyncio`
event loops to schedule code for execution in the future. This breaks
many projects that uses FreezeGun to test asynchronous code.

We fix this by changing `freeze_time` to patch asyncio event loop's
`time()` method in a way that it uses real monotonic time instead of the
frozen one. Note that we couldn't achieve this by adding `asyncio` to
`DEFAULT_IGNORE_LIST` in `freezegun/config.py` because any running async
code has functions from the `asyncio` module on its stack -- adding
`asyncio` to the ignore list would just disable freezing time in any
async code. This is why we patch one method of a specific class instead.

This change not only fixes `asyncio.sleep()` but also things like
`asyncio.get_running_loop().call_later` (for scheduling task execution
in the future) which in turn makes things like timeouts work in async
code while time is frozen. This may not be desired because some users
may expect that execution of events scheduled to happen in the future
can be controlled using FreezeGun. However, it's not easy to distinguish
between things that users would like to see frozen time and those which
should not (like `asyncio.sleep()`) because all of them use the same
clock. Therefore, we opt for making all `asyncio` internals not affected
by FreezeGun.

We also add more tests that verify how FreezeGun interacts with asyncio
code, including tests that cover the scenario described in #437 which we
aim to fix.

Closes #401
Closes #437
  • Loading branch information
marcinsulikowski committed Feb 20, 2023
1 parent 96c2741 commit 89d36e2
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 2 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ Patches and Suggestions
- `Lukasz Balcerzak <https://github.com/lukaszb>`_
- `Hannes Ljungberg <hannes@5monkeys.se>`_
- `staticdev <staticdev-support@proton.me>`_
- `Marcin Sulikowski <https://github.com/marcinsulikowski>`_
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Freezegun Changelog
===================

1.3.0
-----

* Fixed `asyncio` support to avoid `await asyncio.sleep(1)` hanging forever.

1.2.2
-----

Expand Down
20 changes: 18 additions & 2 deletions freezegun/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from . import config
from ._async import wrap_coroutine
import asyncio
import copyreg
import dateutil
import datetime
Expand Down Expand Up @@ -726,6 +727,21 @@ def start(self):
setattr(module, attribute_name, fake)
add_change((module, attribute_name, attribute_value))

# To avoid breaking `asyncio.sleep()`, let asyncio event loops see real
# monotonic time even though we've just frozen `time.monotonic()` which
# is normally used there. If we didn't do this, `await asyncio.sleep()`
# would be hanging forever breaking many tests that use `freeze_time`.
#
# Note that we cannot statically tell the class of asyncio event loops
# because it is not officially documented and can actually be changed
# at run time using `asyncio.set_event_loop_policy`. That's why we check
# the type by creating a loop here and destroying it immediately.
event_loop = asyncio.new_event_loop()
event_loop.close()
EventLoopClass = type(event_loop)
add_change((EventLoopClass, "time", EventLoopClass.time))
EventLoopClass.time = lambda self: real_monotonic()

return freeze_factory

def stop(self):
Expand All @@ -739,8 +755,8 @@ def stop(self):
datetime.date = real_date
copyreg.dispatch_table.pop(real_datetime)
copyreg.dispatch_table.pop(real_date)
for module, module_attribute, original_value in self.undo_changes:
setattr(module, module_attribute, original_value)
for module_or_object, attribute, original_value in self.undo_changes:
setattr(module_or_object, attribute, original_value)
self.undo_changes = []

# Restore modules loaded after start()
Expand Down
68 changes: 68 additions & 0 deletions tests/test_asyncio.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import datetime
import time

from freezegun import freeze_time

Expand All @@ -10,3 +11,70 @@ async def frozen_coroutine():
assert datetime.date.today() == datetime.date(1970, 1, 1)

asyncio.run(frozen_coroutine())


def test_freezing_time_in_coroutine():
"""Test calling freeze_time while executing asyncio loop."""
async def coroutine():
with freeze_time('1970-01-02'):
assert time.time() == 86400
with freeze_time('1970-01-03'):
assert time.time() == 86400 * 2

asyncio.run(coroutine())


def test_freezing_time_before_running_coroutine():
"""Test calling freeze_time before executing asyncio loop."""
async def coroutine():
assert time.time() == 86400
with freeze_time('1970-01-02'):
asyncio.run(coroutine())


def test_asyncio_sleeping_not_affected_by_freeze_time():
"""Test that asyncio.sleep() is not affected by `freeze_time`.
This test ensures that despite freezing time using `freeze_time`,
the asyncio event loop can see real monotonic time, which is required
to make things like `asyncio.sleep()` work.
"""

async def coroutine():
# Sleeping with time frozen should sleep the expected duration.
before_sleep = time.time()
with freeze_time('1970-01-02'):
await asyncio.sleep(0.05)
assert 0.02 <= time.time() - before_sleep < 0.3

# Exiting `freeze_time` the time should not break asyncio sleeping.
before_sleep = time.time()
await asyncio.sleep(0.05)
assert 0.02 <= time.time() - before_sleep < 0.3

asyncio.run(coroutine())


def test_asyncio_to_call_later_with_frozen_time():
"""Test that asyncio `loop.call_later` works with frozen time."""
# `to_call_later` will be called by asyncio event loop and should add
# the Unix timestamp of 1970-01-02 00:00 to the `timestamps` list.
timestamps = []
def to_call_later():
timestamps.append(time.time())

async def coroutine():
# Schedule calling `to_call_later` in 100 ms.
asyncio.get_running_loop().call_later(0.1, to_call_later)

# Sleeping for 10 ms should not result in calling `to_call_later`.
await asyncio.sleep(0.01)
assert timestamps == []

# But sleeping more (150 ms in this case) should call `to_call_later`
# and we should see `timestamps` updated.
await asyncio.sleep(0.15)
assert timestamps == [86400]

with freeze_time('1970-01-02'):
asyncio.run(coroutine())

0 comments on commit 89d36e2

Please sign in to comment.