Skip to content

Hash created nodes into Emscripten's lookup cache - #7

Merged
james-pre merged 3 commits into
zen-fs:mainfrom
alganet:lookup-cache
Sep 1, 2026
Merged

james-pre merged 3 commits into
zen-fs:mainfrom
alganet:lookup-cache

Conversation

@alganet

@alganet alganet commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Hash created nodes into Emscripten's lookup cache

@zenfs/emscripten · performance · trades node.mode freshness

FS.lookupNode(parent, name) consults FS.nameTable before it calls node_ops.lookup, and
FS.createNode() is what puts a node in that table:

// emscripten/src/lib/libfs.js
createNode(parent, name, mode, rdev) {
  var node = new FS.FSNode(parent, name, mode, rdev);

  FS.hashAddNode(node);          // <- this

  return node;
},

(libfs.js#L376)

EmscriptenPlugin.createNode() is the same thing without the insert. This plugin's header says it was
"Adapted from Emscripten's NodeFS", and library_nodefs.js goes through FS.createNode — the
adaptation kept everything except the hash. So nothing the plugin creates is ever in the lookup cache,
and every component of every path is re-resolved through lookup → getMode → fs.lstatSync for the
life of the instance.

The count is one lookup per path component per resolution, so it is arithmetic rather than a
benchmark: 100 resolutions of a six-component path cost 500 node_ops.lookup calls before this
and 5 after — five once, to populate nameTable, instead of five every time. The measurement
below is a test rather than a recipe, and needs no Emscripten build.

Stacked on ZENFS-1, the errno translation PR, which should land first — hence the number.

The trade

node.mode becomes a snapshot. Everything done through Emscripten keeps it current — setattr
writes the mode back on chmod — but a change made to the store by anything else is invisible to the
checks that read it, and those are more than access(): mayOpen, mayLookup, mayCreate and
mayDelete all test node.mode without calling getattr, and so does lookupPath itself —
isLink() and isMountpoint() decide how to traverse a component from the cached mode. Everything
that reaches the store — stat, filesize, reads, readdir — stays correct.

Concretely: after a second holder unlinks a file, FS.lookupPath() still resolves it out of
nameTable and the permission checks pass on a node whose store entry is gone — and mayCreate goes
through lookupNode, so creating that name again answers EEXIST against the phantom.

NODEFS has the same property against the host filesystem and ships that way, so caching everything is
defensible. It only bites when something other than Emscripten mutates the store behind its back,
which is our architecture and probably nobody else's.

Caching only directories is the conservative variant: directories are what paths are traversed
through, so they carry most of the repetition, while a file is looked up once and then held. That
keeps the staleness off files, which is where it is observable. Happy to switch if you would rather
not take it.

Retention changes too. A node now lives in nameTable until unlink, rmdir or unmount takes it
out, where before it was garbage as soon as the lookup returned. Walking a very large tree holds all
of it.

Measurement — no Emscripten build involved

tests/plugin.test.ts grows FS.nameTable, FS.hashName and FS.lookupNode, transcribed from
src/lib/libfs.js, because what the cache is worth cannot be seen without the thing that reads it.
The test then resolves one six-component path a hundred times and counts node_ops.lookup:

let lookups = 0;
const real = plugin.node_ops.lookup;
plugin.node_ops.lookup = (parent, name) => { lookups++; return real.call(plugin.node_ops, parent, name); };

for (let i = 0; i < 100; i++) resolve(root, 'a/b/c/d/leaf.txt');

assert.equal(lookups, 5, '5 components, resolved once each — not 500');

Run it with npm install && npm run build && npx tsx --test tests/plugin.test.ts.

Against main:

AssertionError: 5 components, resolved once each — not 500
  actual: 500
  expected: 5

Against this branch it passes. The count is arithmetic rather than a benchmark — one lookup per
path component per resolution — so it holds for any build.

The lifecycle was already correct

A cache added without its eviction is a leak. This one already had it:

  • FS.rename brackets node_ops.rename with hashRemoveNode / hashAddNode, so this plugin's
    rename setting oldNode.name lands in exactly the window that expects it.
  • FS.unlink and FS.rmdir reach hashRemoveNode through destroyNode.
  • FS.unmount sweeps nameTable for the mount's nodes — which, before this, found none of them.
Tests and notes for review

The fake FS that the errno PR added to tests/plugin.test.ts grows nameTable, hashName and
lookupNode alongside the hook. Two tests: one that a created node reaches hashAddNode, and one
that counts what the table saves. Removing the line from createNode fails both.

hashAddNode is an Emscripten internal and is absent from @types/emscripten, hence the widening
cast. It is called optionally so a build that does not expose it behaves exactly as before rather than
throwing — though the plugin already hard-depends on FSNode, isDir and ErrnoError being present
under those names, so this is more caution than the rest of the file shows.


Disclaimer: Co-authored with Claude Opus 5.

I got assistance from Claude for this change while developing wide, and this change (alongside others in zen-fs/core and zen-fs/dom that I plan to upstream) is live in that project, built from my local fork.

zen-fs is powering all the filesystem sharing across wasi-sh, php-lsp and phasm.

This one helps in project directory traversal performance, and it has a partial staleness trade-off that I considered acceptable. However, I don't fully know other zen-fs/emscripten consumers and their particular use cases. I instructed Claude to think of it as an independent upstreamable contribution that should follow the zen-fs vision.

FS.lookupNode() consults FS.nameTable before it calls node_ops.lookup, and
FS.createNode() is what puts a node there. This plugin was adapted from NodeFS,
which goes through FS.createNode — the adaptation kept everything except the
insert, so every component of every path was re-resolved through lookup ->
getMode -> lstatSync for the life of the instance. A hundred resolutions of a
six-component path cost 500 node_ops.lookup calls; they now cost 5.

The test measures that rather than asserting the call. tests/plugin.test.ts
grows FS.nameTable, FS.hashName and FS.lookupNode, transcribed from
src/lib/libfs.js, because what the cache is worth cannot be seen without the
thing that reads it. No Emscripten build is involved.

The rest of the cache's lifecycle already worked: FS brackets node_ops.rename
with hashRemoveNode/hashAddNode, and unlink, rmdir and unmount all reach
hashRemoveNode through destroyNode.

The trade is that node.mode becomes a snapshot. mayOpen, mayLookup, mayCreate
and mayDelete read it without calling getattr, and so does lookupPath itself —
isLink() and isMountpoint() decide how to traverse a component from the cached
mode. So a store mutated by anything other than Emscripten is invisible to
those, while everything that reaches the store stays correct. NODEFS has the
same property against the host filesystem.

hashAddNode is absent from @types/emscripten, so it is called optionally: a
build that does not expose it behaves exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alganet
alganet requested a review from james-pre as a code owner August 28, 2026 00:56

@james-pre james-pre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

Comment thread src/plugin.ts Outdated
Comment thread src/plugin.ts Outdated
Comment thread tests/plugin.test.ts
Comment thread tests/plugin.test.ts Outdated
Comment thread tests/plugin.test.ts Outdated
alganet and others added 2 commits September 1, 2026 17:21
Co-authored-by: James Prevett <jp@jamespre.dev>
hashAddNode, nameTable and FSNode.name_next reached @types/emscripten in
DefinitelyTyped#75459, released as 1.41.6, so the call goes through the declared
type instead of a widening cast. It is no longer called optionally either: the
type declares it present, and the plugin already hard-depends on FSNode, isDir
and ErrnoError being there under those names.

The corrected FSNode constructor signature — parent nullable, rdev optional —
retires the two assertions createNode needed to satisfy the old one, and
EmFS.FSNode carrying id and name_next retires the shim the test kept for them.

One unrelated fix rides along: the lookup counter held node_ops.lookup unbound,
which eslint reports as an error rather than a warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@james-pre james-pre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again!

@james-pre
james-pre merged commit a51c136 into zen-fs:main Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants