Skip to content

Pyodide/wasm support: pure-Python wheel, worker transport, GlowScript host factory - #291

Draft
sspickle wants to merge 16 commits into
masterfrom
pyodide-packaging
Draft

Pyodide/wasm support: pure-Python wheel, worker transport, GlowScript host factory#291
sspickle wants to merge 16 commits into
masterfrom
pyodide-packaging

Conversation

@sspickle

Copy link
Copy Markdown
Contributor

Experimental / draft: makes vpython installable and runnable on Pyodide/wasm targets, and gives downstream consumers a pinned pure-Python wheel artifact to build against. This is the packaging half of the trinket adoption work (running vpython-jupyter inside trinket's Pyodide web-worker runtime, GlowScript rendering on the page); the trinket-side integration lands separately and pins the wheel this CI produces.

What's here (14 commits, rebased onto current master)

  • Pure-Python wheel path: VPYTHON_PURE_PYTHON=1 builds with no C extension → a py3-none-any wheel, which is what Pyodide/micropip require (they cannot install platform wheels). Nothing is lost but speed: _vector_import_helper already falls back to the pure-Python vector when cyvector is absent. Ordinary builds are completely unchanged — this is opt-in via the env var.
  • glowcomm_host.js: a front-end factory extracted from the notebook glue so a non-Jupyter host page (or worker client) can drive the GlowScript canvas from the same glowcomm message stream.
  • trinket_worker.py: transport shim for driving vpython from a web worker — async rate() (cooperative, doesn't block the worker's message pump) and loud stubs for the notebook-only surface.
  • CI (pure-wheel.yml): every push to master/this branch builds the pure wheel, asserts it is py3-none-any and importable, sha256-sums it, uploads it as an actions artifact, and attaches it to GitHub releases — so consumers pin by URL + sha256 without coupling to PyPI release cadence. (PyPI publication of the pure wheel can follow once this stabilizes.)
  • Housekeeping: .venv/ gitignored.

Verification

Why draft

The API surface of glowcomm_host.js/trinket_worker.py should be considered unstable until the trinket-side integration exercises it end-to-end. Review welcome on the packaging/CI pieces now — the pure-wheel mechanism stands on its own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR

sspickle and others added 16 commits August 15, 2026 18:00
Two changes that together let `vpython` be installed and imported under
Pyodide/wasm. Both are opt-in or invisible to existing users: an ordinary
`python -m build` still produces the same platform wheel it always did, and
`install_requires` is untouched.

1. `VPYTHON_PURE_PYTHON=1` builds with no C extension, producing a
   `py3-none-any` wheel. micropip (and any other wasm installer) cannot install a
   platform wheel, and merely DECLARING `ext_modules` is what makes every wheel a
   platform wheel — so the key is now omitted entirely for that build rather than
   set to an empty list, which would not have changed the tag. Nothing is lost but
   speed: `_vector_import_helper` already falls back to the pure-Python `vector`
   when `cyvector` is unavailable.

2. `vpython/__init__.py` uses `importlib.metadata` instead of `pkg_resources`.
   pkg_resources is deprecated upstream and simply absent in Pyodide, so importing
   vpython there failed on the package's very first line. `python_requires` moves
   to >=3.8 accordingly (importlib.metadata is 3.8+; `notebook>=7` already
   required 3.8+, so this narrows nothing in practice).

Verified in a real Pyodide 3.13.2 Web Worker, installing this branch's own pure
wheel with `deps=False` and no setuptools present:

    OK       vpython.vector
    OK       vpython.vpython
    PACKAGE OK  __version__ = 7.6.5  __gs_version__ = 3.2

`__gs_version__` resolving also confirms `gs_version.py` reads the bundled
glow.min.js fine there, and that it is on the same 3.2 line trinket serves.

Both builds were checked: with the flag, `vpython-7.6.5-py3-none-any.whl`;
without it, `vpython-7.6.5-cp314-cp314-macosx_10_15_universal2.whl` as before.

Execution now reaches the transport seam — `sphere()` stops at vpython.py:267
`from .no_notebook import _` (autobahn), which is the next piece of work, not a
packaging problem.
Third transport beside with_notebook (Jupyter Comm) and no_notebook
(http.server + autobahn in threads). In a Web Worker none of that exists — no
Jupyter, no servers, no threads — but the host JS environment has postMessage.
So the contract is two functions on the JS global scope:

* the host defines __trinket_vpython_send(jsonString) BEFORE importing vpython;
  every outbound update package (and the 'trigger' handshake) goes through it
* this module sets __trinket_vpython_dispatch(jsonArrayOfEvents); the host calls
  it as browser events arrive, and every call — including a bare trigger — is
  answered with a flush, the same request/reply rhythm the websocket transport
  uses, paced by the browser's ~33 ms canvas_update timer

Selection is sys.platform == 'emscripten' in baseObj.__init__, tried after
_isnotebook so a notebook running under Pyodide (JupyterLite) still gets its
Comm. Importing the module IS the setup, exactly like the other two: GlowWidget()
first (it nulls the module-global sender outside a notebook), then install ours,
then an initial trigger() so the scene canvas buffered during `import vpython`
flushes and baseObj.sent unblocks appendcmd.

Verified end-to-end in trinket's real #108 worker (Pyodide 3.13.2, this branch's
pure wheel, deps=False), with send captured on the Python side:

  MSG 0: {"cmds": [{"cmd": "canvas", ...}, {"cmd": "distant_light", ...} x2]}
  MSG 1: {"cmds": [{"cmd": "sphere", "idx": 4, "color": [1,0,0], ...}],
          "attrs": ["a4a1,2,3"]}

