Skip to content

Commit 1a38a47

Browse files
authored
Merge pull request #253 from rstudio/session-iteration-error
Fix errors resulting from session close
2 parents 70d97bc + d78d1b1 commit 1a38a47

File tree

5 files changed

+60
-5
lines changed

5 files changed

+60
-5
lines changed

.github/workflows/pytest.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ jobs:
7575
- name: Run unit tests
7676
if: steps.install.outcome == 'success' && (success() || failure())
7777
run: |
78-
pwd
79-
cd tests
80-
pytest
78+
make test
8179
8280
- name: Type check with pyright
8381
if: steps.install.outcome == 'success' && (success() || failure())

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ lint: ## check style with flake8
5858
flake8 --show-source --max-line-length=127 shiny tests examples
5959

6060
test: ## run tests quickly with the default Python
61+
python3 tests/asyncio_prevent.py
6162
pytest
6263

6364
test-all: ## run tests on every Python version with tox

shiny/_app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,9 @@ def _request_flush(self, session: Session) -> None:
310310
async def _flush_pending_sessions(self) -> None:
311311
# TODO: Until we have reactive domains, flush all sessions (because we
312312
# can't yet keep track of which ones need a flush)
313-
for _, session in self._sessions.items():
313+
# Use list() to create a copy, in case self._sessions mutates from under us,
314+
# which would cause a "dictionary changed size during iteration" RuntimeError
315+
for _, session in list(self._sessions.items()):
314316
await session._flush()
315317
# for id, session in self._sessions_needing_flush.items():
316318
# await session.flush()

shiny/reactive/_core.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,24 @@ def __init__(self) -> None:
119119
)
120120
self._next_id: int = 0
121121
self._pending_flush_queue: PriorityQueueFIFO[Context] = PriorityQueueFIFO()
122-
self.lock = asyncio.Lock()
122+
self._lock: Optional[asyncio.Lock] = None
123123
self._flushed_callbacks = _utils.AsyncCallbacks()
124124

125+
@property
126+
def lock(self) -> asyncio.Lock:
127+
"""
128+
Lock that protects this ReactiveEnvironment. It must be lazily created, because
129+
at the time the module is loaded, there generally isn't a running asyncio loop
130+
yet. This causes the asyncio.Lock to be created with a different loop than it
131+
will be invoked from later; when that happens, acquire() will succeed if there's
132+
no contention, but throw a "hey you're on the wrong loop" error if there is.
133+
"""
134+
if self._lock is None:
135+
# Ensure we have a loop; get_running_loop() throws an error if we don't
136+
asyncio.get_running_loop()
137+
self._lock = asyncio.Lock()
138+
return self._lock
139+
125140
def next_id(self) -> int:
126141
"""Return the next available id"""
127142
id = self._next_id

tests/asyncio_prevent.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""This script is NOT designed to be run as part of a pytest suite; it demands a clean
2+
Python process because it needs to be run before the shiny module is loaded.
3+
4+
The purpose of this script is to ensure that no shiny module initialization code uses
5+
the event loop. The running event loop during shiny module startup is different than the
6+
running event loop during the app's operation (I assume uvicorn creates the latter). The
7+
creation of, say, an asyncio.Lock during the course of shiny module startup will result
8+
in race conditions if the lock is used during the app's operation."""
9+
10+
if __name__ == "__main__":
11+
import sys
12+
import asyncio
13+
import importlib
14+
from typing import Optional
15+
16+
if "shiny" in sys.modules:
17+
raise RuntimeError(
18+
"Bad test: shiny was already loaded, it's important that SpyEventLoopPolicy"
19+
" is installed before shiny loads"
20+
)
21+
22+
class SpyEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
23+
def get_event_loop(self):
24+
raise RuntimeError("get_event_loop called")
25+
26+
def set_event_loop(self, loop: Optional[asyncio.AbstractEventLoop]):
27+
raise RuntimeError("set_event_loop called")
28+
29+
def new_event_loop(self):
30+
raise RuntimeError("new_event_loop called")
31+
32+
asyncio.set_event_loop_policy(SpyEventLoopPolicy())
33+
34+
# Doing this instead of "import shiny" so no linter is tempted to remove it
35+
importlib.import_module("shiny")
36+
sys.stderr.write(
37+
"Success; shiny module loading did not attempt to access an asyncio event "
38+
"loop\n"
39+
)

0 commit comments

Comments
 (0)