diff --git a/docs/changelog.rst b/docs/changelog.rst
index 6922f23a..7ad71327 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,10 @@ Changelog
`CalVer, YY.month.patch `_
+24.9.4
+======
+- Add :ref:`ASYNC122 ` delayed-entry-of-relative-cancelscope.
+
24.9.3
======
- :ref:`ASYNC102 ` and :ref:`ASYNC120 `:
@@ -19,7 +23,7 @@ Changelog
24.9.1
======
-- Add :ref:`ASYNC121 ` control-flow-in-taskgroup
+- Add :ref:`ASYNC121 ` control-flow-in-taskgroup.
24.8.1
======
@@ -37,7 +41,7 @@ Changelog
24.5.5
======
-- Add :ref:`ASYNC300 ` create-task-no-reference
+- Add :ref:`ASYNC300 ` create-task-no-reference.
24.5.4
======
diff --git a/docs/rules.rst b/docs/rules.rst
index e7ff3324..a1815362 100644
--- a/docs/rules.rst
+++ b/docs/rules.rst
@@ -86,6 +86,8 @@ _`ASYNC120` : await-in-except
_`ASYNC121`: control-flow-in-taskgroup
`return`, `continue`, and `break` inside a :ref:`taskgroup_nursery` can lead to counterintuitive behaviour. Refactor the code to instead cancel the :ref:`cancel_scope` inside the TaskGroup/Nursery and place the statement outside of the TaskGroup/Nursery block. In asyncio a user might expect the statement to have an immediate effect, but it will wait for all tasks to finish before having an effect. See `Trio issue #1493 `_ for further issues specific to trio/anyio.
+_`ASYNC122`: delayed-entry-of-relative-cancelscope
+ :func:`trio.move_on_after`, :func:`trio.fail_after`, :func:`anyio.move_on_after` and :func:`anyio.fail_after` behaves unintuitively if initialization and entry are separated, with the timeout starting on initialization. Trio>=0.27 changes this behaviour, so if you don't support older versions you should disable this check. See `Trio issue #2512 `_.
Blocking sync calls in async functions
======================================
diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py
index b048d557..af042280 100644
--- a/flake8_async/__init__.py
+++ b/flake8_async/__init__.py
@@ -38,7 +38,7 @@
# CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1"
-__version__ = "24.9.3"
+__version__ = "24.9.4"
# taken from https://github.com/Zac-HD/shed
diff --git a/flake8_async/visitors/visitors.py b/flake8_async/visitors/visitors.py
index ec7ee1d5..6551f9ec 100644
--- a/flake8_async/visitors/visitors.py
+++ b/flake8_async/visitors/visitors.py
@@ -410,6 +410,33 @@ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef):
visit_AsyncFunctionDef = visit_FunctionDef
+@error_class
+class Visitor122(Flake8AsyncVisitor):
+ error_codes: Mapping[str, str] = {
+ "ASYNC122": (
+ "Separating initialization from entry of {} changed behavior in Trio"
+ " 0.27, and was unintuitive before then. If you only support"
+ " trio>=0.27 you should disable this check."
+ )
+ }
+
+ def __init__(self, *args: Any, **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self.in_withitem = False
+
+ def visit_withitem(self, node: ast.withitem):
+ self.save_state(node, "in_withitem")
+ self.in_withitem = True
+
+ def visit_Call(self, node: ast.Call):
+ if not self.in_withitem and (
+ match := get_matching_call(
+ node, "fail_after", "move_on_after", base=("trio", "anyio")
+ )
+ ):
+ self.error(node, f"{match[2]}.{match[1]}")
+
+
@error_class_cst
class Visitor300(Flake8AsyncVisitor_cst):
error_codes: Mapping[str, str] = {
diff --git a/tests/eval_files/async122.py b/tests/eval_files/async122.py
new file mode 100644
index 00000000..4a8a96eb
--- /dev/null
+++ b/tests/eval_files/async122.py
@@ -0,0 +1,35 @@
+# ASYNCIO_NO_ERROR
+
+import trio
+
+
+def safe():
+ with trio.move_on_after(5):
+ ...
+ with open("hello"), trio.move_on_after(5):
+ ...
+
+
+def separated():
+ k = trio.move_on_after(5) # ASYNC122: 8, "trio.move_on_after"
+
+ with k:
+ ...
+
+ l = trio.fail_after(5) # ASYNC122: 8, "trio.fail_after"
+ with l:
+ ...
+
+
+def fancy_thing_we_dont_cover():
+ # it's hard to distinguish this bad case
+ kk = trio.fail_after
+
+ ll = kk(5)
+
+ with ll:
+ ...
+ # from this good case
+ with kk(5):
+ ...
+ # so we don't bother
diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py
index 81407500..5219f274 100644
--- a/tests/test_flake8_async.py
+++ b/tests/test_flake8_async.py
@@ -481,6 +481,7 @@ def _parse_eval_file(
# opening nurseries & taskgroups can only be done in async context, so ASYNC121
# doesn't check for it
"ASYNC121",
+ "ASYNC122",
"ASYNC300",
"ASYNC912",
}