Repository: pallets/werkzeug

Issue:
LocalStack class has strange behavior under asyncio
The new `LocalStack` implementation based on the contextvars module has a strange behavior when used under asyncio. The problem appears to be related to the inheritance of contexts that occur between a parent and children async tasks, combined with the fact that the stack is implemented with a mutable list.

Consider the following example:

```python
import asyncio
from werkzeug.local import LocalStack

ls = LocalStack()

async def task(n):
    ls.push(n)
    for i in range(3):
        await asyncio.sleep(1)
        print(f'task {n}: {ls.top}')

async def main():
    tasks = [asyncio.create_task(task(i)) for i in range(3)]
    await asyncio.wait(tasks)

asyncio.run(main())
```

This works perfectly fine, with each task having its own private stack. The output is:

```
(venv) $ python local.py
task 0: 0
task 1: 1
task 2: 2
task 0: 0
task 1: 1
task 2: 2
task 0: 0
task 1: 1
task 2: 2
```

Now I change the `main()` function to also push something to the stack:

```python
async def main():
    ls.push(42)
    tasks = [asyncio.create_task(task(i)) for i in range(3)]
    await asyncio.wait(tasks)
```

Now all the tasks have a single shared stack and a push from one task directly affect the others:

```
task 0: 2
task 1: 2
task 2: 2
task 0: 2
task 1: 2
task 2: 2
task 0: 2
task 1: 2
task 2: 2
```

The problem is that `LocalStack` uses a list to keep the stack. If this list is initialized before the child tasks are started, then these tasks will inherit the list from the parent task, and assume it is theirs to push/pop. Since the list is mutable, changes to the list affect all tasks.

If, on the other side, the child tasks are started with the local stack uninitialized, then the tasks will inherit an empty context, so then each will create their own mutable list and everything works as expected.

Environment:

- Python version: 3.7 and up
- Werkzeug version: 2.x release candidates



Discussion:

Resolving patch:
diff --git a/src/werkzeug/local.py b/src/werkzeug/local.py
--- a/src/werkzeug/local.py
+++ b/src/werkzeug/local.py
@@ -219,10 +219,9 @@ def _lookup():
 
     def push(self, obj: t.Any) -> t.List[t.Any]:
         """Pushes a new item to the stack"""
-        rv = getattr(self._local, "stack", None)
-        if rv is None:
-            self._local.stack = rv = []
+        rv = getattr(self._local, "stack", []).copy()
         rv.append(obj)
+        self._local.stack = rv
         return rv
 
     def pop(self) -> t.Any:


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -106,6 +106,23 @@ def test_local_stack():
     assert repr(proxy) == "<LocalProxy unbound>"
 
 
+@pytest.mark.skipif(
+    sys.version_info < (3, 7),
+    reason="Locals are not task local in Python 3.6",
+)
+def test_local_stack_asyncio():
+    ls = local.LocalStack()
+    ls.push(1)
+
+    async def task():
+        ls.push(1)
+        assert len(ls._local.stack) == 2
+
+    loop = asyncio.get_event_loop()
+    futures = [asyncio.ensure_future(task()) for _ in range(3)]
+    loop.run_until_complete(asyncio.gather(*futures))
+
+
 @pytest.mark.skipif(
     sys.version_info > (3, 6),
     reason="The ident is not supported in  Python3.7 or higher",