A context as a filesystem¶
A context is a tree of named things with values. So is a directory. This is the machinery that makes the second a view of the first: with one preference switched on, the open scene appears at a path, every object in it is a file, reading a file renders the object and writing one changes it.
Three pieces, each usable without the next:
| Piece | What it is | Needs |
|---|---|---|
DataSource storage + MIME |
any file, held three ways, identified | nothing |
ContextFileSystem |
the tree: paths, bytes, write-back | nothing |
ContextMount |
that tree at a real path | /dev/fuse, fusermount3 |
1. Any file, and where its bytes live¶
A DataSource could always hold a file. It could not say what the file was,
and it had one bit — Embedded — for a question with three answers. Both are
fixed.
Storage¶
- External — the path travels, the bytes do not. Read when needed, nothing kept. A scene stays small and always reflects the file on disk; move the file and the scene is broken.
- Cached — still a file on disk, but its bytes are held in memory too, so repeated reads cost nothing. Not saved: a document written from a cached source carries the path, exactly as External does. A RAM cache, not a copy of record.
- Embedded — the bytes are the source of truth and are written into the document, which then opens anywhere with nothing beside it.
Embedded the property is still there, still in the same place in the
payload, and still means "the bytes travel"; Storage is the fuller answer
and the two are kept in step in both directions. AddFile(path, storage) is
the one call that does everything: records the path, identifies the type, sets
Format only when a codec of ours reads that type, and places the bytes.
The distinction is not theoretical — it is what
DataSourceFileTest::TestWhatTheDocumentCarries demonstrates by deleting the
file and reading the document again: the embedded source still has its bytes,
the cached and external ones say honestly that they have nothing and why.
MimeType¶
Core/MimeTypes.h names a file from both ends. Content first, the name as the
tie-breaker:
Mime::Identify("scan.stl", pngBytes, size); // "image/png" -- content wins
Mime::Identify("recipe.py", pythonSource, n); // "text/x-python"
Mime::Identify("notes.md", binaryBytes, n); // "application/octet-stream"
Mime::Identify("mystery"); // "application/octet-stream"
"It reads as text" is the weakest thing content can say — every source file
and every CSV passes it — so a more specific name survives it, which is how
.py stays Python. A name claiming text over bytes that are not text loses.
It is a small explicit table, not a parse of /usr/share/mime: a scene saved
on one machine has to say the same thing about its files on another, and a
shared-mime-info database differs between distributions. Unrecognised is
application/octet-stream, which is an answer. Mime::FormatFor() maps a
type to this project's codec name where there is one, which is what keeps a
PDF from claiming a Format no codec can read.
Both new properties are kept out of the property walk
(SetSerialized(false)) and written by serialize() behind
BOOST_CLASS_VERSION(DataSource, 1) — the walk writes every serialized
property positionally, so a field added there would have shifted the payload
of every DataSource ever saved. A version 0 document still reads.
2. The tree¶
ContextFileSystem is the whole of the idea, with no mounting in it: paths
in, bytes out, bytes in, objects changed. Which means every rule below is
tested without a kernel, a mount point or a privilege
(ContextFileSystemTest).
/ the context
/beam.json an algorithm -- its fields, as JSON
/main.py a Recipe -- its Python, as Python
/readme.md a TextBuffer, by its declared media type
/cube.stl a DataSource -- the file it holds, itself
/world/ an object that has children
/world/world.json its own fields
/world/detector.json a child
An object with children is a directory holding its own file beside
theirs; an object without children is just the file. Two objects of one
name is legal in a context and impossible in a directory, so the second gets a
suffix before the extension (sink.json, sink-2.json).
What is in a file¶
The object's class decides — there is no per-object setting
(ContextFileSystem::Rendering records which it was):
| Class | Content | Name |
|---|---|---|
one that registered a FileRepresentation |
what it registered (below — an image or a collection is its read-only binary payload) | name.<its extension> |
DataSource |
the file it holds (Bypass) |
the file's own name |
DataSink with a format |
the file it wrote, read-only (Bypass, below) |
its file's name, or name.<format> |
Recipe |
its Python | name.py |
TextBuffer |
its text | name.md, name.txt, … by its MimeType |
| anything else | its fields, as JSON | name.json |
The JSON is not a new encoding. It comes from the same schema walk
(ObjectFactory::SaveObjectTable → uprops::SchemaTable) that the
FlatBuffers wire format's generic tier carries and the payload-shape test
hashes — so every class in the library has a JSON form already and none of
them had to be taught how, which is what lets a mounted context show a file
for a class nobody thought about. Core/SchemaJson.h renders it and reads it
back: enums appear as their labels, a vector as an array, a nested table as an
object, bytes as base64, and with metadata on, each field's units,
description, range and choices come along under @meta.
Both directions¶
Writing a file applies it through the same loader a document load uses, and
the object announces the change once — a CoalescedUpdate guard around
the whole application, because everything downstream of an object is
subscribed to Object::Updated, which carries no payload, so an announcement
per field would be a render, an AutoRun pass and a panel rebuild per field.
Nothing is half-applied: a JSON file that does not parse changes nothing and
says why.
A field the class does not have is reported, not silently dropped. The
class and name header keys are informational — a write-back never renames
or re-classes anything.
A file written is new whether or not its object says so: the view bumps its
version itself, so the next read returns what was written and never a cached
rendering from before. A recipe's file is the clearest case — SetSource()
announces nothing (an editor calls it per keystroke) — so a recipe written
through the view also announces Updated() once, which is what lets an editor
panel showing it follow. Recipe and text-buffer files are never cached at all:
reading one is a string copy, and either can change without a word.
The other direction is the FileChanged signal: an object that announces an
update makes its file new (a fresh mtime, a fresh size), which is what a mount
turns into the event an editor or an inotify watcher sees.
Every change the view makes to the scene — a write applied, a rename, an
object added — runs through SetDispatcher(): a mount asks from its own
thread, and an application whose scene belongs to one thread runs the change
there.
For a DataSource the write goes where the bytes actually live: an
External source's file on disk is what is being shown, so that is what a
write changes; an Embedded one takes the bytes into its own buffer.
Large objects: a file of their own, and a size worked out once¶
A file manager stats every file it shows, again on every refresh, and the
mount answers with zero attribute timeouts. When a stat meant rendering the
object, a scene with an image in it got slower to look at as the image grew,
for no reason: a 100³ VoxImage rendered as JSON is 10.7 MB (its voxels in
base64), about 100 ms of work, and that was paid on every stat, every
lookup, every open and for every entry of every listing. A cat of it then
copied the whole rendering once per 128 KB chunk the kernel asked for.
Three things fix it, each useful alone:
- Sizes are kept. Each file's length (and media type) is worked out once
per change of its object and kept, keyed on the version an
Updated()bumps; the bytes too, up toSetCacheLimit()(64 MiB by default). A look at an unchanged object renders nothing. Caches survive a rebuild of the tree, so adding one object does not re-render the others. Data sources and sinks are the exception — their file can change on disk unannounced — and are measured with a stat of that file instead. - A class can say what its file is.
ContextFileSystem::RegisterRepresentation<T>()takes aFileRepresentation: an extension, a media type, arender, an optionalsizethat needs no rendering, and an optionalapply. Withoutapplythe file is read-only (0444to the kernel) — readable, copyable out, renamable, never written. Math registersFileRepresentation::BinaryPayload()—name.bin, the payload a document holds — forVoxImage,PointSetandEventTable. - A real file format is a sink's. An object is not converted to some
other format on the way out: that is a decision, and the scene already has
the object that makes it. A
DataSinkwhose format is known (itsFormat, or itsFilePath's extension) is shown as the file it wrote — under that file's name, or asname.<format>when embedded — read-only, since the next change of its input rewrites it. Wire an image to avtisink and the mount has a.vtiParaView opens as it is. A sink with no format is an object like any other (name.json). An embedded sink renames like any object; one writing a file is named by that file and does not. - Open files are shared, not copied. A mount serves reads of an open file
from one
shared_ptrsnapshot (ContextFileSystem::Snapshot()), so a chunked read costs what the chunk costs.
ContextFileSystemImageTest measures it, in process and through a real mount:
| 10³ voxels | 100³ voxels | |
|---|---|---|
stat + ls through the mount |
0.10 ms | 0.10 ms |
| open + read 512 B (a file manager sniffing) | 0.06 ms | 0.06 ms |
cat of image.bin |
0.1 ms | 16 ms (8 MB) |
cat of a vti sink's export.vti |
0.1 ms | 0.7 ms (272 KB) |
| one JSON rendering — what every look used to cost | 106 ms (10.7 MB) |
Rebuilding, and not rebuilding¶
A context announces itself whenever anything in it changes, values included.
Rebuilding the tree on every announcement would throw away and remake every
per-object subscription several times a second — and worse, a file's own
change notification would be destroyed before it could fire, which is exactly
what happened before RefreshIfStale() compared a signature of what the tree
is made of (which objects, under which names, in which rendering) instead.
Versions and mtimes carry across a genuine rebuild, so adding one object does
not make every other file look new.
3. The mount¶
ContextMount puts that tree at a path. Then ls, cat, an editor, a Python
script and the file manager all reach the objects in the open scene as
ordinary files.
No libfuse¶
There is deliberately no build-time dependency, for the same reason
DeviceMemory dlopens the CUDA driver rather than linking a toolkit: the
stable thing is the protocol, not the library. The kernel's side of FUSE is
one UAPI header (<linux/fuse.h>, present wherever Linux headers are), and
the only privileged step — the mount() call — is what the setuid
fusermount3 exists to do: it mounts, and hands the open device back over a
unix socket (_FUSE_COMMFD, SCM_RIGHTS). So ContextMount speaks the
protocol itself over /dev/fuse: INIT, LOOKUP, GETATTR, SETATTR,
OPENDIR/READDIR, OPEN/READ/WRITE/FLUSH/RELEASE, CREATE/MKNOD,
UNLINK, RENAME/RENAME2, STATFS, ACCESS. Everything else is answered
ENOSYS.
A machine with no /dev/fuse and a machine with no fusermount3 both get
IsSupported() == false and a sentence saying which.
Three details that cost a debugging session each:
- A read is rendered once, at open. A file read in four chunks has to be one consistent answer, not four renderings of a scene that moved in between.
- A write is accumulated and applied on flush. The kernel sends a
document in pages, and a page of JSON is not a document. A failed apply
fails the
close(), which is where a shell and an editor look for it. - Invalidations are sent from their own thread. A
FileChangedarising from a write happens inside the request being answered, and telling the kernel to invalidate an inode from within a request on that same inode is the one thing the protocol says not to do — the kernel holds the inode and waits for the reply, the invalidation waits for the inode. A queue and a second thread keep them apart.
Attribute and entry timeouts are zero: the kernel asks every time, so the
files always show the scene as it is. The cost is a round trip per stat,
which is nothing next to being wrong.
New files, saving, renaming, and what it refuses¶
A file created in the mount is staged. cp or mv from outside, a drag
in the file manager, an editor's temporary: it is a file in the view's memory
that reads and writes like any other, and is not an object yet — because the
file an editor creates is as often a temporary it is about to rename over the
original as it is something new. Then one of three things happens:
- It is renamed over an object's file — an atomic save. The object takes
the bytes exactly as if they had been written in place, and stays the same
object (its connections, its place in the scene). This is how gedit and
everything on GIO, Kate (
QSaveFile), JetBrains,sed -iand Python'sos.replacesave. - It is left alone, closed, for the settle delay (
ContextMount::SetSettleDelay, 1 s) — it is something new, and is added to the scene where it is (at the top, or among the children of the object whose directory it is in):
| File | Becomes |
|---|---|
.py |
a Recipe with that source |
.md, .txt |
a TextBuffer with that text |
.json whose class this library has |
that class, with those values — the inverse of what the view shows for one |
anything else (.vtk, .stl, an unknown .json, …) |
a DataSource holding the bytes, embedded, under that file name |
ContextFileSystem::RegisterImporter() adds to that; an importer whose
object would not appear under exactly that name is overruled by a
DataSource, which always does.
* It is removed — gone, nothing happened.
A scratch name — hidden (.main.py.swp, .goutputstream-…), a backup
(main.py~, .bak, .orig, .tmp, .part, JetBrains' ___jb_), an
autosave (#main.py#) — is staged but never becomes an object.
Moving an object's file to a scratch name is a backup: the object stays where it is, and a scratch copy of its file appears under the new name. That is vim's save (move the original aside, write a new one, remove the backup), and it ends with the object written in place.
Renaming an object to a free name renames the object: mv beam.json
gun.json makes it gun; mv world earth renames a directory's object.
Within one directory only (EXDEV otherwise — moving an object is a change
of structure), to a name not taken (EEXIST), keeping the extension
(EINVAL — it is the rendering's, not the name's). A data source, and a sink
writing a file, are named by that file, and the file of a directory's own
object by its directory, so these are refused (EPERM). The mount moves the
inode numbers with the name, so an open file and the kernel's cache follow
it.
Refused: deleting an object's file (EPERM — that is the scene's call),
making a directory, links. A read-only view does none of the above
(EROFS).
GVFS, and what "a volume" actually means¶
Once mounted it is in /proc/mounts like any other filesystem:
That is the layer GVFS reads. gio info <folder> names it as a unix mount of
type fuse.ulibfs, and in a desktop session GVFS's volume monitor is what
puts such a mount in the file manager's sidebar — by its own rule, which
shows mounts under the home directory and not under /tmp. That is why the
preference defaults to ~/ulib-context.
A native GVFS backend (a gvfsd backend registered by a .mount file in
/usr/share/gvfs/mounts) is the other way to do this. It needs root to
install and is reachable only by GIO-aware applications — not by cat, not
by python, not by an editor, unless gvfsd-fuse re-exports it as FUSE
anyway. FUSE is the primitive; this takes it directly.
4. In gcompose¶
Preferences → Coding → Context as a filesystem (experimental): a
checkbox, the folder, and a read-only switch. ContextMountService applies
them exactly as McpHttpService applies the MCP endpoint's, and follows the
open document — opening another scene re-mounts rather than leaving a tree of
objects that are gone. Where FUSE is unavailable the group is disabled with
the reason in place of the status.
The service installs the dispatcher that runs the mount's changes on the GUI thread. Not as a blocking queued call: the GUI thread can itself be waiting on the mount (a file dialog looking at the folder), and the mount's thread, waiting for it, would never answer. So the wait has a deadline (5 s); a change that has not started by then is cancelled and never runs, and closing the mount cancels at once.
The recipe editor follows its recipe: a .py saved through the mount shows
in it straight away, keeping the cursor. Before, the editor kept its own copy
and the next Run or Export wrote it back over the saved file.
5. The example, and the tests¶
src/Core/examples/03_a_context_as_files.cpp (run by ctest, and shipped as
assets/examples/recipes/03_a_context_as_files.gcx) builds a scene with one
of each kind of object, prints the tree, mounts it, reads it with
open/read/stat, writes a recipe and a JSON property back, changes an
object in the scene and reads the file again, and shows what is refused. With
no FUSE it prints why and does the tree half only.
| Test | Covers |
|---|---|
DataSourceFileTest |
identification, the three storage modes, what a document carries |
ContextFileSystemTest |
the tree, every rendering, write-back, change propagation, a file rendered once per change, a recipe reading back what was written, new files becoming objects, the ways editors save, the dispatcher |
ContextMountTest |
the same through real system calls on a real mount — including sed -i, Python's os.replace, vim, gio save, cp, mv and gio copy in, where installed; skips itself where FUSE is unavailable |
ContextFileSystemImageTest |
an image of 10³ and 100³ voxels: its read-only payload, a vti sink shown as its output, rename, and that looking costs the same at both sizes — in process and mounted |