fix(http-client-python): boot pyodide lazily in the browser - #11507
Open
timotheeguerin wants to merge 1 commit into
Open
fix(http-client-python): boot pyodide lazily in the browser#11507timotheeguerin wants to merge 1 commit into
timotheeguerin wants to merge 1 commit into
Conversation
The browser bootstrap ran as a module-level side effect, so simply importing the emitter downloaded a full CPython WebAssembly runtime and micropip-installed its wheels from PyPI. Hosts like the TypeSpec playground import every library in the import map up front, so this happened on every page load. Measured on typespec.io/playground (WebKit, iPhone emulation) it added ~290MB of resident memory (463MB -> 752MB) and ~8MB of downloads, pushing the tab past the per-tab memory budget on mobile browsers and preventing the playground from loading. Boot pyodide on the first emit instead, and drop the cached promise when the bootstrap fails so a later emit can retry. Fixes microsoft#11506
commit: |
Contributor
|
All changed packages have been documented.
Show changes
|
|
You can try these changes here
|
timotheeguerin
marked this pull request as ready for review
July 31, 2026 14:26
timotheeguerin
requested review from
ChenxiJiang333,
catalinaperalta,
iscai-msft,
kashifkhan,
l0lawrence,
lmazuel,
msyyc,
tadelesh and
xirzec
as code owners
July 31, 2026 14:26
github-merge-queue Bot
pushed a commit
that referenced
this pull request
Jul 31, 2026
Fixes #11506 ## Problem `typespec.io/playground` hangs forever on iOS. The standalone playground (`cadlplayground.z22.web.core.windows.net`) works fine. The difference: `website/src/components/playground-component/import-map.ts` adds `@typespec/http-client-python` and `@typespec/http-client-csharp` to the website's import map, and `browser-host.ts` eagerly `import()`s **every** library in the map at startup — before the user has picked an emitter. `@typespec/http-client-python` boots a full Pyodide (CPython/WASM) runtime as a module-level side effect, which alone pulls ~23 MB of extra payload and ~270 MB of resident memory. Measured with Playwright WebKit under iPhone 15 emulation (`ps` RSS of the `WebKit.WebContent` process): | Page | RSS | | --- | --- | | typespec.io home | 195 MB | | standalone playground (works on real iPhone) | 547 MB | | typespec.io/playground?version=1.13.x (no extra emitters — **works on real iPhone**) | 463 MB | | typespec.io/playground (as shipped — **hangs on real iPhone**) | 741 MB | ## Fix Add a `deferredEmitters` option. Libraries named there are registered as placeholder entries at startup (so they still show up in the emitter dropdown) and are only imported when a compilation actually needs them — i.e. when they're the selected emitter or listed under `emit` in the tspconfig. The website passes the two heavy client emitters. Nothing is downloaded or evaluated for them until you select one. Caveat, documented on the option: a deferred library can't be referenced by an `import` statement in TypeSpec source. That's fine for these two — they're pure emitters with no decorators or `.tsp` surface. ## Relation to #11507 #11507 makes the Pyodide boot lazy inside the Python emitter. That's the right fix at the source, but the website resolves emitter bundles at **runtime** from blob storage (`typespec.blob.core.windows.net/pkgs/<name>/latest.json`), uploaded by a separate ADO stage. So it can't take effect — or be validated in a PR preview — until the emitter is republished. This PR fixes the site independently of that, and also cuts startup cost for the C# emitter. ## Tests - `packages/playground/test/browser-host-deferred.test.ts` — deferral, exclusion from the virtual `package.json` dependencies, load-once memoization, retry after a failed load, no-op for non-deferred libs. - `packages/playground/test/deferred-emitter-compile.test.ts` — end-to-end against the **real** compiler: compiling with a deferred emitter fails to resolve it, then succeeds and writes its output after `loadLibrary()`. Full playground suite: 55/55 green. `tsc --noEmit` clean for `packages/playground` and `website/src`. ## Not fixed here Monaco web workers fail to start on typespec.io on desktop *and* mobile (`Could not create web worker(s). Falling back to loading web worker code in main thread`) — `MonacoEnvironment.getWorker` isn't defined. Pre-existing and unrelated to the hang, but worth a follow-up: everything currently runs on the main thread. ## Why this is an opt-in list rather than "defer every emitter" `isEmitter` comes from `$lib.emitter`, which only exists once the bundle has been evaluated. Neither the blob index (`latest.json` is just `{version, imports}`) nor `package.json` carries an emitter marker, so there is no way to populate the emitter dropdown without importing every candidate. Deferring everything would leave the dropdown empty. Deferring *all* emitters would also break the ones that ship TypeSpec surface — `@typespec/openapi3` (`lib/decorators.tsp`), `@typespec/json-schema`, `@typespec/protobuf` — since an `import "@typespec/json-schema";` in the editor would fail to resolve before the emitter is ever selected. `http-client-python` and `http-client-csharp` are the only two in the map that are pure emitters with no `.tsp`. The payoff is also concentrated in those two: with pyodide blocked the page sits at 474 MB vs 463 MB for `?version=1.13.x` (which loads no additional packages at all), so everything else the website pulls in eagerly — including the ~7 Azure libraries that `http-client-python`'s import map drags along — is only ~11 MB combined. Possible follow-up, not done here: the host's `readFile`/`stat` are async, so a miss under `node_modules/<deferred-lib>/` could trigger the load mid-resolution. That would remove the "cannot be referenced by an `import` statement" restriction and make the list safe to expand without auditing each library.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #11506
Problem
The browser Pyodide bootstrap ran as a module-level side effect:
So merely
importing@typespec/http-client-pythondownloaded a full CPython WebAssembly runtime (pyodide.asm.wasm,python_stdlib.zip) and micropip-installedjinja2,black,setuptools, … live from PyPI.The playground imports every library in its import map up front (
loadLibrariesinpackages/playground/src/browser-host.ts), so this happened on every single page load, whether or not the user ever selected the Python emitter.Impact
Measured against the live
typespec.io/playgroundwith Playwright WebKit under iPhone 15 emulation:Total page weight was 22.9 MB across 197 requests, of which ~8 MB was Pyodide + PyPI wheels.
~750 MB is past what iOS Safari allows a single tab, so the WebContent process gets killed and reloaded — which is exactly the "loads infinitely" symptom in the issue.
Fix
Boot Pyodide on the first browser emit instead of at import time. The cached promise is dropped if the bootstrap rejects, so a later emit can retry after a transient failure (matching the existing behaviour in
pyodide-loader.browser.ts).No behaviour change for the Node path, and no change for users who actually select the Python emitter beyond moving the download to the point where it's needed.
Testing
Added
emitter/test/browser-pyodide.test.ts, which stubswindowand asserts that importing the emitter does not callloadPyodide. Verified it fails against the previous implementation and passes with this change.Follow-ups (not in this PR)
Could not create web worker(s). Falling back to loading web worker code in main thread) on desktop and mobile alike, so compilation runs on the main thread. Painful on a phone CPU.