Skip to content

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

enum class DataStorage { External, Cached, Embedded };
  • 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 to SetCacheLimit() (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 a FileRepresentation: an extension, a media type, a render, an optional size that needs no rendering, and an optional apply. Without apply the file is read-only (0444 to the kernel) — readable, copyable out, renamable, never written. Math registers FileRepresentation::BinaryPayload() — name.bin, the payload a document holds — for VoxImage, PointSet and EventTable.
  • 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 DataSink whose format is known (its Format, or its FilePath's extension) is shown as the file it wrote — under that file's name, or as name.<format> when embedded — read-only, since the next change of its input rewrites it. Wire an image to a vti sink and the mount has a .vti ParaView 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_ptr snapshot (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 FileChanged arising 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 -i and Python's os.replace save.
  • 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:

ulib:example /tmp/ulib_example_context fuse.ulibfs rw,nosuid,nodev,...

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