python3 has its own set of WASM-runtime-level divergences. See the
TypeScript runtime reference for the
worker, filesystem, socket, and containment details.Node versions
The Node SDK and packages that use it require Node^22.20 || ^24.12 || >=25,
matching the XZ streaming codec
shipped with the SDK. Node 20, 23, and early Node 22/24 releases are outside
that range. The runtime-agnostic core and browser packages do not acquire this
Node requirement.
Shell traps (Python, Node and Browser)
trap 'commands' EXIT (or 0) registers cleanup for the shell that ends. It runs at exit, in the frame that called it, so a function’s locals and $1 are still in scope. It also runs when a child shell ends (bash -c, a script, ( ... ), a pipeline stage, $( ), a background job) or when set -e or a fatal expansion error ends the shell. $? starts at the ending status. The status stays that unless the cleanup calls exit N or fails under set -e.
trap -p lists the registration, trap - EXIT removes it and trap '' EXIT turns cleanup off. A subshell lists its parent’s registration but runs only one it registers itself. bash and sh start with none.
trap 'commands' ERR runs after a command fails where set -e would end the shell: not in an if/while/until test, on the left of &&/|| or after !, and not again for the group, loop or case whose command already ran it. $? is the failed status while it runs and after it. trap 'commands' RETURN runs as a function or a sourced file returns. A function neither runs nor lists its caller’s ERR and RETURN actions unless set -E / set -T, and gives them back when it returns unless it set its own; a child shell keeps listing them but runs neither. exec CMD replaces the shell, so its actions go with it.
A workspace session persists across shell() calls, so a call that returns does not fire EXIT. Register the cleanup inside bash -c when it has to finish before the call returns. Registrations are live shell state: session stores do not keep them.
Mirage has no OS signals. Cancellation, kill, timeouts and closing a workspace or session stop the work without running cleanup, and they interrupt cleanup that is already running (bash runs EXIT on a SIGTERM). trap refuses every other event (INT, TERM, DEBUG, …) with mirage: trap: NAME: not supported, and trap -l the same way.
Background job output (Python, Node and Browser)
A background job writes where the shell that started it writes, as it writes, so its lines and the foreground’s come out in the order they were written, as in bash.wait prints none of a job’s output, and fg prints only the job’s command line.
A shell() call returns whatever reached the session’s virtual terminal while the line ran. A job that writes after the call has returned has no line to show it, so that output waits and comes first in the next call on the session; bash prints it right away. With sink, the output goes to the console while a line runs, and between lines it waits the same way.
$( ) and every pipeline stage but the last wait for the jobs they start, as bash reads a pipe until every writer has closed it. Inside $( ), jobs still lists the caller’s jobs, but wait and kill reach only its own. bash -c sees only its own jobs.
Node
1. patchNodeFs only patches CJS require('fs'), not ESM named imports
The problem. patchNodeFs() routes fs calls on mounted paths through the workspace so that third-party libraries “just work” against them. It swaps the functions on the CommonJS fs object and on fs.promises. ESM is different:
import { readFile } from 'node:fs/promises'binds the function when the module links.- There is no public hook to replace that binding after the fact.
- Loader hooks (
--loader=…) could intercept the resolution, but they’re a build-time decision, not a runtime monkey-patch.
fs.readFileSync, fs.statSync) refuses a mounted path, since the workspace answers asynchronously. Paths outside every mount keep Node’s own behavior in every form.
Why Python doesn’t hit this. Python has no equivalent concept of “ESM vs CJS”. with Workspace() as ws: swaps builtins.open, the os module’s functions and shutil.rmtree in place: one set of mutable globals, one patch point, works for every caller.
Workarounds.
- Use FUSE instead
- Use Mirage's VFS API directly
- Restrict your code to CJS
If you want ESM-imported
node:fs to see your mounted data, expose the workspace as a real filesystem. Mount FUSE, then every fs call, ESM or CJS, goes through the kernel.node:fs through the workspace would remove the ESM limitation, at the cost of forcing consumers to opt into the loader (node --loader @struktoai/mirage-node/loader main.mjs). Not planned currently.
2. The FUSE mount is served by your process’s event loop
The problem.@zkochan/fuse-native dispatches every FUSE request (getattr, read, …) as a callback on the mounting process’s Node event loop. Any synchronous access to your own mountpoint from that same process (readFileSync, statSync, execFileSync('cat', …)) blocks the loop that must answer the request: the call deadlocks, the kernel eventually times the filesystem out, and later operations fail with Device not configured / ENOTCONN.
fs APIs and async subprocess spawns from the mounting process, or dedicate a child process to owning the mount (see examples/typescript/fuse/helper.ts for the pattern). External processes (a sandbox runtime, another terminal, your shell) are unaffected — the constraint only binds the process that created the mount.
3. Size-unknown API files stat as 0 bytes until first open
The behavior. API-backed mounts (Trello, Linear, Slack, MongoDB, …) returnstat.size = null because the byte size isn’t known until the API has been called. Over the FUSE mount these files behave like Linux /proc files: they stat as 0 bytes until first open, and become fully readable the moment anything opens them. Mirage mounts with direct_io (the kernel reads to EOF regardless of the reported size; @zkochan/fuse-native doesn’t expose the option, so Mirage appends it to the mount option string itself) and attr_timeout=0 (post-open fstat returns the real size of the now-fetched content, kept warm in a 30-second cache).
What that means per tool:
Mirage never reports a fake size and never fetches content during
stat: returning real sizes eagerly would fire one API call per file on every ls -l. Earlier TS versions instead reported a fake 100 MiB size with zero-padded reads (JSON.parse of a FUSE-read file failed on the padding); that sentinel is gone and reads are byte-exact.
Why Python behaves the same. Unlike the other entries on this page, this one is not TS-specific: it is a design property of API-backed mounts, and Python’s FUSE layer has identical semantics (see Python FUSE setup). It is listed here because the stat-time zeros are the most common FUSE surprise in practice.
Browser
The browser SDK runs entirely in-page: no kernel, no subprocesses, no Nodefs. That removes the Node sections above (neither FUSE nor patchNodeFs apply) but introduces its own constraints.
1. No FUSE
Browsers can’t mount filesystems. The browserWorkspace takes plain mounts: there is no Mount backend option and no addFuseMount. Use ws.shell(...) (virtual executor) and ws.vfs.read/write instead. Every builtin (cat, grep, jq, awk, etc.) is reimplemented in-process, so most agent code paths work unchanged from Node; python3 runs on Pyodide (see below).
2. OPFS quotas and persistence
OPFSVFS writes through the Origin Private File System. Two things to know:
- Storage quota. The browser sets per-origin quotas (typically a fraction of free disk, single-digit GB on most setups). Hitting it raises
QuotaExceededError. Callnavigator.storage.estimate()to inspect. - Eviction. Origins that aren’t persisted can be cleared by the browser under storage pressure. For long-lived workspaces, request
navigator.storage.persist()early.
3. CORS for HTTP-backed VFS
Mounts that hit third-party APIs (S3, GitHub, Linear, etc.) makefetch calls from the page. Anything not configured to allow your origin via CORS will fail with the usual browser error. Workarounds:
- Browser-native auth flows. mounts like Box, Dropbox, GDrive, GDocs ship PKCE OAuth examples that work entirely in-browser.
- Pre-signed URLs. For S3/R2/GCS, generate pre-signed URLs server-side and pass them in. The Mirage browser examples include a Vite dev-server
presignerplugin as reference. - Same-origin proxy. Stand up a tiny proxy on your own domain that forwards to the upstream API with the right auth headers.
4. Pyodide caches accessed files for each run
Pyodide’s worker fetches workspace files on access and caches them for that execution. An external edit to a cached file is not visible until the next run. Without workers, or with neitherSharedArrayBuffer nor Mirage’s
service worker, python3 in a workspace exits 127 rather than copy every
mount in before each run.
See worker and browser setup
for browser requirements and file access
for its limitations.
5. Some VFS drivers remain Node-only
SSH, LanceDB, Email, Disk, GridFS, Nextcloud, and the Hugging Face mounts are only exposed by@struktoai/mirage-node because their drivers need Node APIs or raw TCP. Databricks Volumes and Jaeger use HTTP but are not exported by @struktoai/mirage-browser yet. Importing any of them from @struktoai/mirage-browser is a build error.
Postgres and MongoDB do have browser implementations:
- Postgres uses
NeonPgDriverover HTTP. - MongoDB uses
HttpMongoDriverand an HTTP proxy such as the bundled mongo-proxy example.
Quick reference
The Node limitations are runtime-level, not Mirage design decisions. Python’s equivalent behavior is strictly better in each case, so if a workflow absolutely requires ESM-level
fs patching or large API-backed FUSE reads, consider whether the Python SDK fits better for that specific piece. The Browser limitations are by design (no kernel, no subprocess), so the workarounds there are about choosing the right runtime for the task.
CPU-bound builtin processing
Line readers, cache drains, andwc processing periodically yield to the event loop. Large RAM files and warm cache reads therefore allow host timers and I/O callbacks to run. Workspace.shell(command, { signal }) forwards caller cancellation to builtin input processing; Python provides the corresponding cancel=asyncio.Event() path and task cancellation.
File-cache fingerprints use incremental MD5: the Node package installs native hashing with 64 KiB updates, while browser/core uses JavaScript block batches. Both yield on a time budget; Python uses incremental hashlib updates.
These checkpoints are cooperative, not a hard execution-time bound. A synchronous regex call, native sort, whole-buffer allocation, or individual hash chunk can still occupy the thread until that operation returns. Custom handlers must observe cancellation themselves. Embeddings requiring isolation from arbitrary CPU work should run the workspace in a separate worker or process.
Queued calls have not started execution: cancelling one removes its waiter immediately, even while the preceding call is still blocked. Once a call starts, cancellation is cooperative and usually leaves backend writes in place; conditional S3 file moves may attempt rollback.
Cancellation itself has one seam. Python runs the whole line as one task, cancels it and joins it, so every await in the line unwinds before execute raises; the line’s own flush and history record get the same 250 ms grace after the cancel and are cancelled too if a store holds them past it. A JavaScript promise cannot be cancelled, so TypeScript fires the signal, joins the tree for a short grace (250 ms) while responsive leaves close their producers and settle, then releases the caller. execute always rejects with an AbortError whose cause is the signal’s reason. A leaf that does not observe the signal within the grace (a custom handler, a backend call with no signal parameter) keeps running in the background until it settles on its own; its output is dropped and it cannot change $? (the status entry point refuses a statement that settles after the release), but any write it completes still lands on the backend. A handler that loops over operands reaches its backend through slots (a generic-bound command such as rm, cp or mkdir) or through the dispatcher (a namespace-routed one such as chmod, touch or rm of a link), and both refuse to start once the signal has fired, so a released line begins no further read or write between operands; only a custom handler that reaches its backend directly can. Python’s cancelled task never reaches the next operand, and a Python handler or store must let CancelledError propagate: one that swallows both deliveries holds execute until it returns, as it would hold asyncio.wait_for, and a warning is logged while it does. A caller cancelled from outside (asyncio.wait_for around execute) gets the same grace as the event. Loading workspace state, parsing, routing, the fetch of a managed variable’s secret, the history record and the session flush are raced against the signal as well, so a stalled state store or secret source cannot hold the caller either. A line is recorded in history once it has been parsed; an abort that lands while workspace state is still loading records nothing, in both languages. A command timeout is the line’s own answer (exit 124), never an abort of the invocation: neither language writes the caller’s signal or event. A background job, and every line it evaluates, runs without the caller’s signal or event; only kill reaches it.
The cancellation boundary is caller completion, not proof that every custom handler has stopped. TypeScript retains the evaluation and its owned parser programs until the command tree settles, even after the grace releases the caller. An uncooperative mount handler can keep running after that tree settles. An embedding that needs a hard stop for arbitrary handlers must provide process or worker isolation.
Streaming resource bounds
Named gzip/gunzip/zgrep,iconv -o, archive commands, deferred redirects and
script output, external sort, split/csplit and record reordering use incremental
I/O or replayable scratch storage. Known-size tar members can emit immediately;
unknown-size members must be spooled before writing their headers. ZIP updates
copy unchanged compressed members, but archive indexing and updates still read
the existing archive in full. They do not yet use remote range reads to fetch
only the central directory or selected members.
Streaming bounds payload memory in supported host packages; it does not make
every algorithm constant-memory. A single large record, archive metadata,
AWK arrays, regex tac, diff/patch and jq --slurp retain their algorithmic
working data. Whole-byte results still collect output.
The standard rg engine on Python, Node and Browser limits requested scratch
storage for each search to 64 MiB. This covers both state tables, their capture rows,
start and candidate capture slots, and the closure stack. Compiled expressions,
input, emitted matches and captures, printer output, and allocator overhead are
outside that limit. Ordinary search retains only whole-match captures.
Replacement templates containing $ conservatively retain all capture groups
when the selected output uses replacement. A pattern whose scratch storage
would exceed the limit is refused before searching. --regex-size-limit
controls compilation separately and does not raise the search scratch limit.
Native write paths cover disk, SSH, OPFS, GridFS and the upload-session APIs for
Drive, Dropbox, OneDrive, SharePoint and Box. Uploads that require a length or
replay stage their input first. Provider sessions are cleaned up where the API
supports it; this is not a durable upload-resume service across process restarts.
S3 uses 8 MiB multipart parts, with a 10,000-part limit (about 78.1 GiB with this
fixed part size). Presigned uploads and conditional writes to custom S3 endpoints
retain a single PUT, backed by scratch storage and limited to 5 GiB. Store-native
append is unavailable on S3: it still downloads and uploads the object again.
Async I/O lets another request run while an operation waits. Compression and
comparison still consume CPU; spooling adds local I/O. Reductions in peak memory
or time to first output do not imply an equal increase in server throughput or
lower cloud network bills.
Measured command-level costs
The mirrored Python implementations were measured with Python 3.12.11 on macOS, using three isolated runs over a 128 MiB deterministic binary input. Each probe consumes output without collecting it. These are median tracked Python allocations (tracemalloc), not process RSS or TypeScript measurements; the
baseline measured implementations match main at 866615ceb7.
Output SHA-256 values matched before and after. Named gzip’s maximum event-loop
lag fell from 1,877 ms to 4.88 ms in the same probe. Its total compression time
did not improve. Unknown-size tar incurs scratch I/O and remains slower than
collecting the input; a known size avoids that staging. The cache result also
changes admission: the 128 MiB file is no longer cached with the default fill
budget, so repeated reads can cost more backend I/O.
Run from the repository root:
gzip_stdin,
tar_current (with --known-size to skip staging), cache_default, cache_8mib
and cache_0. --mib changes the input size. For the previous reader contract,
run the same script with the previous source tree on PYTHONPATH and
--whole-read for named gzip and tar. Whole-result workspace APIs collect their
output, so their final result memory is outside this streaming probe.