Translate errnos at the Emscripten boundary - #6
Merged
Merged
Conversation
alganet
force-pushed
the
errno-translation
branch
from
August 23, 2026 03:23
bcd51c1 to
51193a5
Compare
alganet
marked this pull request as draft
August 23, 2026 03:26
alganet
marked this pull request as ready for review
August 23, 2026 03:29
james-pre
requested changes
Aug 23, 2026
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
force-pushed
the
errno-translation
branch
from
August 26, 2026 04:00
11a4010 to
dd835c8
Compare
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. |
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.
The problem
kerium's
Errnois Linux's numbering, and that is what every error reaching this package carries. Emscripten'sFSchecks 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:
ENOENTEACCESSo an untranslated
ENOENTarrives at Emscripten asEACCES, and that is not just a wrong message — file creation stops working.FS.lookupPathforgives a missing final component underO_CREATonly when it sees its ownENOENT: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/coreis the only import besides the package itself, so this runs in afresh clone after
npm install && npm run build.2 is Emscripten's
EACCES; 44 is itsENOENT. SinceFS.lookupPathforgives a missing finalcomponent under
O_CREATonly for 44, that 2 is what stops files from being created — and it isreported 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 theerrno behind it.
Against
main:Against this branch:
The directory is writable in both runs; in the first the file is simply never created.
The fix
src/errno.tsmaps Linux errnos to Emscripten's, andplugin.tscalls 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
Errnostraight intoem_fs.ErrnoError, andmount(), 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), whatgetxattrreturns for a missing attribute, would arrive asEOVERFLOW.EOVERFLOW(75) would arrive asEXDEV.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 givesValues 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 areErrnomembers 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.jsalready globbedtests/**/*.ts. This adds one.tests/plugin.test.ts— the boundary, with a fakeem_fswhoseErrnoErrorrecords the number handed to it, so what the plugin raises is visible without a wasm build. Four of its five cases fail againstmain(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. Notsx.package-lock.jsonis unchanged.@zenfs/emscripten/errno.js) rather than../src, matching core and dom, which is what lets plain node run them.npx zenfs-test -vn --common.tests/.coverageadded to.gitignore(dom has the same entry) — the runner creates it and it otherwise tripsformat:check.format:check,lice src -a,lint(0 errors),buildand the tests all pass on this commit.Notes for review
.github/workflows/ci.yamland.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 onnode-version: latest, and core already runs-n, butenginesstill says>= 18. Say the word and it can usetsxlike dom instead.createNodenever inserts intoFS.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.