Skip to content

Commit d1968c6

Browse files
committed
Do not release a shared request when a borrowing reader walks away
1 parent 48ddb07 commit d1968c6

2 files changed

Lines changed: 125 additions & 18 deletions

File tree

‎src/reactpy/_event_proxy.py‎

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,10 @@ async def resolve(
322322
shared = self._inflight.get(key)
323323
if shared is not None:
324324
# Somebody else has already asked for this, so there is no sense in
325-
# asking twice - whether or not the answer is wanted fresh
326-
payload = await self._await(*shared, event_id, path)
325+
# asking twice - whether or not the answer is wanted fresh. The one who
326+
# asked owns the request and releases it; this reader only borrows the
327+
# answer, and walking away early must not take the request with it.
328+
payload = await self._await(*shared, event_id, path, owner=False)
327329
return self._to_value(payload, event_id, path)
328330

329331
request_id, future = self._enqueue(event_id, path, call, args, reply=True)
@@ -363,7 +365,7 @@ async def resolve_many(
363365
"""
364366
values: list[Any] = [None] * len(paths)
365367
outstanding: list[
366-
tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None]]
368+
tuple[int, tuple[str, tuple[str, ...]], tuple[str, Future | None], bool]
367369
] = []
368370
ours: list[tuple[str, tuple[str, ...]]] = []
369371
for index, path in enumerate(paths):
@@ -373,18 +375,21 @@ async def resolve_many(
373375
continue
374376
shared = self._inflight.get(key)
375377
if shared is not None:
376-
outstanding.append((index, key, shared))
378+
# Somebody else asked for this one and is waiting on it too; it is
379+
# theirs to release, and this reader walking away must not take it
380+
# away from under them.
381+
outstanding.append((index, key, shared, False))
377382
continue
378383
entry = self._enqueue(event_id, path, False, (), True)
379384
self._inflight[key] = entry
380385
ours.append(key)
381-
outstanding.append((index, key, entry))
386+
outstanding.append((index, key, entry, True))
382387

383388
try:
384389
payloads = await asyncio.gather(
385390
*(
386-
self._await(request_id, future, event_id, paths[index])
387-
for index, _, (request_id, future) in outstanding
391+
self._await(request_id, future, event_id, paths[index], owner)
392+
for index, _, (request_id, future), owner in outstanding
388393
),
389394
# One path going wrong must not throw away the answers the client
390395
# already gave to its neighbours in the same batch.
@@ -395,7 +400,7 @@ async def resolve_many(
395400
self._inflight.pop(key, None)
396401

397402
failure: BaseException | None = None
398-
for (index, key, _), payload in zip(outstanding, payloads, strict=True):
403+
for (index, key, _, _), payload in zip(outstanding, payloads, strict=True):
399404
if isinstance(payload, asyncio.CancelledError):
400405
raise payload # nocov - nothing cancels a request which is in flight
401406
if isinstance(payload, BaseException):
@@ -450,23 +455,42 @@ async def _await(
450455
future: Future | None,
451456
event_id: str,
452457
path: Sequence[str | int],
458+
owner: bool = True,
453459
) -> dict[str, Any]:
454460
if future is None: # nocov - only reply=False requests have no future
455461
return {"ok": True, "value": None, "error": None, "code": None}
462+
timeout = REACTPY_EVENT_PROXY_TIMEOUT.current
463+
if not owner:
464+
# This is somebody else's request, so nothing here is ours to give up. The
465+
# wait is shielded: walking away must not cancel the answer the reader which
466+
# asked is still waiting to collect, and a wait of our own which ran out
467+
# says so rather than striking a round trip which that reader is still due
468+
# an answer to.
469+
try:
470+
return await asyncio.wait_for(
471+
asyncio.shield(asyncio.wrap_future(future)), timeout
472+
)
473+
except (TimeoutError, FuturesTimeoutError):
474+
msg = self._timeout_message(event_id, path)
475+
raise EventProxyTimeout(msg) from None
456476
try:
457-
return await asyncio.wait_for(
458-
asyncio.wrap_future(future), REACTPY_EVENT_PROXY_TIMEOUT.current
459-
)
477+
return await asyncio.wait_for(asyncio.wrap_future(future), timeout)
460478
except (TimeoutError, FuturesTimeoutError):
461479
self._handle_timeout(request_id, event_id, path)
462480
finally:
463481
# Whatever is still recorded for this request at this point belongs to
464482
# nobody. Either the answer came in and was routed, or it went unanswered
465-
# and was counted as such - or the reader itself went away, which is the
466-
# one case which would otherwise leave a request waiting forever for a
467-
# handler which is no longer there to be woken by it.
483+
# and was counted as such - or the reader itself went away, which is the one
484+
# case which would otherwise leave a request waiting forever for a handler
485+
# which is no longer there to be woken by it.
468486
self._take(request_id)
469487

488+
def _timeout_message(self, event_id: str, path: Sequence[str | int]) -> str:
489+
return (
490+
f"the client did not answer the request for {list(path)!r} of event "
491+
f"{event_id!r} within {REACTPY_EVENT_PROXY_TIMEOUT.current}s"
492+
)
493+
470494
def _enqueue(
471495
self,
472496
event_id: str,
@@ -588,10 +612,7 @@ def _handle_timeout(
588612
self, request_id: str, event_id: str, path: Sequence[str | int]
589613
) -> NoReturn:
590614
self._strike(request_id)
591-
msg = (
592-
f"the client did not answer the request for {list(path)!r} of event "
593-
f"{event_id!r} within {REACTPY_EVENT_PROXY_TIMEOUT.current}s"
594-
)
615+
msg = self._timeout_message(event_id, path)
595616
raise EventProxyTimeout(msg)
596617

597618
def _strike(self, request_id: str) -> None:

‎tests/test_core/test_event_proxy.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,92 @@ async def test_a_reader_which_went_away_releases_its_request():
13111311
await again
13121312

13131313

1314+
async def test_a_reader_which_only_borrowed_an_answer_releases_nothing():
1315+
"""Two readers of one property share the request, and only one of them owns it
1316+
1317+
The second handler to want a property borrows the answer of the first rather than
1318+
asking the client twice. When that second reader goes away it is not its request to
1319+
release: the handler which asked is still there, still waiting, and still expects the
1320+
answer to find it.
1321+
"""
1322+
backend, client = make_client(make_objects(), respond=False)
1323+
event_object = make_event(backend, "evt")
1324+
1325+
first = asyncio.create_task(event_object.read("target"))
1326+
await poll(lambda: len(client.requests) == 1)
1327+
request_id = next(iter(backend._pending))
1328+
1329+
# The borrower wants one property which is being asked for and one which is not, so
1330+
# the request it sends itself is proof that it joined rather than asked again.
1331+
borrower = asyncio.create_task(event_object.read("target", "type"))
1332+
await poll(lambda: len(client.requests) == 2)
1333+
assert requested_paths(client)[1] == ["type"], "only what it lacks goes out"
1334+
1335+
borrower.cancel()
1336+
with pytest.raises(asyncio.CancelledError):
1337+
await borrower
1338+
1339+
assert request_id in backend._pending, "the one still waiting keeps its request"
1340+
1341+
backend.handle_response(
1342+
{
1343+
"type": EVENT_PROXY_RESPONSE_TYPE,
1344+
"requestId": request_id,
1345+
"ok": True,
1346+
"value": "typed",
1347+
"error": None,
1348+
"code": None,
1349+
},
1350+
)
1351+
assert await first == ("typed",)
1352+
assert backend._pending == {}
1353+
1354+
1355+
async def test_a_reader_which_borrowed_an_answer_gives_up_on_its_own_clock(
1356+
monkeypatch,
1357+
):
1358+
"""A borrower runs out of patience on its own, without costing anyone else
1359+
1360+
Waiting on somebody else's request is held to the same deadline as waiting on one's
1361+
own, so a client which has stopped answering cannot strand a handler which merely
1362+
borrowed an answer. Giving up is the borrower's own affair though: the request stays
1363+
where the reader which asked left it, and it is that reader whose round trip is
1364+
counted as having gone unanswered.
1365+
"""
1366+
backend, client = make_client(make_objects(), respond=False)
1367+
event_object = make_event(backend, "evt")
1368+
1369+
# Each reader is held to the clock which was in force when its own wait began, so
1370+
# the borrower gives up while the reader which asked is still waiting.
1371+
monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 30)
1372+
owner = asyncio.create_task(event_object.read("target"))
1373+
await poll(lambda: len(client.requests) == 1)
1374+
request_id = next(iter(backend._pending))
1375+
1376+
monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05)
1377+
borrower = asyncio.create_task(event_object.read("target", "type"))
1378+
await poll(lambda: len(client.requests) == 2)
1379+
1380+
with pytest.raises(EventProxyTimeout):
1381+
await borrower
1382+
1383+
assert not owner.done(), "the reader which asked is still waiting for its answer"
1384+
assert request_id in backend._pending, "its request stays where it was left"
1385+
assert backend._timeouts == 1, "only the round trip which was the borrower's own"
1386+
1387+
backend.handle_response(
1388+
{
1389+
"type": EVENT_PROXY_RESPONSE_TYPE,
1390+
"requestId": request_id,
1391+
"ok": True,
1392+
"value": "typed",
1393+
"error": None,
1394+
"code": None,
1395+
},
1396+
)
1397+
assert await owner == ("typed",)
1398+
1399+
13141400
async def test_requests_may_not_pile_up_on_a_client_which_is_not_answering(monkeypatch):
13151401
"""There is a limit to how much a client can be asked at once
13161402

0 commit comments

Comments
 (0)