Skip to content

Commit 2504893

Browse files
sspickleclaude
andcommitted
docs(worker): the mechanism was corrected everywhere EXCEPT the code it describes
Re-review of finding 1. Comments only; no behaviour change. pytest 31 passed. b403c71 replaced a false reason for the unconditional await ("a loop that stops yielding cannot be stopped") with a more careful one. Then the browser experiment for finding 6 disproved the replacement too, and I updated the report and trinket's spec — but not these three, which b403c71 had written hours earlier and which still said the scene "freezes", with "no events dispatched, no flush". So the same claim was wrong here twice in a row, the second time in the one place a reader of _async_rate actually looks. What the experiment showed — build with `if remaining <= 0.0: return`, sync, run — is narrower and much harder to notice: * the scene KEEPS ANIMATING; _flush_if_due() is synchronous, so outbound updates need no yield at all; * Stop KEEPS WORKING; terminate() needs nothing from this thread; * INBOUND events silently stop arriving — the host delivers them by calling __trinket_vpython_dispatch, which only gets a turn when the coroutine gives one up. Inbound dispatch is the only half that needs the yield. The failure mode is therefore a scene that animates, a Stop button that works, and mouse events that quietly do nothing — which is exactly why the first browser test written for it passed against the broken build. All three sites now say that, and each says the browser is where it was established rather than presenting it as deduction. The docstring also notes why the CPython test asserts a bystander coroutine instead: yielding is the property, and a co-scheduled task is its only visible consequence here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
1 parent 68eff8c commit 2504893

2 files changed

Lines changed: 36 additions & 20 deletions

File tree

tests/test_trinket_worker.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,20 @@ async def loop():
194194
def test_rate_still_yields_when_user_code_overruns_the_period(worker_env):
195195
"""Safety: over-period work must not turn rate() into a non-yielding return.
196196
197-
In a worker the host delivers browser events by CALLING the transport's
198-
dispatch, which only gets a turn when the running coroutine gives one up. A
199-
rate() that returns without awaiting therefore freezes the scene — no events
200-
dispatched, no flush — while the program runs flat out. (Stop survives it:
201-
that is worker.terminate() on the page side and needs nothing from this
202-
thread. Which makes the symptom subtler, not milder.)
197+
What that costs was measured in a browser, not reasoned about, and it is
198+
narrower than it sounds. The scene keeps animating: the flush above is
199+
synchronous, so outbound updates go out without any yield. Stop keeps
200+
working: that is worker.terminate() on the page side, needing nothing from
201+
this thread. The one thing that breaks is INBOUND — the host delivers
202+
browser events by CALLING the transport's dispatch, and that call only gets
203+
a turn when the running coroutine gives one up, so scene.bind handlers and
204+
mouse picks silently never fire.
205+
206+
That is why this test asserts a bystander coroutine gets to run rather than
207+
anything about output: yielding is the property, and in CPython a
208+
co-scheduled task is the only visible consequence of it. The browser half
209+
(a click handler firing during an over-period loop) lives in trinket's
210+
worker-vpython.spec.js; both fail on the same mutation.
203211
"""
204212
vp, _ = worker_env
205213
calls = 5
@@ -224,8 +232,9 @@ async def loop():
224232
gained = asyncio.run(loop())
225233
assert gained >= calls, (
226234
'rate() yielded %d times in %d over-period calls — a loop that stops '
227-
'yielding starves the event loop, so no browser event is dispatched and '
228-
'no update is flushed: the scene freezes while the program runs on'
235+
'yielding starves the event loop, so the host never gets to deliver a '
236+
'browser event: the scene still animates and Stop still works, but '
237+
'scene.bind handlers and mouse picks silently never fire'
229238
% (gained, calls))
230239

231240

vpython/trinket_worker.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,19 +128,26 @@ async def _async_rate(maxRate):
128128
# flush we just issued onto the page without an added wait.
129129
remaining = 0.0 if last is None else (last + period) - time.monotonic()
130130
# Always await, even at zero. When user code overruns the period the
131-
# remainder is negative and there is nothing left to wait for — but a
132-
# bare return would never yield to the event loop, and in a worker run
133-
# nothing else does either: the host delivers browser events by CALLING
134-
# __trinket_vpython_dispatch, and that call only gets a turn when the
135-
# running coroutine gives one up. A rate() that stops yielding therefore
136-
# freezes the scene — no events dispatched, no flush, a program running
137-
# flat out with a picture that never changes.
131+
# remainder is negative and there is nothing left to wait for, which
132+
# makes an early `return` here look free. It is not, and what it costs
133+
# was established by building that version and running it in a browser
134+
# (see trinket's worker-vpython.spec.js) rather than by reasoning:
138135
#
139-
# It does NOT break Stop: Stop is worker.terminate() on the page side,
140-
# which is unconditional and needs no cooperation from this thread
141-
# (trinket's worker-client.js says so at the top). That makes the
142-
# symptom subtler, not milder — a frozen animation from a program that
143-
# is still running reads as "vpython is broken" rather than as a hang.
136+
# * The scene KEEPS ANIMATING. _flush_if_due() above is synchronous —
137+
# it postMessages without yielding — so outbound updates go out
138+
# exactly as before and the picture moves normally.
139+
# * Stop KEEPS WORKING. That is worker.terminate() on the page side,
140+
# unconditional, needing nothing from this thread (trinket's
141+
# worker-client.js says so at the top).
142+
# * INBOUND EVENTS SILENTLY STOP ARRIVING. The host delivers them by
143+
# CALLING __trinket_vpython_dispatch, and that call only gets a turn
144+
# when the running coroutine gives one up. Nothing else in a worker
145+
# run does. So scene.bind handlers, mouse picks and the rest simply
146+
# never fire.
147+
#
148+
# Inbound dispatch is the ONLY half that needs this yield, which is what
149+
# makes losing it so hard to spot: a scene that animates, a Stop button
150+
# that works, and mouse events that quietly do nothing.
144151
#
145152
# asyncio.sleep(0) yields. Clamping at 0 also means a loop that falls
146153
# behind simply stays behind rather than banking debt and then bursting

0 commit comments

Comments
 (0)