Skip to content

Commit 01ec56f

Browse files
committed
Let a client which came back read its events again
Being switched off was a statement about the connection which was bound at the time - that the client behind it stopped answering - but it used to last for the life of the layout. A single unanswered message, a page which was reloaded or a socket which was quietly replaced, left every handler which came after it reading nothing but ``EventProxyUnavailable`` while a perfectly willing browser sat on the other end of the new connection. Binding a sender which is not the one already in place, which is what a reconnect does, now clears the switch and the timeout budget along with it. Binding the very same sender again does not, and neither does anything at all once the layout has closed.
1 parent d536803 commit 01ec56f

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎src/reactpy/_event_proxy.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def __init__(self) -> None:
161161
self._send_tasks: set[Any] = set()
162162
self._timeouts = 0
163163
self._disabled = False
164+
self._closed = False
164165
# Requests are counted against the round trip which carried them - one send of
165166
# a batch, or one answer which came back - rather than against each other.
166167
self._round_trip = 0
@@ -189,11 +190,35 @@ def set_sender(self, send: SendCoroutine) -> None:
189190
This must be called from the event loop which runs the associated layout since
190191
live access needs to know both *how* and *from which thread* to talk to the
191192
browser.
193+
194+
Binding a *different* sender also brings live access back if an earlier one was
195+
switched off. Being switched off is a statement about the connection which was
196+
bound at the time - that the client behind it stopped answering - and it says
197+
nothing about the client which has just connected. That one has yet to be given
198+
the chance to answer for itself.
192199
"""
200+
if self._send is not send:
201+
self._reactivate()
193202
self._send = send
194203
self._loop = asyncio.get_running_loop()
195204
self._loop_thread_id = threading.get_ident()
196205

206+
def _reactivate(self) -> None:
207+
"""Give a newly bound sender the chance to answer for itself
208+
209+
Without this, being switched off is permanent for the life of the layout, and a
210+
single unanswered message - a page which was reloaded, a socket which was
211+
replaced - leaves every later handler reading nothing but
212+
:class:`EventProxyUnavailable` while a perfectly willing browser sits on the
213+
other end of the connection.
214+
"""
215+
if self._closed or not (self._disabled or self._timeouts):
216+
return
217+
self._disabled = False
218+
self._timeouts = 0
219+
self._struck.clear()
220+
logger.debug("Live event access re-enabled for a newly bound sender")
221+
197222
def disable(self, reason: str) -> None:
198223
"""Stop attempting live access, for example because the client went away"""
199224
if not self._disabled:
@@ -202,8 +227,13 @@ def disable(self, reason: str) -> None:
202227
self._abandon_pending(reason)
203228

204229
def close(self) -> None:
205-
"""Abandon all in-flight requests (called when the layout shuts down)"""
230+
"""Abandon all in-flight requests (called when the layout shuts down)
231+
232+
This one is final. Binding a sender afterwards does not bring live access back,
233+
because there is no layout left for the answers to belong to.
234+
"""
206235
self._disabled = True
236+
self._closed = True
207237
self._abandon_pending("the layout was closed")
208238

209239
def _abandon_pending(self, reason: str) -> None:

‎src/reactpy/core/layout.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ def set_event_proxy_sender(
212212
This must be called from the event loop running the layout, and is what makes
213213
it possible for event handlers to read properties from, and call methods on,
214214
the browser objects which triggered them.
215+
216+
Binding a sender which is not the one already in place - which is what a
217+
reconnect does - also brings live access back if an earlier client was switched
218+
off for going quiet. A client which has just connected has not failed yet.
215219
"""
216220
self._event_proxy_sender = send
217221
backend = getattr(self, "_event_proxy_backend", None)

‎tests/test_core/test_event_proxy.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,51 @@ async def test_one_silent_batch_costs_one_timeout(monkeypatch):
10031003
assert backend.disabled, "three dead round trips is a dead connection"
10041004

10051005

1006+
async def test_a_client_which_connected_again_can_be_read_again(monkeypatch):
1007+
"""A reconnect is a new client, not the one which stopped answering
1008+
1009+
Being switched off used to last for the life of the layout, so one unanswered
1010+
message - a page which was reloaded, a socket which was quietly replaced - left
1011+
every handler which came after it reading nothing but ``EventProxyUnavailable``,
1012+
while a perfectly willing browser sat on the other end of the connection.
1013+
"""
1014+
monkeypatch.setattr(REACTPY_EVENT_PROXY_TIMEOUT, "current", 0.05)
1015+
monkeypatch.setattr(REACTPY_EVENT_PROXY_MAX_TIMEOUTS, "current", 1)
1016+
1017+
async def silence(message: dict[str, Any]) -> None:
1018+
return None
1019+
1020+
backend = EventProxyBackend()
1021+
backend.set_sender(silence)
1022+
event_object = make_event(backend, "evt")
1023+
1024+
with pytest.raises(EventProxyTimeout):
1025+
await event_object.read("target")
1026+
1027+
assert backend.disabled
1028+
backend.set_sender(silence)
1029+
assert backend.disabled, "the very same silent sender is not a second chance"
1030+
1031+
alive = CannedClient(backend, ok=True, value="typed")
1032+
backend.set_sender(alive.send)
1033+
assert backend.available, "a client which has just connected has not failed yet"
1034+
assert await event_object.read("target.value") == ("typed",)
1035+
1036+
1037+
async def test_a_closed_layout_does_not_come_back_when_a_sender_appears():
1038+
"""Closing is the layout going away, which no sender can undo"""
1039+
backend = EventProxyBackend()
1040+
backend.disable("the client went away")
1041+
backend.close()
1042+
1043+
async def send(message: dict[str, Any]) -> None:
1044+
return None
1045+
1046+
backend.set_sender(send)
1047+
assert backend.disabled
1048+
assert not backend.available
1049+
1050+
10061051
async def test_failures_the_client_cannot_explain_are_still_reported():
10071052
backend, client = make_canned_client(ok=False)
10081053
event_object = make_event(backend, "evt")

0 commit comments

Comments
 (0)