MSG 1's attrs entry is ball.pos = vector(1,2,3) in the protocol's compact
per-attribute wire coding — constructors, attribute updates and the
browser-driven flush all cross the seam. What remains is the OTHER half of the
pipe: a browser-side consumer of this stream (glowcomm.js reads the identical
format from a Comm), which lives in the embedding host, not here.
glowcomm.js is the Jupyter notebook front end: a Comm/WebSocket, nbextension
font loading, a setTimeout pacing loop, and GlowScript constructors it expects
to find on window. Underneath that is the actual wire protocol — turn a decoded
{cmds, methods, attrs} package into GlowScript objects — which is host-neutral
and is what any embedding (Jupyter, JupyterLab, a Pyodide Web Worker page) needs.

glowcomm_host.js is that core, extracted as a factory:

  createGlowFrontend({container, send, glow}) -> {handle(ops), reset(), destroy()}

Ported verbatim (normalized-diff clean against glowcomm.js): the attrs/attrsb/
methods/vecattrs/textattrs tables and patterns, decode, fix_location, o2vec3,
handler (as handle), handle_cmds, handle_methods, handle_attrs.

Not ported (host's job / later work): the Comm + WebSocket setup, fontloading/
checkloading, domessage/onmessage, send/msclock/update_canvas, send_to_server/ok,
send_pick/send_compound, and process/process_pause/process_waitfor/
process_binding/control_handler.

Deliberate departures from a byte-for-byte port:

- Constructors come off the injected `glow` registry (defaults to globalThis),
  so `sphere(cfg)` is `glow.sphere(cfg)`. Same for vec/print_anchor.
- Per-function "use strict" replaced by one file-level directive.
- handle() decodes a shallow copy, and handle_cmds copies each cmd before
  deleting cmd/idx/method from it. glowcomm.js owned and discarded the Comm
  payload; here the package belongs to the caller and may be replayed.
- The canvas case drops the JupyterLab '#glowscript' lookup and the jQuery
  contextmenu handler; the host's container is published on globalThis.__context
  instead. (A host that wants the right-click suppression can add it itself.)
- `instanceof` on vec/curve/points goes through is_a(), which returns false
  instead of throwing when the injected registry is not constructible (unit
  test stubs). Identical result for real GlowScript classes.
- glowcomm.js:889 reads `ifif (val == 'None')`, an upstream typo that makes the
  file unparseable; glowcomm.html has the same line correctly as `if`. Ported
  as `if`, with a comment.

Deferred call sites — each now hits a local stub that console.warns
"glowcomm_host: <feature> not wired yet" and forwards a glowcomm-shaped event
array to opts.send. Wiring them to the host channel is a later task:

  send_compound  <- handle_cmds 'compound' (non-clone), 'extrusion', 'text' (non-clone)
  send_pick      <- handle_methods 'pick'
  process_binding<- handle_methods 'bind', 'unbind'
  process_waitfor<- handle_methods 'waitfor'
  process_pause  <- handle_methods 'pause'
  control_handler<- handle_cmds 'winput', 'checkbox', 'radio', 'button', 'slider', 'menu'

Exercised by the consumer's unit suite (5 tests) against captured transport
packages and a stub glow registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…ed events

Two review findings on the port.

1. The canvas case handed glow a raw DOM element and replaced __context on
   every canvas cmd. __context is glow's own scratch space (canvas_selected,
   canvas_all, print_container), and upstream ran its container lookup at most
   once, so replacing it wipes state belonging to canvases already on the page.
   glow also stores the container as a jQuery object — the setter is
   `glowscript_container = $(value)` and the print path calls
   `canvas.container.css(...)`, which throws on a bare element. Now merges into
   any existing __context and wraps with glow.$ / $ / jQuery, warning if it
   cannot find one.

