From 1508fb92ff60c47485bb758971788b86d89971e4 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Wed, 26 Aug 2026 01:10:47 -0300 Subject: [PATCH 1/3] Hash created nodes into Emscripten's lookup cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/plugin.ts | 6 ++++ tests/plugin.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/plugin.ts b/src/plugin.ts index d0dfe6a..b538c73 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -62,6 +62,12 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { const node: Node = new this.em_fs.FSNode(parent!, name, mode, rdev!); node.node_ops = this.node_ops; node.stream_ops = this.stream_ops; + + // FS.createNode() adds the node to the lookup cache and this adaptation of NodeFS dropped + // it, so every path component was re-resolved on every lookup. hashAddNode is not in + // @types/emscripten, and is called optionally so a build without it behaves as before. + (this.em_fs as typeof EmFS & { hashAddNode?(node: EmFS.FSNode): void }).hashAddNode?.(node); + return node; } diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 371d509..624a861 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -34,12 +34,34 @@ class FSNode { } } +const hashed: EmFS.FSNode[] = []; + +// FS.nameTable and friends, straight out of emscripten's src/lib/libfs.js, since +// what the cache is worth can't be seen without the thing that reads it. +type Cached = EmFS.FSNode & { name_next?: Cached; id: number }; +const nameTable: (Cached | undefined)[] = new Array(4096); + +function hashName(parentid: number, name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return ((parentid + hash) >>> 0) % nameTable.length; +} + const em_fs = { ErrnoError, FSNode, isDir: (mode: number) => (mode & 0o170000) === 0o040000, isFile: (mode: number) => (mode & 0o170000) === 0o100000, isLink: (mode: number) => (mode & 0o170000) === 0o120000, + hashAddNode: (node: EmFS.FSNode) => { + hashed.push(node); + const cached = node as Cached; + const hash = hashName(cached.parent.id as unknown as number, cached.name); + cached.name_next = nameTable[hash]; + nameTable[hash] = cached; + }, } as unknown as typeof EmFS; mount('/zen', InMemory.create({ label: 'plugin-test' })); @@ -102,3 +124,48 @@ suite('errnos crossing into Emscripten', () => { assert.equal(plugin.node_ops.getattr(node).size, 5); }); }); + +/** FS.lookupNode: the table first, node_ops.lookup only when it misses. */ +function lookupNode(parent: Cached, name: string): Cached { + for (let node = nameTable[hashName(parent.id, name)]; node; node = node.name_next) { + if ((node.parent as unknown as Cached).id === parent.id && node.name === name) return node; + } + return plugin.node_ops.lookup(parent, name) as Cached; +} + +/** And FS.lookupPath, for a path with no symlinks or mounts in it. */ +function resolve(root: Cached, path: string): Cached { + let current = root; + for (const part of path.split('/')) current = lookupNode(current, part); + return current; +} + +suite("Emscripten's lookup cache", () => { + test('a created node is hashed into it', () => { + const node = plugin.createNode(null, 'hashed.txt', 0o100644); + assert.ok(hashed.includes(node)); + }); + + test('a resolved path is resolved once, not once per resolution', () => { + fs.mkdirSync('/zen/root/a/b/c/d', { recursive: true }); + fs.writeFileSync('/zen/root/a/b/c/d/leaf.txt', 'x'); + + const root = rooted() as Cached; + (root as unknown as FSNode).mount = { opts: { root: '/zen/root' } }; + + let lookups = 0; + const real = plugin.node_ops.lookup; + plugin.node_ops.lookup = (parent: EmFS.FSNode, name: string) => { + lookups++; + return real.call(plugin.node_ops, parent, name); + }; + + try { + for (let i = 0; i < 100; i++) resolve(root, 'a/b/c/d/leaf.txt'); + } finally { + plugin.node_ops.lookup = real; + } + + assert.equal(lookups, 5, '5 components, resolved once each — not 500'); + }); +}); From f2d559c41bcda304d8b4d30d45ea15ebfed0f79c Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Tue, 1 Sep 2026 17:21:03 -0300 Subject: [PATCH 2/3] Cleanup comments and add adjust test descriptions Co-authored-by: James Prevett --- src/plugin.ts | 3 --- tests/plugin.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index b538c73..9a5b214 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -63,9 +63,6 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { node.node_ops = this.node_ops; node.stream_ops = this.stream_ops; - // FS.createNode() adds the node to the lookup cache and this adaptation of NodeFS dropped - // it, so every path component was re-resolved on every lookup. hashAddNode is not in - // @types/emscripten, and is called optionally so a build without it behaves as before. (this.em_fs as typeof EmFS & { hashAddNode?(node: EmFS.FSNode): void }).hashAddNode?.(node); return node; diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 624a861..58f0994 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -141,12 +141,12 @@ function resolve(root: Cached, path: string): Cached { } suite("Emscripten's lookup cache", () => { - test('a created node is hashed into it', () => { + test('a created node is hashed into it #7', () => { const node = plugin.createNode(null, 'hashed.txt', 0o100644); assert.ok(hashed.includes(node)); }); - test('a resolved path is resolved once, not once per resolution', () => { + test('a resolved path is resolved once, not once per resolution #7', () => { fs.mkdirSync('/zen/root/a/b/c/d', { recursive: true }); fs.writeFileSync('/zen/root/a/b/c/d/leaf.txt', 'x'); From 20740793c6fb2fac1536c3abd86c23660d536121 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Tue, 1 Sep 2026 17:23:15 -0300 Subject: [PATCH 3/3] Drop the cast now the types are upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 8 ++++---- package.json | 2 +- src/plugin.ts | 6 ++---- tests/plugin.test.ts | 24 +++++++++++------------- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6465a5..0873b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "LGPL-3.0-or-later", "devDependencies": { "@eslint/js": "^10.0.1", - "@types/emscripten": "^1.40.0", + "@types/emscripten": "^1.41.6", "eslint": "^10.1.0", "globals": "^17.0.0", "prettier": "^3.2.5", @@ -289,9 +289,9 @@ "license": "MIT" }, "node_modules/@types/emscripten": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", - "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 1ad2584..bc3f248 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/emscripten": "^1.40.0", + "@types/emscripten": "^1.41.6", "eslint": "^10.1.0", "globals": "^17.0.0", "prettier": "^3.2.5", diff --git a/src/plugin.ts b/src/plugin.ts index 9a5b214..354380e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -59,12 +59,10 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!this.em_fs.isDir(mode) && !this.em_fs.isFile(mode) && !this.em_fs.isLink(mode)) { throw new this.em_fs.ErrnoError(toEmscriptenErrno(Errno.EINVAL)); } - const node: Node = new this.em_fs.FSNode(parent!, name, mode, rdev!); + const node: Node = new this.em_fs.FSNode(parent, name, mode, rdev); node.node_ops = this.node_ops; node.stream_ops = this.stream_ops; - - (this.em_fs as typeof EmFS & { hashAddNode?(node: EmFS.FSNode): void }).hashAddNode?.(node); - + this.em_fs.hashAddNode(node); return node; } diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 58f0994..a16dae2 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -38,8 +38,7 @@ const hashed: EmFS.FSNode[] = []; // FS.nameTable and friends, straight out of emscripten's src/lib/libfs.js, since // what the cache is worth can't be seen without the thing that reads it. -type Cached = EmFS.FSNode & { name_next?: Cached; id: number }; -const nameTable: (Cached | undefined)[] = new Array(4096); +const nameTable: (EmFS.FSNode | undefined)[] = new Array(4096); function hashName(parentid: number, name: string): number { let hash = 0; @@ -57,10 +56,9 @@ const em_fs = { isLink: (mode: number) => (mode & 0o170000) === 0o120000, hashAddNode: (node: EmFS.FSNode) => { hashed.push(node); - const cached = node as Cached; - const hash = hashName(cached.parent.id as unknown as number, cached.name); - cached.name_next = nameTable[hash]; - nameTable[hash] = cached; + const hash = hashName(node.parent.id, node.name); + node.name_next = nameTable[hash]; + nameTable[hash] = node; }, } as unknown as typeof EmFS; @@ -126,15 +124,15 @@ suite('errnos crossing into Emscripten', () => { }); /** FS.lookupNode: the table first, node_ops.lookup only when it misses. */ -function lookupNode(parent: Cached, name: string): Cached { +function lookupNode(parent: EmFS.FSNode, name: string): EmFS.FSNode { for (let node = nameTable[hashName(parent.id, name)]; node; node = node.name_next) { - if ((node.parent as unknown as Cached).id === parent.id && node.name === name) return node; + if (node.parent.id === parent.id && node.name === name) return node; } - return plugin.node_ops.lookup(parent, name) as Cached; + return plugin.node_ops.lookup(parent, name); } /** And FS.lookupPath, for a path with no symlinks or mounts in it. */ -function resolve(root: Cached, path: string): Cached { +function resolve(root: EmFS.FSNode, path: string): EmFS.FSNode { let current = root; for (const part of path.split('/')) current = lookupNode(current, part); return current; @@ -150,14 +148,14 @@ suite("Emscripten's lookup cache", () => { fs.mkdirSync('/zen/root/a/b/c/d', { recursive: true }); fs.writeFileSync('/zen/root/a/b/c/d/leaf.txt', 'x'); - const root = rooted() as Cached; + const root = rooted(); (root as unknown as FSNode).mount = { opts: { root: '/zen/root' } }; let lookups = 0; - const real = plugin.node_ops.lookup; + const real = plugin.node_ops.lookup.bind(plugin.node_ops); plugin.node_ops.lookup = (parent: EmFS.FSNode, name: string) => { lookups++; - return real.call(plugin.node_ops, parent, name); + return real(parent, name); }; try {