Skip to content

Translate errnos at the Emscripten boundary - #6

Merged
james-pre merged 1 commit into
zen-fs:mainfrom
alganet:errno-translation
Aug 27, 2026
Merged

james-pre merged 1 commit into
zen-fs:mainfrom
alganet:errno-translation

Conversation

@alganet

@alganet alganet commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

The problem

kerium's Errno is Linux's numbering, and that is what every error reaching this package carries. Emscripten's FS checks against its own table, which takes its values from WASI. Nothing translated between the two.

They disagree on every one of the 130 errnos kerium defines — there is not a single value the two tables happen to share. On the two that matter most, they disagree by swapping:

Linux Emscripten
ENOENT 2 44
EACCES 13 2

So an untranslated ENOENT arrives at Emscripten as EACCES, and that is not just a wrong message — file creation stops working. FS.lookupPath forgives a missing final component under O_CREAT only when it sees its own ENOENT:

// emscripten/src/lib/libfs.js
if ((e?.errno === {{{ cDefs.ENOENT }}}) && islast && opts.noent_okay) {

https://github.com/emscripten-core/emscripten/blob/a04d0beef36bfa7661cee4b8dff7d3909303dabc/src/lib/libfs.js#L278

It saw 2, did not forgive, and reported that 2 back as "Permission denied" — for a directory that is perfectly writable. Every other operation looks fine, which is what makes it hard to spot.

Reproduction

No third-party code: @zenfs/core is the only import besides the package itself, so this runs in a
fresh clone after npm install && npm run build.

import { fs, InMemory, mount } from '@zenfs/core';
import EmscriptenPlugin from '@zenfs/emscripten/plugin.js';

// Emscripten's FS, reduced to the handful of things the plugin touches.
// ErrnoError records the number it is handed, which is all this needs to show.
const FS = {
    ErrnoError: class extends Error {
        constructor(errno) {
            super('errno ' + errno);
            this.errno = errno;
        }
    },
    FSNode: class {
        constructor(parent, name, mode) {
            this.parent = parent ?? this;
            this.name = name;
            this.mode = mode;
        }
    },
    isDir: mode => (mode & 0o170000) === 0o040000,
    isFile: mode => (mode & 0o170000) === 0o100000,
    isLink: mode => (mode & 0o170000) === 0o120000,
};

mount('/zen', InMemory.create({ label: 'repro' }));
fs.mkdirSync('/zen/root');

const plugin = new EmscriptenPlugin(fs, FS);

try {
    plugin.getMode('/zen/root/absent.txt');
} catch (error) {
    console.log('a missing path raises errno', error.errno);
}
against main:        a missing path raises errno 2
against this branch: a missing path raises errno 44

2 is Emscripten's EACCES; 44 is its ENOENT. Since FS.lookupPath forgives a missing final
component under O_CREAT only for 44, that 2 is what stops files from being created — and it is
reported as "Permission denied".

The same thing end to end, in a real Emscripten module

Not the minimal case — it needs a build to mount into, and the one below is a PHP CLI. Substitute
any module that exposes FS. Included because it shows the actual consequence rather than the
errno behind it.

import { fs, InMemory, mount } from '@zenfs/core';
import EmscriptenPlugin from '@zenfs/emscripten/plugin.js';

// Any Emscripten build that exposes FS. This one is a PHP CLI.
const em = await (await import('@alganet/phasm')).default();

mount('/zen', InMemory.create({ label: 'repro' }));
fs.mkdirSync('/zen/root');

em.FS.mkdirTree('/mnt');
em.FS.mount(new EmscriptenPlugin(fs, em.FS), { root: '/zen/root' }, '/mnt');

console.log('the mount point is a writable directory:', em.FS.isDir(em.FS.stat('/mnt').mode));

try {
    em.FS.writeFile('/mnt/new.txt', 'hello');
    console.log('writeFile("/mnt/new.txt", "hello"): ok');
} catch (error) {
    console.log('writeFile("/mnt/new.txt", "hello"): errno', error.errno);
}

console.log('readdir("/mnt"):', em.FS.readdir('/mnt'));

try {
    em.FS.stat('/mnt/missing');
} catch (error) {
    console.log('stat("/mnt/missing"): errno', error.errno);
}

Against main:

the mount point is a writable directory: true
writeFile("/mnt/new.txt", "hello"): errno 2
readdir("/mnt"): [ '.', '..' ]
stat("/mnt/missing"): errno 2

Against this branch:

the mount point is a writable directory: true
writeFile("/mnt/new.txt", "hello"): ok
readdir("/mnt"): [ 'new.txt', '.', '..' ]
stat("/mnt/missing"): errno 44

The directory is writable in both runs; in the first the file is simply never created.

The fix

src/errno.ts maps Linux errnos to Emscripten's, and plugin.ts calls it at each point an error is raised.

Translating once, at the throw, is load-bearing. A second pass is not idempotent: 13 maps to 2, and 2 maps on to 44. Wrapping an already-translated plugin from outside turns "permission denied" into "no such file". Raising also covers the two sites that passed kerium's Errno straight into em_fs.ErrnoError, and mount(), which reaches the store outside both op tables to stat the mount root — a missing root used to report "Permission denied" rather than "not there".

Why the whole table

The table covers all 130 of kerium's errnos rather than the handful a filesystem raises most. Since no value survives translation unchanged, a missing row would not be an untranslated errno — it would be a different named one. ENODATA (61), what getxattr returns for a missing attribute, would arrive as EOVERFLOW. EOVERFLOW (75) would arrive as EXDEV.

satisfies Record<Errno, number> is what holds it: a kerium release that adds an errno fails the build instead of silently mistranslating it. Removing one row gives

error TS2741: Property '[Errno.ENOTDIR]' is missing in type '{ ... }' but required in type 'Record<Errno, number>'.

Values are derived from Emscripten's system/lib/libc/musl/arch/emscripten/bits/errno.h, whose entries are the __WASI_ERRNO_* constants plus a block of its own above them. Keys are Errno members rather than raw numbers so the Linux side cannot be mistyped. kerium spells three of Linux's names its own way (ENRNG, ECSI, EEUCLEAN); those rows are marked.

Tests

The repo had no test setup, though eslint.config.js already globbed tests/**/*.ts. This adds one.

  • tests/plugin.test.ts — the boundary, with a fake em_fs whose ErrnoError records the number handed to it, so what the plugin raises is visible without a wasm build. Four of its five cases fail against main (missing path, missing mount root, missing lookup, unusable mode); the fifth shows a present file still resolves.
  • tests/errno.test.ts — the table's invariants, including no errno translates to itself, which is simultaneously the completeness check and the reason a passthrough was never safe.

Tooling follows what core does, so nothing new is installed:

  • npm test → npx zenfs-test -bcn — your runner, on plain node, as core's own script and CI use it. No tsx. package-lock.json is unchanged.
  • Tests import the built package (@zenfs/emscripten/errno.js) rather than ../src, matching core and dom, which is what lets plain node run them.
  • CI had no test step, so it gains one: npx zenfs-test -vn --common.
  • tests/.coverage added to .gitignore (dom has the same entry) — the runner creates it and it otherwise trips format:check.

format:check, lice src -a, lint (0 errors), build and the tests all pass on this commit.

Notes for review

  • The diff touches .github/workflows/ci.yaml and .gitignore. Tests CI does not run are decoration, but if you would rather keep this to source and tests, both lift out cleanly.
  • -n (plain node type stripping) needs a recent Node. CI is on node-version: latest, and core already runs -n, but engines still says >= 18. Say the word and it can use tsx like dom instead.
  • A second PR follows for an unrelated find in the same file: createNode never inserts into FS.nameTable, so nothing this plugin creates is ever in the lookup cache. It is kept separate because it is a performance change with a behavioural trade, not a bug fix.

@alganet
alganet requested a review from james-pre as a code owner August 23, 2026 03:16
@alganet
alganet force-pushed the errno-translation branch from bcd51c1 to 51193a5 Compare August 23, 2026 03:23
@alganet
alganet marked this pull request as draft August 23, 2026 03:26
@alganet
alganet marked this pull request as ready for review August 23, 2026 03:29

@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! I have a couple bits of feedback that need addressing but otherwise it looks fine.

Also please disclose what LLM was used to generate the PR so I can add it as a co-author on the squash commit.

Comment thread .gitignore Outdated
Comment thread src/errno.ts Outdated
Comment thread src/errno.ts Outdated
Comment thread tests/errno.test.ts Outdated
Comment thread tests/errno.test.ts Outdated
Comment thread tests/errno.test.ts Outdated
Comment thread tests/plugin.test.ts Outdated
Comment thread tests/plugin.test.ts Outdated
Comment thread tests/plugin.test.ts Outdated
@alganet

alganet commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the quick feedback! It was co-authored by Opus 5, I will apply the suggestions and re-add the trailers on the commit itself as well.

ZenFS and kerium throw Linux errno numbers; Emscripten's FS checks against its
own WASI-derived table. Nothing translated between them, and the two tables do
not agree on a single one of the 130 errnos kerium defines. On the two that
matter most they disagree by swapping: ENOENT is 2 on Linux and 44 in
Emscripten, while 2 in Emscripten means EACCES.

That breaks file creation. FS.lookupPath forgives a missing final component
under O_CREAT only when it sees its own ENOENT, 44. It saw 2, so the create was
not forgiven, and the 2 it handed back reads as "Permission denied" for a
directory that is perfectly writable.

Translation happens where the error is raised, once. A second pass is not
idempotent — 13 maps to 2 and 2 maps on to 44 — so translating anywhere else
turns one error into another. Raising covers the two sites that passed kerium's
Errno straight to em_fs.ErrnoError, and mount(), which stats the mount root
outside both op tables.

The table covers all of kerium's Errno rather than the few a filesystem is
likeliest to raise. Since no value survives translation unchanged, a missing row
would not be an untranslated errno but a different named one — ENODATA, what
getxattr returns for a missing attribute, would arrive as EOVERFLOW.
`satisfies Record<Errno, number>` is what keeps the table complete: a kerium
release that adds an errno fails the build rather than mistranslating it.

Four of the five plugin tests fail against this commit's parent. The repo had no
test setup though its eslint config already expected one, so this adds a `test`
script and a CI step for them — zenfs-test --common on plain node, the way core
runs it, installing nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alganet
alganet force-pushed the errno-translation branch from 11a4010 to dd835c8 Compare August 26, 2026 04:00
@alganet

alganet commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@james-pre I rebased the branch and updated it so it features one clean commit on top of the existing main, and addressed your feedback items.

@james-pre
james-pre merged commit 57832c8 into zen-fs:main Aug 27, 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