2. Four of the six deferred stubs forwarded a synthesized payload to opts.send.
   Those payloads are not protocol-valid: control_handler emitted {idx, widget}
   with idx/value missing, and process_binding/process_waitfor emitted
   {event: undefined}. vpython.py handle_msg (vpython.py:398-427) routes any
   event with a 'widget' key through object_registry[evt['idx']] and reads
   evt['value']/evt['text'] before calling the user's bind callback, and routes
   everything else through object_registry[evt['canvas']] into
   canvas.handle_event's `ev = evt['event']` (vpython.py:3288). A partial event
   raises inside the kernel's message loop, so once a live send is wired any
   widget click or bound event would crash the run. control_handler,
   process_binding, process_waitfor and process_pause are now warn-and-drop.

   send_pick and send_compound keep forwarding — their payloads are byte-faithful
   to glowcomm.js, and both are synchronous barriers Python blocks on.

Pinned by the consumer's unit suite, which now fails against the previous commit
on both points ("expected undefined to deeply equal [ 'pre-existing' ]" and
"expected [ [ { idx: undefined, ... } ] ] to deeply equal []").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
… hosts

A Web Worker gives vpython one thread, and that thread is the one the
browser's replies arrive on — so every place vpython spins waiting for the
front end is a deadlock, not a delay. Rewrite that surface at transport boot:

* rate() and sleep() become coroutines that flush buffered updates and then
  await asyncio.sleep, so the async transform's inserted `await` yields to
  the host loop. rate is a module-level *instance* already bound into user
  namespaces, so the patch lands on _RateKeeper2.__call__ — the class — and
  reaches every binding.
* scene.pause/scene.waitfor and the six widget constructors raise
  NotImplementedError naming themselves and the escape hatch, rather than
  hanging silently. The widgets share controls.setup (each subclass defines
  its own __init__ that delegates there), so one patch covers all six.

The patches live in apply_worker_patches(), called by the bootstrap, and
__init__.py now imports the transport eagerly under emscripten — before the
star-imports bind rate/sleep into the package namespace, which is the only
ordering where a student's `from vpython import *` sees the patched names.
The lazy selection in baseObj.__init__ stays as the fallback; it now finds
the module cached and no-ops.

tests/test_trinket_worker.py stands the whole thing up on CPython with fake
js/pyodide modules and sys.platform forced to emscripten, asserting through
the package namespace exactly as a student's program would see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…ment

Review follow-ups on the transport patches:

* _async_rate flushed on every call, so a rate(1000) loop pushed 1000 update
  packages/second at the page. Upstream RateKeeper caps renders at
  MAX_RENDERS/second regardless of how often rate() is called; do the same
  behind a monotonic-clock gate, reading the constant from rate_control.
  Pacing is unchanged — the full 1/maxRate interval is still awaited — and a
  skipped flush loses nothing, since the updates stay buffered for the next.
