Tidy the NIO output-option mapper and finish translating open errnos - #899
Merged
Conversation
added 2 commits
September 8, 2026 17:23
Three follow-ups to #897, none of them behavior the gate was missing: - `nio-write!` became dead when Files/write moved onto the shared option mapper — its only caller. `nio-write-bv!` (still used by copy) and `nio-output-data->bv` cover what it did. - The Files/write lambda bound a `let*` variable named `file-options`, which shadows the Chez macro of that name for the rest of the scope. It reads fine today because nothing below it wants the macro; a later `(file-options no-fail)` in that body would silently become an application of a bytevector-sized fixnum instead. Renamed to `fopts`. - APPEND + TRUNCATE_EXISTING now reports the JDK's own wording, "APPEND + TRUNCATE_EXISTING not allowed". The new row closes the one gap in the suite: CREATE_NEW's atomic refusal was asserted for plain CREATE_NEW and for a symlink, but its legal combinations — CREATE_NEW+APPEND, CREATE_NEW+TRUNCATE_EXISTING, CREATE_NEW+CREATE — were only exercised against a *missing* path, so the direction the fix is about went unchecked for them. Each combination now has to throw FileAlreadyExistsException and leave the existing bytes alone. Chez's exclusive-create is what enforces it: the `append` and default file-options both omit `no-fail`, so the open carries O_EXCL. unit gate: 1687/1687.
#897 gave nio-open-output-port a guard with arms for EEXIST and ENOENT and an `else` that re-raised. Everything else therefore escaped as the raw Chez condition, and jolt rendered it as a bare java.io.IOException whose message named open-file-output-port: (Files/newOutputStream <a directory>) => java.io.IOException "open-file-output-port: failed for /tmp/d: is a directory" The JVM answers a java.nio.file.FileSystemException reading "/tmp/d: Is a directory". This is what #897's class-hierarchy entry for FileSystemException was for; nothing had reached it yet. The contract is UnixException.translateToIOException: ENOENT, EEXIST and EACCES each get a class and carry only the path as their message, and every remaining errno is a plain FileSystemException whose message is "<path>: <reason>". Chez hands us the strerror text as the second irritant of the &i/o-filename condition, so the rendering comes out byte-identical on Linux and macOS -- both spell EISDIR "Is a directory" and ENOTDIR "Not a directory". The reason is picked by scanning the irritants for a string that is not the filename rather than by position, so an unexpected irritant list degrades to the bare path instead of mislabeling the error. Strictly a refinement for callers: FileSystemException is an IOException in the hierarchy, so a `catch java.io.IOException` that caught the old escape still catches these. The new row covers EISDIR and ENOTDIR through both newOutputStream and write, asserting the class, the IOException relationship, and the exact message. It deliberately does not cover the EACCES arm: provoking it needs a mode-500 directory, which does not fail for a suite run as root, and a gate row whose outcome depends on the runner's uid is worse than an uncovered one-line mapping. unit gate: 1688/1688. cli smoke: 200 passed, 0 failed.
The row I just added pinned the reason text ("Is a directory", "Not a
directory") exactly. That text is libc's, not jolt's: jolt's contract is
that it forwards strerror into the JDK's position in the message, and
"<path>: <reason>" is the part worth pinning.
It also could not be checked where it would break. The unit gate runs on
Linux in the `test` job; the macOS job is the flake workflow's packaging
smoke and never invokes `make unit`. So an exact-wording row would rot
unseen on macOS and surface as a local failure for a contributor there.
Now asserts the path prefix and a non-empty reason instead.
unit gate: 1688/1688.
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.
Follow-ups to #897, in two parts.
1. Finish the errno translation (the actual fix)
#897 gave
nio-open-output-porta guard with arms for EEXIST and ENOENT and anelsethat re-raised. Everything else escaped as the raw Chez condition, whichjolt rendered as a bare
java.io.IOException:The JVM answers
java.nio.file.FileSystemExceptionreading/tmp/d: Is a directory. That is what #897'sclass-hierarchyentry forFileSystemExceptionwas for — nothing had reached it yet.
The contract is
UnixException.translateToIOException: ENOENT, EEXIST and EACCESeach get a class and carry only the path as their message, and every remaining
errno (EISDIR, ELOOP, ENOTDIR, ENOSPC) is a plain
FileSystemExceptionwhosemessage is
<path>: <reason>. Chez hands us the strerror text as the secondirritant of the
&i/o-filenamecondition, so the rendering is byte-identical onLinux and macOS — both spell EISDIR
Is a directoryand ENOTDIRNot a directory. The reason is picked by scanning the irritants for a string that isnot the filename rather than by position, so an unexpected irritant list degrades
to the bare path instead of mislabeling the error.
This is strictly a refinement for callers:
FileSystemExceptionis anIOExceptionin the hierarchy, so acatch java.io.IOExceptionthat caught theold escape still catches these.
The new row covers EISDIR and ENOTDIR through both
newOutputStreamandwrite,asserting the class, the
IOExceptionrelationship, and the message's shape —<path>: <non-empty reason>. It stops short of pinning the reason text: that islibc's strerror, not jolt's to promise, and the unit gate runs only on Linux (the
macOS job is the flake workflow's packaging smoke and never invokes
make unit),so an exact-wording row could only rot unseen on macOS.
It also leaves the EACCES arm uncovered: provoking it needs a mode-500 directory,
which does not fail for a suite run as root, and a gate row whose outcome depends
on the runner's uid is worse than an uncovered one-line mapping.
2. Cleanups and a test gap
nio-write!is dead. Its only caller wasFiles/write, which fix(nio): honor output stream open options #897 movedonto the shared option mapper.
nio-write-bv!(still used bycopy) andnio-output-data->bvcover what it did.Files/writelambda shadowed Chez'sfile-optionsmacro with alet*variable of the same name. It reads correctly today because nothing below the
binding wants the macro — but a later
(file-options no-fail)added inside thatbody would silently become an application of a fixnum. Renamed to
fopts.APPEND + TRUNCATE_EXISTING not allowed.CREATE_NEWand for a symlink, but the other legal combinations —CREATE_NEW+APPEND,CREATE_NEW+TRUNCATE_EXISTING,CREATE_NEW+CREATE— wereexercised only against a missing path, i.e. the create side, so the
direction the fix is about went unchecked for them. Those take a different
file-optionsbranch than plain CREATE_NEW does ((no-truncate append)vs(file-options)), and what makes them refuse is that neither branch carriesno-fail, so the open is O_EXCL. The new row pins that each must throwFileAlreadyExistsExceptionand leave the existing bytes intact.Validation
make unit: 1688/1688 (1686 before this branch, plus two new rows)make smoke: 200 passed, 0 failed