Files
Zecheng Zhang b448775596 fix(workspace): the door owns every verb that names a link
Three codex comments on the guest link surface turned out to be one
missing rule. The dispatcher already answered symlink, readlink and the
unlink of a link; rename, a no-follow stat and the existence check were
not there, so each one reached a backend that has never heard of the
name. LINK_ENTRY_OPS plus one predicate says which verbs the node table
answers, and ln, mv, git and FUSE read the rule instead of keeping their
own copy of it.

A rename of a link answered ENOENT and moved nothing, on pyodide, wasi,
FUSE and os.rename. A no-follow stat answered ENOENT everywhere, so the
wasi lstat rebuilt the row from the target string and reported epoch
zero: a guest's own utime persisted and stayed invisible. FUSE was a
third copy of that mistake, reporting the mount's construction time and
the mounting user for every link. And symlink never looked at the name
it was given, so ln -s over a live file exited 0 and left the bytes
under a link nothing could read, and a guest could bury a mount root.

Two more of the same family, found while probing rather than reported:
a rename onto an existing link left the link shadowing the file that
had just landed, and prepare_mv wrote the node table directly, which
made a link rename the one write in the shell no admission policy could
see. Both go through the door now, and ln -f is GNU's own algorithm
(remove the destination, then link), so it replaces a regular file the
way coreutils does.

git checkout relied on symlink overwriting a link to retarget one, so
restoreEntry removes the old name unconditionally rather than skipping
that when the path is already linked.
2026-08-21 08:17:18 -07:00

203 lines
9.1 KiB
Plaintext