* rate(0) clamped to 1 Hz where upstream raises. Validate synchronously in a
  wrapper, before the coroutine is built, so the error lands at the call site
  as it does upstream rather than on await (or never, if nothing awaits).
* The test fixture left its emscripten-booted package in sys.modules, handing
  async sleep and raising widgets to every later `import vpython` in the
  session. Snapshot and restore the vpython* keys around it, with a guard test
  that the cache comes back clean.
* The closing comment still described the pre-eager-boot world, where the
  canvas was already buffered when the transport booted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
The front-end has been one-way since Task 1: Python builds objects, the
page draws them, and every path back was a warn-and-drop stub. This ports
the other half of glowcomm.js — update_canvas (:193-275), process
(:311-346), process_binding (:373-380) and send (:150-170) — so a
scene.bind('click', f) handler actually fires.

The event objects are byte-faithful to glowcomm.js on purpose: their field
set IS the wire contract that vpython.py's handle_msg (:394-425) and
canvas.handle_event (:3287) destructure, and a missing key is a KeyError
inside the kernel's message loop rather than a polite failure.

Two things are deliberately NOT verbatim.

send() becomes tick(). Upstream's send() owned both halves of pacing: what
goes in a tick, and when the next one happens (it re-armed its own
setTimeout). The when belongs to the host — it is the same clock that
drives rendering, and in trinket it belongs to the RUN — so this file
exports the what and the host calls it.

Events flush themselves when no clock is running. Upstream could queue and
wait because its timer never stopped; a host whose clock is scoped to the
run has no clock at all by the time the student clicks, and
`scene.bind('click', f)` followed by the end of the program is exactly what
an interactive example looks like. So queue() sends immediately unless a
tick happened in the last 100 ms, which keeps glowcomm's 33 ms coalescing
for the case it was written for — a bound mousemove must not cost one
worker round trip per mouse pixel — without deadlocking the case it was
not. pick and _compound never wait at all: Python is blocked in _wait()
for both.

Also: canvas.hasmouse is a CLASS static and outlives a scene, so
update_canvas ignores a canvas this front-end no longer owns — otherwise
the first tick of a new generation reports an idx that means something
else in Python's (persistent) object_registry. reset() now clears the
queue and re-seeds the camera diff for the same reason.

waitfor, pause and widgets stay warn-and-drop: they are deferred by design
(spec V5, NotImplementedError on the Python side), and the comment now
says so rather than "not ported yet".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
… on every flush

Review of the Task 9 port found two holes, both in the same place: what
happens to an event when no clock is running.

I1. PACING_GRACE_MS was doing a job it cannot do. Inferring "the host is
ticking" from "a tick happened <100 ms ago" is right until the tick that
happens to be the LAST one — after which an event arriving inside that
window is queued for a tick that will never come, and sits there until some
later event happens to flush it. For a program that has ended, there is no
later event. Add pacingStopped(): the host says so, because only the host
knows. The grace window stays as a backstop for a host that forgets, or one
whose clock dies without warning.

I2. glowcomm.js polled the canvas on every message out, because send() was
the only way out. The event-driven flush added here was a second way out
and it carried no update_canvas — so with no clock running, a bound handler
saw scene.forward/up/center/range/autoscale and keysdown() frozen at
whatever they were when the clock stopped. flush() and tick() now share a
drain() that appends update_canvas() in upstream's events.concat(update)
order. flush() still returns early on an empty queue: it never polls or
sends unprompted, which is what keeps a stopped scene silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…ives

Two things can make the worker flush a scene: the host's ~33 ms pacer, and
rate() inside the student's own loop, which triggers a render up to
rate_control.MAX_RENDERS a second. While an animation is running the second is
doing the whole job and the pacer's handshake is pure packet overhead — measured
from trinket at 91 host messages against 254 packages over three seconds, a
third of the traffic on the hottest path in the system, buying nothing.

