Limitations¶
Organised by whose constraint each one is, because that is what decides whether it can ever change. A browser sandbox rule is permanent; a choice this project made for v1 is not.
The porting matrix is the same material as a per-capability table, generated from the registry the tools themselves read.
The browser sandbox¶
Not negotiable by anyone. These are the rules a page runs under.
No raw TCP or UDP. socket.connect() is worse than unavailable: Emscripten backs sockets
with WebSockets, so it succeeds and the first recv hangs until the timeout — or forever.
Use HTTP. Anything that needs a real socket needs a server.
No subprocesses, no fork, no shell. subprocess raises clearly; os.system in a browser
returns 0 and does nothing, which is the most dangerous entry in the whole registry because
it passes every test run under Node.
CORS applies to every request your app makes, including from requests and httpx.
No persistent filesystem by default. Pyodide’s filesystem is in memory and disappears with
the tab. IDBFS can persist it, but WAL, mmap and file locking are the usual failure points
and this project has not measured them — so it does not promise persistence.
Downloads and clipboard go through browser APIs, not paths. App.deliver_binary needs an
override; open_url is already overridden by the driver.
Pyodide¶
The interpreter. Some of this could change in a future Pyodide; none of it is something this project can fix.
Threads are unavailable in any configuration
@work(thread=True) cannot work. Pyodide is not built with -pthread, and its ABI
documentation forbids -pthread in any library linked against it — so this is not a flag
anyone can turn on. sys._emscripten_info.pthreads is False and
grep -c SharedArrayBuffer pyodide.asm.mjs is 0.
SharedArrayBuffer does not change this. It enables the interrupt buffer and urllib3’s
streaming worker. Changing it needs a forked Pyodide build.
loop.run_in_executor() ignores the executor and runs the callable inline on the only
thread. It returns the right answer, so nothing fails; the page freezes for the duration.
This project turns it into a warning naming the substitute.
textual[syntax] does not install, so TextArea syntax highlighting is unavailable.
Textual’s syntax extra requires tree-sitter>=0.25.0; Pyodide bundles 0.23.2 as a native
wheel, which cannot be fetched from PyPI at another version. Measured directly:
micropip.install("textual[syntax]") fails and micropip.install("textual") succeeds. A
plain TextArea works; TextArea.code_editor and any language= argument do not. This is
the same trap as the next entry, reached through an extra instead of a direct dependency, and
micropip’s message for it (“can’t find a pure Python wheel for tree-sitter”) is misleading:
the wheel exists, one minor version too old.
A package bundled as a native wheel pins you to Pyodide’s version of it. cryptography is
three majors behind PyPI; polars, eleven minors. Since Pyodide 314 a package can also publish
a pyemscripten wasm wheel to PyPI, which has no such constraint — doctor reports which of
the two you are getting.
Errno numbers differ. ENOENT is 44 under Emscripten, not 2. Code matching on the
number breaks silently.
JSPI is reported, not depended on. pyodide.ffi.run_sync() lets synchronous Python await a
promise via stack switching, and Chrome has shipped it unflagged since 137 — but its own
docstring still says experimental, so check puts it in the runtime facts and nothing here
requires it.
Boot costs seconds. Fetching and starting a CPython interpreter is ~10 MB on the first
visit, cached afterwards. pyodide-pack and a trimmed stdLibURL are the measurable levers;
neither is wired up here yet.
This project¶
Deliberate v1 choices. Each of these could move, and each entry says what that would take.
Main thread by default; a Web Worker is one flag away. By default the interpreter shares a
thread with the page, so a slow on_mount freezes the tab — measured at a 1333 ms gap
between animation frames while a Textual app spent about a second in a synchronous loop.
textual-wasm build --worker moves the interpreter off that thread, and the same measurement
becomes 16.8 ms, one frame. Both builds render identically; textual-wasm check --worker
runs the full four-runtime comparison against a worker build to keep that true.
Neither mode needs COOP/COEP headers, so both deploy to GitHub Pages. That is not an accident of the current design: cross-origin isolation would be required to block the worker waiting on main-thread input, and Textual never needs to — its input path is a queue an async loop drains, so a message arriving whenever it arrives is the right shape.
What a worker does not buy is threads. sys._emscripten_info.pthreads is False inside
one exactly as it is on the main thread, so @work(thread=True) remains unavailable. The
freeze does not get shorter; it moves somewhere the user cannot see it.
The render comparison assumes a deterministic screen. check diffs cells, and it cannot
tell a font-width bug from an app that drew something different. An app whose content depends
on the network or the clock will differ between legs for reasons that have nothing to do with
WebAssembly. Point it at a stable screen.
Mouse, bracketed paste, clipboard and focus/blur are implemented but not covered by the cross-runtime check. They are exercised by hand, not mechanically.
The stdlib is not trimmed. A build ships the full closure. Nothing lazily loads.
Emoji-presentation width is a property of the terminal, not of this project. Fed the same
bytes, tmux 3.4 places ⚠️ (U+26A0 U+FE0F) one column further along than Chrome does; tmux
3.5a and 3.7c agree with Chrome exactly. A variation selector requests emoji presentation and
emulators only honour it consistently once their Unicode width data is recent enough — so an
app that draws such emoji will occupy different columns in different terminals, and nothing on
the WebAssembly side changes that. check refuses to use a tmux older than 3.5 as its
reference, so a disagreement about tmux is never reported as one about the browser.
The cross-browser check reads xterm.js’s buffer, not pixels. Chromium, Firefox and WebKit render the demo identically cell for cell — but xterm.js’s width logic is the same JavaScript in all three, so cell assignment is engine-independent by construction. Glyph-level rendering is not covered. See Browser support.
Nerd Font and Powerline glyphs are unmeasured. They are private-use codepoints whose width is a property of the font file, not of the emulator, so the render equivalence result — which covers box drawing, CJK, combining marks, astral characters, variation selectors and ZWJ emoji — does not extend to them.
The data channel has no backpressure, and Python sets the ceiling. send returns as soon
as the value is queued, and nothing tells a fast sender to slow down. Measured in Chromium: a
page pushes 200,000 messages in 162 ms and the application takes 2.7 seconds to consume them,
so the cost of a burst is the backlog afterwards. The receiving ceiling is about 75,000
messages a second, and it belongs to the interpreter — the same burst takes the same time on a
terminal, and --worker moves the work without reducing it. A control a person operates sits
three orders of magnitude below that ceiling. A kHz sample stream does not. See The data channel.
A bound reactive is not type-checked at runtime. Textual’s reactives do not validate, so a
page sending a string to a reactive[int] leaves a string there, and the failure surfaces
somewhere else entirely. bind warns once when the type changes and accepts a validate=
argument. Neither replaces treating a page as untrusted input. The behaviour is Textual’s; the
bridge can only make it visible.
Textual upstream¶
Things that work today but rest on Textual’s current internals. None of them requires a fork, and each would be a small upstream change.
error_console is a plain attribute. Routing crash output into the terminal the user is
looking at works by replacing it. A supported hook for “where do tracebacks go” would be
better than an attribute assignment.
Driver construction is by class name from an environment variable. That hook is public,
but App._driver is the only handle on the constructed instance, and it is private. This
project keeps that risk in one function and a lint rule stops it spreading.
@work(thread=True) fails at loop.run_in_executor, deep inside the worker machinery,
instead of at decoration time where the mistake is. A capability check at decoration would
turn a confusing runtime symptom into a clear error.
Textual’s version is pinned into the comparison. Two runtimes are only comparable if they load the same code, so a Textual release changes what the check is checking. CI against upstream releases is the mitigation, and the study recommends building it early.
What is not a limitation¶
These are commonly assumed to be problems and are not:
requestsandhttpxwork in a browser. Bundledurllib3ships an Emscripten backend routing through JSPI, a worker, or XHR, and Pyodide patches httpx to use a fetch transport. Blocking HTTP code does not have to be rewritten async.C-extension dependencies are not limited to what Pyodide bundles, since
pyemscriptenwheels install from PyPI.CJK, emoji and combining characters render correctly. Measured against a real terminal, cell for cell. The width drift everyone expects did not occur.
Textual needs no patch. Everything here is an out-of-tree driver selected through a public hook.