---
title: Python filesystem
icon: file-binary
description: Use Python's open(), os.listdir, pathlib, PIL, pandas, directly against any Mirage mount.
---
Python code inside `ws.execute('python3 ...')` can `open()`, `os.listdir()`, `pathlib.Path()` etc. against any registered Mirage mount. Mirage mounts a filesystem of its own inside the interpreter, below the syscall boundary, so those calls reach Mirage's mount layer (RAM, S3, Linear, GDocs, Slack, anything you've registered).
```python
# inside python3
import json
team = json.load(open('/linear/teams/eng/team.json')) # reads through Linear's API
open('/ram/notes.txt', 'w').write('hello') # writes flush back to RAMResource
```
## What works
<Tabs>
<Tab title="Read a file">
```python
open('/s3/data.csv').read()
open('/linear/teams/eng/team.json').read()
open('/gdocs/owned/MyDoc.gdoc.json').read()
```
Every mounted prefix is collected before the run, so reads are sync and cost no round trip.
</Tab>
<Tab title="Write a file">
```python
open('/ram/out.txt', 'w').write('hello')
open('/s3/icon.png', 'wb').write(png_bytes)
```
`close()` records the write; when the script finishes, the bytes go back through the resource's `write` op, or its `append` op when the handle only extended the file.
</Tab>
<Tab title="List a directory">
```python
import os
for entry in os.listdir('/linear/teams/'):
print(entry)
# Synthesized paths work too, Gmail materializes date dirs on access:
for msg_dir in os.listdir('/gmail/INBOX/2026-05-03/'):
print(msg_dir)
```
A synthesized path is materialized when the prefix is collected, before the run.
</Tab>
<Tab title="pathlib & glob">
```python
from pathlib import Path
Path('/gdocs/owned/MyDoc.gdoc.json').read_text()
import glob
for f in glob.glob('/ram/*.txt'):
print(f)
```
</Tab>
<Tab title="Native libs">
```python
from PIL import Image
Image.new('RGB', (4, 4), color='red').save('/ram/icon.png')
import json
json.dump({'k': 'v'}, open('/ram/cfg.json', 'w'))
import numpy as np
np.save('/ram/arr.npy', np.arange(10))
```
Anything that goes through Python's `open()` works (PIL, numpy, pandas, json, pickle).
</Tab>
<Tab title="Symlinks & metadata">
```python
import os
os.symlink('report.json', '/ram/latest.json')
print(os.readlink('/ram/latest.json'), os.path.islink('/ram/latest.json'))
os.utime('/ram/report.json', (1767225600, 1767225600))
os.chmod('/ram/report.json', 0o600)
```
A link is namespace state, so no backend has to be able to store one:
the op reaches the name plane and a link lands on an S3 or Notion mount
the same way it lands on a RAM one. Links the shell made are collected
with the prefix, as links rather than as copies of their targets, so a
cyclic one is safe to walk past. A `chmod` or `utime` a backend cannot
keep is stored in the namespace overlay and reported back by `stat`, and
an `lstat` reads the link's own row rather than its target's, so a
`follow_symlinks=False` stamp is visible right after it is written.
`os.rename` moves a link by its own name instead of following it, and
`os.symlink` onto a name that is taken raises `FileExistsError`, both as
POSIX has them.
</Tab>
<Tab title="Cross-mount">
```python
# Read from one mount, write to another, same Python code:
import os, json
out = []
for t in os.listdir('/linear/teams/')[:5]:
team = json.load(open(f'/linear/teams/{t}/team.json'))
out.append({'name': team['name']})
json.dump(out, open('/ram/summary.json', 'w'), indent=2)
```
</Tab>
</Tabs>
## What doesn't work
| Case | Why | Workaround |
|---|---|---|
| **External edits show up mid-run**, someone else edits the GDoc while your script runs | The tree is collected before the run and served synchronously after that | Each run re-collects, so the next one sees the edit |
| **Huge mounts**, collecting a 100GB S3 bucket | Every byte is held in memory for the run | Mount a narrower prefix |
| **Concurrent writers**, two Python processes write the same path | Last `close()` wins, no conflict detection | Out of scope for v1 |
| **Browser-only resources from Node**, OPFS | OPFS isn't a thing in Node | Run in a browser-side Mirage |
## How it works (one paragraph)
Mirage registers its own Emscripten filesystem at each mount prefix, so every spelling of an operation (`open`, `os.open`, `pathlib`, a C extension's `fopen`, `shutil`'s fd-relative walk) arrives as the same callback. Emscripten's callbacks are synchronous, so nothing reaches the mount inline: before each run Mirage collects every mounted prefix into the filesystem's node table, and reads are served from there. Writes are recorded on a journal in the order the guest performed them, and replayed against the mounts after the script returns, where awaiting is free. A handle that only extended a file replays as an `append`, so an append loop ships its tail rather than the whole file each time. A failed replay lands on stderr with a nonzero exit, and stops the rest of the journal rather than applying entries whose prerequisite never landed.
```
Python open() / os.open() / C fopen()
Mirage filesystem callbacks (sync)
│ │
reads writes
│ │
▼ ▼
node table mutation journal
(collected │
before the run) script ends
replay against mounts
```
Nothing is patched inside the interpreter, and none of it needs JSPI.
## Quick start (TypeScript)
```ts
import { Workspace, RAMResource, S3Resource, MountMode } from '@struktoai/mirage-node'
const ws = new Workspace({}, { mode: MountMode.WRITE })
ws.addMount('/ram', new RAMResource(), MountMode.WRITE)
ws.addMount('/s3', new S3Resource({ bucket: 'my-bucket', region: 'us-east-1' }), MountMode.READ)
await ws.fs.writeFile('/ram/in.json', '{"hello":"world"}')
const r = await ws.execute(`python3 -c '
import json
print(json.load(open("/ram/in.json"))["hello"])
'`)
console.log(r.stdoutText) // "world"
```
See the full demo at [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts).
## Python packages (PIL, numpy, pandas, …)
Pyodide ships CPython, but third-party packages aren't loaded until you import them. Mirage scans the code you run for `import` statements and **auto-fetches matching packages on demand**, so `from PIL import Image`, `import numpy as np`, `import pandas as pd` all just work the first time. Subsequent calls hit Pyodide's package cache.
If you need to opt out (e.g., to keep workspace startup lean):
```ts
new Workspace({}, { python: { autoLoadFromImports: false } })
```
## Resource compatibility
| Resource | Status |
|---|---|
| RAM, OPFS (browser), Disk | ✅ |
| S3, R2, GCS, OCI, Supabase | ✅ (narrow the prefix to keep the working set small) |
| Linear, GDocs, GSheets, GSlides, GDrive | ✅ |
| GitHub, Redis | ✅ |
| Gmail (including synthesized date dirs) | ✅ (materialized when the prefix is collected) |
| Slack | ⚠️ preloads channel histories; large workspaces may be slow |
## Runtime requirements
None. Reads, mounts added between runs, and writes all work on any engine, with no V8 flag and no [JSPI](https://github.com/WebAssembly/js-promise-integration). That is the point of collecting the tree before the run and replaying writes after it: a filesystem callback never has to suspend, so nothing depends on stack switching, which no shipping Node enables by default and Safari does not implement.
To run the example:
```bash
node --import tsx/esm examples/typescript/pyodide/vfs.ts
```
## Errors you might see
| Symptom | What it means |
|---|---|
| `FileNotFoundError [Errno 44]` | The path was not in the tree collected before the run. A callback cannot go looking for more, so this is a genuine absence at collection time. |
| `python3: failed to <op> <path> on mount: ...` on stderr, exit 1 | The script finished but a recorded mutation could not be applied (auth, read-only mount, network). |
| `python3: skipped N later mutation(s) after that failure` | The journal is a sequence, not a set, so the replay stops at the first failure rather than applying entries whose prerequisite never landed. |
| `OSError [Errno 5]` on opening a file you can see | The mount listed the file but would not serve its bytes, so its real content is unknown and any handle is refused rather than guessing. |
| `console.warn: mirage preload: cannot read <path>` | One entry failed while the prefix was collected, others continued. The file is marked unreadable rather than left looking absent. |
## See also
- [`python`](/typescript/python): broader `python3` builtin behavior (env, argv, stdin, exit codes)
- [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts): runnable demo across RAM, S3, GDocs, Linear