poll() is the tick a host uses in that state. It does the half only the browser
can do — queued events, and the camera/mouse state update_canvas() samples — and
skips the bare {event:'update_canvas',trigger:1} the program no longer needs. It
still stamps last_tick, because the host's clock IS running: events must keep
batching into the next tick rather than each paying its own round trip.

Also: a note for whoever wires control_handler. Upstream reports a slider ONLY
through update_canvas(), which was safe in a notebook where the clock never
stops. On a host whose clock belongs to the run, flush() deliberately returns
early on an empty queue, so after a program ends a slider drag would sit in
`sliders` until some unrelated event flushed it. The fix at that point is to
queue() a widget event, as upstream already does for every other widget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…eep flushed uncapped

Final-review C1 and I2 (trinket .superpowers/sdd/2026-08-10-worker-vpython).

apply_worker_patches() made pause, waitfor and widgets raise. It missed every
OTHER synchronous barrier in the package, and each one spins the worker's only
thread waiting for a browser reply that can only be delivered BY that thread:

  compound(...)      `while not baseObj.sent: time.sleep(0.001)`  — always hangs
  text(...)          _wait(canvas)                                — always hangs
  extrusion(...)     _wait(canvas)                                — always hangs
  scene.mouse.pick   _wait(canvas)                                — always hangs
  obj.clone()        `while not baseObj.empty(): rate(60)`        — intermittent

This branch made it worse rather than better: _wait() polls with rate(30), and
rate is now a coroutine factory, so called from synchronous library code it
builds a coroutine and discards it without ever sleeping. The hang became a 100%
CPU spin with no yield.

What a student sees is nothing at all — no output, no error, no traceback, and a
Stop button that works. That is precisely the silent no-op the design's Errors
section forbids ("a pause that doesn't pause corrupts program meaning"), and
compound and text are not exotic in the M&I corpus a trial would be run against.
So all five now raise the existing _DEFER message naming themselves, and _wait
itself is patched as a backstop: those four are every caller in the package
today, but a future one should say so rather than hang.

Mouse.pick is a property, so it is replaced as one — the deferral has to fire on
attribute access, not on a call — keeping the original setter, which already
refuses.

Separately, and the same defect class as the render cap rate() already had:
_async_sleep flushed on EVERY call, so `while True: sleep(0.001)` floods the page
with ~1000 update packages a second, each a postMessage plus a handle() on the
main thread. Both now share one _flush_if_due() gate, so a loop mixing them obeys
one cap rather than one each.

glowcomm_host.js gains GLOWCOMM_HOST_VERSION (exposed as
createGlowFrontend.version). The design's two-repo mitigation was "wheel filename
carries the version; host shim logs both at boot" — the front-end had no version
to log, so trinket's shim could not hold up its end. Now it can, and trinket's
sync script refuses to copy a front-end whose version disagrees with the wheel's.

Tests: 26 passed (up from 17) — six deferral cases asserting the exact message
text, three for the shared flush gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
… body

Final-review caveat 5 (trinket .superpowers/sdd/2026-08-10-worker-vpython).

rate(N) means "N iterations per second", not "sleep 1/N between them". The
worker's _async_rate did the latter:

    await asyncio.sleep(1.0 / float(maxRate))

flat, regardless of what the loop body had just cost. So rate(60) — a 16.7 ms
period — with 10 ms of physics per iteration paced at 37 Hz, not 60. Every
program with real work in it ran measurably slower under the worker than the
same program on the main-thread bridge, which contradicts the spec's success
criterion ("renders and animates identically to the main path"). Measured here,
before and after, at 100+ iterations per row:

    rate(50),  10 ms work    33.3 Hz  ->  46.1 Hz   (target 50)
    rate(60),  10 ms work    37.5 Hz  ->  55.6 Hz   (target 60)
    rate(100),  5 ms work    66.7 Hz  ->  91.7 Hz   (target 100)
    rate(30),  25 ms work    17.1 Hz  ->  28.5 Hz   (target 30)

