Repository navigation
Hash created nodes into Emscripten's lookup cache - #7
Merged
Merged
Conversation
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>
james-pre
reviewed
Aug 28, 2026
7 of 8 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hash created nodes into Emscripten's lookup cache
@zenfs/emscripten· performance · tradesnode.modefreshnessFS.lookupNode(parent, name)consultsFS.nameTablebefore it callsnode_ops.lookup, andFS.createNode()is what puts a node in that table:(
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.jsgoes throughFS.createNode— theadaptation 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.lstatSyncfor thelife of the instance.
The count is one
lookupper path component per resolution, so it is arithmetic rather than abenchmark: 100 resolutions of a six-component path cost 500
node_ops.lookupcalls before thisand 5 after — five once, to populate
nameTable, instead of five every time. The measurementbelow is a test rather than a recipe, and needs no Emscripten build.
The trade
node.modebecomes a snapshot. Everything done through Emscripten keeps it current —setattrwrites the mode back on
chmod— but a change made to the store by anything else is invisible to thechecks that read it, and those are more than
access():mayOpen,mayLookup,mayCreateandmayDeleteall testnode.modewithout callinggetattr, and so doeslookupPathitself —isLink()andisMountpoint()decide how to traverse a component from the cached mode. Everythingthat reaches the store —
stat,filesize, reads,readdir— stays correct.Concretely: after a second holder unlinks a file,
FS.lookupPath()still resolves it out ofnameTableand the permission checks pass on a node whose store entry is gone — andmayCreategoesthrough
lookupNode, so creating that name again answersEEXISTagainst 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
nameTableuntilunlink,rmdirorunmounttakes itout, 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.tsgrowsFS.nameTable,FS.hashNameandFS.lookupNode, transcribed fromsrc/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:Run it with
npm install && npm run build && npx tsx --test tests/plugin.test.ts.Against
main:Against this branch it passes. The count is arithmetic rather than a benchmark — one
lookupperpath 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.renamebracketsnode_ops.renamewithhashRemoveNode/hashAddNode, so this plugin'srenamesettingoldNode.namelands in exactly the window that expects it.FS.unlinkandFS.rmdirreachhashRemoveNodethroughdestroyNode.FS.unmountsweepsnameTablefor the mount's nodes — which, before this, found none of them.Tests and notes for review
The fake
FSthat the errno PR added totests/plugin.test.tsgrowsnameTable,hashNameandlookupNodealongside the hook. Two tests: one that a created node reacheshashAddNode, and onethat counts what the table saves. Removing the line from
createNodefails both.hashAddNodeis an Emscripten internal and is absent from@types/emscripten, hence the wideningcast. 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,isDirandErrnoErrorbeing presentunder 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.