Upstream's _RateKeeper2.__call__ measures the time spent in user code between
rate() returns (`userTime`, rate_control.py:173) and subtracts it from the
delay. We already handle the OTHER half of what upstream does — decoupling
render frequency from call frequency — via _flush_if_due()'s MAX_RENDERS cap, so
only the timing half was missing. This is that half, as a fixed timestep:
remember when the last call RETURNED (when the user's iteration began) and sleep
only the remainder of the period.

The residual ~6% shortfall in the numbers above is asyncio.sleep overshoot, not
compensation error: it is equally present with no user code at all (rate(50)
with an empty body measures 47.2 Hz), and closing it would mean carrying debt
forward, which is precisely what must not happen.

Four properties the implementation is pinned to, all tested:

  * The remainder is clamped at max(0, ...), and the next deadline is anchored
    on the ACTUAL return rather than on last+period. A loop that falls behind
    stays behind; it does not bank debt and then burst through a batch of
    zero-sleep iterations catching up.
  * It ALWAYS awaits, including at zero. When user code overruns the period
    there is nothing left to wait for, but a bare `return` would never yield to
    the event loop — and a worker loop that stops yielding dispatches no scene
    events and cannot be stopped. asyncio.sleep(0) yields. This is the safety
    property that matters more than the pacing one.
  * The period is recomputed each call, so rate(10) after a rate(1000) loop
    slows down on THAT call rather than one call later.
  * The first call has no previous return to measure a remainder from, so it
    yields and returns — as upstream's `count == 1` branch does after
    callInteract(). It costs at most one period, once, per program, and gets the
    flush that call just issued onto the page without an added wait.

sleep(dt) is deliberately untouched: "sleep dt seconds" is not "pace at a target
rate", and compensating it would make sleep(1) mean something other than one
second. It keeps its _flush_if_due(). rate(0)/rate(-1) still raise ValueError
synchronously at the call site, and the render cap is unchanged.

Tests: 31 passed (up from 26) — five pacing cases, wall-clock assertions kept
loose enough for noisy CI (direction and rough magnitude, never equality), with
user code simulated by a busy spin rather than an await so the only yield point
under test is rate() itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
Review finding 1 (correctness of the JUSTIFICATION, not of the code) plus
finding 3. No behaviour change: comments, a docstring, an assert message, and
one added assertion.

e086450 said an unconditional await was needed because a rate() that stops
yielding "cannot be stopped". That is false on this host. Stop is
worker.terminate() on the page side — unconditional, needing nothing from the
worker thread — and trinket's worker-client.js says so in its opening comment.
A wrong reason attached to a right decision is a liability: the next reader
checks it, finds Stop works fine without the await, and deletes the await.

The true reason is narrower and worse. In a worker the host delivers browser
events by CALLING __trinket_vpython_dispatch, and that call only gets a turn
when the running coroutine gives one up. A rate() that returns without awaiting
therefore dispatches no events and flushes no updates: the scene FREEZES while
the program runs flat out. Stop surviving that is what makes it subtle rather
than mild — a frozen picture from a program that is still running reads to a
student as "vpython is broken", not as a hang, and Stop clearing it up is
consistent with both stories. Corrected in all three places it appeared here;
the report's copy goes with the trinket commit.

Finding 3: test_rate_subtracts_user_code_time_from_the_period asserted only an
upper bound, so "sleep zero whenever the body did any work" — a plausible
mis-implementation of exactly this fix, and a much worse one than the flat sleep
it replaced — would have passed it. It now also asserts elapsed > ideal * 0.7:
compensating for the body's cost must not become free-running.

Tests: 31 passed (unchanged count; the new bound rides on the existing case).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
…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
…or wasm consumers

VPYTHON_PURE_PYTHON=1 wheel built on every push to master/pyodide-packaging,
verified pure + importable, sha256-summed, uploaded as an actions artifact,
and attached to GitHub releases. Downstream (trinket) pins by URL + sha256.
Also gitignore local virtualenvs.
Pre-release tags host pinned wasm artifacts (pure-wheel.yml); the PyPI
upload must only run for real releases. Guard is per-job because uploads
happen per-job — a pre-release partially published 7.6.6.dev0 before its
linux legs failed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant