Skip to content

Dir.pwd raises when the working directory is gone (ruby_getcwd), and the corpus total drops 122 because of it - #685

Merged
tannevaled merged 4 commits into
mainfrom
wt35-dir-pwd
Sep 26, 2026
Merged

tannevaled merged 4 commits into
mainfrom
wt35-dir-pwd

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #681.

The defect

Dir.pwd discarded the error from os.Getwd() and returned the path of a
working directory that no longer existed. MRI reaches util.c ruby_getcwd(),
which never hands a failure back to its caller — it calls
rb_syserr_fail(errno, "getcwd") — so a removed working directory is
Errno::ENOENT: No such file or directory - getcwd.

The C read behind each decision

decision C function
the error is raised, not returned, and the message is "<strerror> - getcwd" with no @ func part util.c ruby_getcwd → error.c rb_syserr_fail
Dir.pwd / Dir.getwd reach it dir.c dir_s_getwd → rb_dir_getwd → rb_dir_getwd_ospath
File.expand_path / File.absolute_path reach it for a relative path with no base file.c rb_file_expand_path_internal, append_fspath(result, fname, ruby_getcwd(), &enc, fsenc)
File.realdirpath reaches it file.c rb_check_realpath_emulate (curdir = rb_dir_getwd_ospath())
Dir.chdir(d) reaches it only with a block dir.c chdir_path: if (chdir_alone_block_p()) { args.old_path = rb_str_encode_ospath(rb_dir_getwd()); … }
Dir#chdir never reaches it dir.c dir_chdir → dir_s_fchdir, which saves the previous directory with dir_initialize(…, ".") — an opendir, not a getcwd
a failed chdir(2) keeps its real errno under the name chdir_path dir.c chdir_path, rb_sys_fail_path(path)
File.realpath does not reach it on macOS file.c rb_check_realpath_internal uses native realpath(3) on the unresolved path when HAVE_REALPATH

The premise the measurement overturned

Adding the error check fixes nothing on this platform. Go's syscall.Getwd on
darwin is getattrlist(".", ATTR_CMN_FULLPATH): it keeps reporting the recorded
path of an unlinked directory and returns err == nil. Measured on macOS 25
/ Go 1.26.4 — before and after the removal, os.Getwd() returns the same path
with no error.

So the answer is confirmed the way getcwd(3) confirms it, and the way
os.Getwd itself validates $PWD: the name it produced must still resolve to
the same file as ".". One check covers the three cases witnessed against MRI
4.0.5:

  • directory removed → Errno::ENOENT;
  • removed and recreated, so the name resolves to a different inode →
    Errno::ENOENT again (os.Getwd is perfectly happy here);
  • a parent that became unsearchable → the errno the resolution fails with,
    Errno::EACCES, which is what getcwd(3) reports.

A working directory reached through a symlink still passes: os.Getwd names the
physical path, which is the same file.

Siblings of the shape — two of the four must NOT raise

grep finds four os.Getwd() calls. Measurement against MRI 4.0.5 says only
two of them should fail, and both of the others are now commented so the next
sweep has to argue with MRI rather than with a rule:

  • Dir.chdir(d) without a block — MRI never consults getcwd and returns 0.
    The block form now raises.
  • Dir#chdir — MRI saves the previous directory by opening ".", so it succeeds
    out of a removed directory. Raising there would be a new divergence.

While in chdir_path: a failed chdir(2) asserted Errno::ENOENT @ dir_chdir
for every failure. It now keeps the real errno under MRI's own label, so changing
into a regular file is Errno::ENOTDIR: Not a directory @ chdir_path - /etc/hosts.

Conformance: −122, and that is the fix working

The corpus was measured serially (-jobs 2 for the sweep, -jobs 1 to re-read
every file that moved, three times each) at SPEC_SHA 87b1631.

22759 → 22637 (−122) locally, and CI's own ratchet reads the same move on a
different host: 22772 on main at 2aa1c944, 22650 on this branch at
018b156 — also −122, with the floor still 175 clear.

file before after MRI 4.0.5 through the same shim sign
core/dir/glob_spec.rb 92 16 16 −76, exact convergence
core/dir/element_reference_spec.rb 63 16 16 −47, exact convergence
core/random/bytes_spec.rb 8 9 11 +1 is NOISE — that file returns 8 or 9 on both binaries across repeated serial runs

Nothing else moved across four full sweeps (one before, three after: 22637,
22637, 22636, 22636 — the ±1 is core/random/bytes_spec.rb again).

Two readings that are NOT results, both caught because the harness separates a
timeout from a zero:

  • core/kernel/require_spec.rb came back TIMEOUT / 0 in one sweep, which would
    have read as −69. Run on its own it takes 0.045s and reports 69, three times
    for each binary. It hangs intermittently; nothing here changed it.
  • core/process/exit_spec.rb flips NORESULT ↔ TIMEOUT between runs at 0
    passing either way — a timeout boundary, not a move.

The two Dir files did not merely reach MRI's pass total. Their whole
breakdown is identical — pass=16 fail=44 error=4 and pass=16 fail=43 error=38 — and so is the outcome of every single example, compared name by
name: 48 and 81 detail lines, diff-clean against MRI. Every example lost is
one MRI 4.0.5 also fails or errors. Those examples were being counted with no
judge behind them, because the missing error was itself what let the file
continue past the teardown that removes the working directory.

FLOOR is deliberately untouched.

Left undone, with reasons

  • core/io/read_spec.rb is not this defect. The issue attributed its 104 vs
    82 gap to Dir.pwd; measured, the file does not move at all. 24 of MRI's 28
    errors there are TypeError: no implicit conversion of Hash into String on the
    io_read_internal_encoding shared specs. Minimal witness:
    IO.read(f, {mode: "r"}) is TypeError: no implicit conversion of Hash into Integer in MRI 4.0.5 and returns the file's contents in rbgo;
    IO.read(f, nil, nil, {mode: "r"}) is ArgumentError: wrong number of arguments (given 3, expected 0..2) in MRI and also succeeds here. Owning file
    internal/vm/io_class.go (ioReadFile), outside this PR's cluster.
  • Dir.entries(".") in a removed directory returns [".", ".."] where MRI
    returns []: the synthesised dot entries are unconditional. Same file as this
    change, different defect; it does not affect the corpus (the two Dir files
    converge exactly without it).
  • File.realpath's message in a removed directory is now
    Errno::ENOENT … - getcwd where MRI reports … @ rb_check_realpath_internal - x, because MRI takes the native realpath(3) path on the raw relative
    argument and never expands it. The class matches, and rbgo already reported the
    wrong path there before this change (… - <expanded>/x); making it exact means
    not expanding before resolving, which is a larger change than this one.
  • Dir#chdir on a handle whose directory was removed succeeds in MRI (it
    fchdirs an open fd) and is Errno::ENOENT here. rbgo holds no fd for an open
    Dir, so this needs the handle to carry one.
  • GOOS=js GOARCH=wasm is still red on errno.go — that is GOOS=js GOARCH=wasm has not compiled for 3 days, and the test that catches it is behind an env var no workflow sets #682, untouched
    here.
  • Windows. The first run of this branch was red on that lane, on three tests
    and two causes, both in the assertions rather than in the product. Fixed in
    04e6fd3, and neither fix adapts an expectation to observed behaviour:
    • the strerror sentence is the C library's. Windows maps syscall.ENOENT
      onto ERROR_FILE_NOT_FOUND, so errnoStrerror renders it "The system cannot
      find the file specified." The class was right on every failing line. What
      rb_syserr_fail guarantees on every platform is the Errno::Exxx class and
      the OPERATION name, so that is what is now asserted cross-platform — the
      class, plus a message ending in - getcwd or containing @ chdir_path - .
      The POSIX wording is still pinned exactly, on the POSIX lanes. The narrowed
      dir_test.go row was re-A/B'd and still fails against origin/main
      (which says @ dir_chdir), so it lost the libc sentence and none of its
      discriminating power.

    • TestDirPwdIntact now skips on Windows naming File.realpath on Windows: 8.3 short names are not expanded, and MRI's behaviour there is unwitnessed #636. GetCurrentDirectory
      answers the 8.3 short name (C:/Users/RUNNER~1/… where t.TempDir says
      runneradmin) and does not resolve a symlinked working directory; both are
      about the platform's idea of a canonical path, MRI uses rb_w32_ugetcwd there,
      and there is no Windows MRI here to witness against. These three assertions are
      the first thing in the repo to reproduce File.realpath on Windows: 8.3 short names are not expanded, and MRI's behaviour there is unwitnessed #636 on a runner rather than reason
      about it, and they showed it is not confined to the realpath family — the short
      name comes out of os.Getwd, so Dir.pwd and File.expand_path inherit it
      too. Recorded on File.realpath on Windows: 8.3 short names are not expanded, and MRI's behaviour there is unwitnessed #636 with the runner output; a second Windows fact found beside
      it (a symlinked cwd is reported as the link) is recorded there as well.

      Checked and not a third cause: mapping a Win32 code through the POSIX errno
      table is safe here, because Go aliases exactly ENOENT = ERROR_FILE_NOT_FOUND
      and ENOTDIR = ERROR_PATH_NOT_FOUND and puts every other E* above
      APPLICATION_ERROR (1<<29), which Windows reserves and never returns. So
      raiseChdirErr cannot misclassify a real Win32 error as an unrelated
      Errno::Exxx.

Gates

CGO_ENABLED=0 GOWORK=off go test ./... (full suite), go build ./...,
GOOS=wasip1 GOARCH=wasm go build ./..., gofmt -l internal/ cmd/ empty, and
the below-100%-coverage function list diffed against origin/main's on the same
-coverpkg=./internal/... profile, measured in a detached worktree at
origin/main: identical, 17 functions, same percentages, no new entry and
nothing orphaned
(the rounded total reads 99.9% on both, which is why the lists
and not the totals were compared).

Every new assertion was A/B'd: reverted to origin/main's dir.go/file.go,
confirmed the mutation compiles (go vet clean) and that the witnesses fail,
then restored. The three tests that pass both ways say in their comments that
they are guards, not witnesses.

🤖 Generated with Claude Code

tannevaled and others added 3 commits September 26, 2026 16:52
`Dir.pwd` discarded the error from `os.Getwd()` and returned the path that no
longer existed. MRI reaches `util.c ruby_getcwd()`, which never hands a failure
back to its caller: it calls `rb_syserr_fail(errno, "getcwd")`. So a removed
working directory is `Errno::ENOENT: No such file or directory - getcwd`.

Checking the error was NOT enough. On darwin Go's `syscall.Getwd` is
`getattrlist(".", ATTR_CMN_FULLPATH)`, which still reports the recorded path of
an unlinked directory and returns no error at all (measured: macOS 25, Go
1.26.4). The answer is therefore confirmed the way `getcwd(3)` confirms it, by
resolving the name and requiring it to still be the same file as ".". That one
check reproduces all three cases witnessed against MRI 4.0.5: directory removed
(ENOENT), removed and recreated so the name resolves to another inode (ENOENT),
and an unsearchable parent (EACCES). A cwd reached through a symlink still
passes, because os.Getwd names the physical path.

The same `ruby_getcwd()` call is reached by `file.c
rb_file_expand_path_internal` for a relative path with no base, so
`File.expand_path`, `File.absolute_path` and `File.realdirpath` raise there too.

Two siblings of the shape were measured NOT to raise, and are left alone with a
comment saying why:

  - `Dir.chdir(d)` without a block never consults getcwd; `dir.c chdir_path`
    takes the directory to restore only under `chdir_alone_block_p()`. The
    block form now raises, the plain form still returns 0.
  - `Dir#chdir` is `dir_chdir -> dir_s_fchdir`, which saves the previous
    directory by opening "." rather than by calling getcwd, so it succeeds out
    of a removed directory in MRI. Raising there would be a divergence.

While in `chdir_path`: a failed `chdir(2)` kept the real errno and the C
function's name, instead of asserting `Errno::ENOENT @ dir_chdir`. Changing into
a regular file is now `Errno::ENOTDIR: Not a directory @ chdir_path - /etc/hosts`,
as MRI reports it.

Read for this: `util.c ruby_getcwd`, `dir.c rb_dir_getwd_ospath` /
`rb_dir_getwd` / `dir_s_getwd` / `chdir_path` / `dir_s_fchdir`,
`file.c rb_file_expand_path_internal`, `error.c rb_syserr_fail`.

Refs #681

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The witnesses (each one fails against origin/main, and the mutation compiles):
Dir.pwd / Dir.getwd, File.expand_path, File.absolute_path,
File.expand_path(x, "."), File.realdirpath and Dir.chdir(d){} out of a removed
working directory; Dir.pwd out of one that was removed AND RECREATED, which a
bare error check would have missed entirely because os.Getwd names it happily;
and chdir into a regular file, now Errno::ENOTDIR @ chdir_path.

Two of the tests are GUARDS and say so in their comments: the plain Dir.chdir(d)
and Dir#chdir out of a removed directory, which MRI 4.0.5 was measured NOT to
fail, and Dir.pwd in an intact directory including one reached by symlink.

os.Getwd's error branch and stat(".")'s cannot be produced on darwin, so they go
through seams: osGetwd joins the existing osStat from filestat.go. The two
raiseChdirErr fallbacks (an errno with no registered class, an error carrying no
errno) are exercised by calling it directly and recovering the raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows refuses to unlink a directory that is any process's current directory, so
the state these tests need cannot be constructed there; and MRI takes a different
implementation on that platform (rb_w32_ugetcwd, dir.c line 140), so there is no
witness here for what it would report. The chdir errno mapping is skipped for the
same reason. The 100% coverage gate runs on the POSIX lanes, so no coverage is
lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h one

The windows lane was red on three tests and two causes, both in the assertions
rather than in the product. Neither fix adapts an expectation to observed
behaviour: one drops a claim about the C LIBRARY that was never a claim about
rbgo, the other skips a case that has no MRI witness at all.

1. The strerror sentence is the platform's. Windows maps syscall.ENOENT onto
   ERROR_FILE_NOT_FOUND, so errnoStrerror renders it "The system cannot find the
   file specified." What rb_syserr_fail guarantees everywhere is the Errno class
   and the OPERATION name, so that is what is asserted cross-platform — the class,
   and a message ending in "- getcwd" or containing "@ chdir_path - ". The POSIX
   wording is still pinned exactly, on the POSIX lanes. The narrowed dir_test.go
   row was re-A/B'd and still FAILS against origin/main (which says
   "@ dir_chdir"), so it lost the libc sentence and none of its discriminating
   power.

2. TestDirPwdIntact cannot run on Windows. GetCurrentDirectory answers the 8.3
   short name (C:/Users/RUNNER~1/... where t.TempDir says runneradmin) and does
   not resolve a symlinked working directory. Both are about the platform's idea
   of a canonical path, MRI uses rb_w32_ugetcwd there and there is no Windows MRI
   on this host to witness against — the short-name half is open issue #636, which
   these three assertions are the first thing in the repo to REPRODUCE on a runner
   rather than reason about. Canonicalising the expected path to match would bake
   both behaviours into a test and make #636 harder to find, so it skips naming
   the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit b4a5219 into main Sep 26, 2026
7 checks passed
@tannevaled
tannevaled deleted the wt35-dir-pwd branch September 26, 2026 17:55
tannevaled added a commit that referenced this pull request Sep 26, 2026
The provisional 22650 was the reading on #685 before #692 merged. The combined
tree reads 23113 — the same total #692 measured on its own, so #685's -122 did
NOT subtract from it.

That is informative rather than a coincidence: the 122 examples #685 removed were
the Dir specs continuing past a deleted working directory, and #692 restores the
`after :all` hook whose absence deleted it. With the hook back, the directory
survives and Dir.pwd never has to raise. The two changes address one root cause
from opposite sides and overlap instead of composing, which is why predicting the
sum would have been wrong in either direction.

Taken from the lane that applies the floor, on the merge of this branch with
main, not from a development host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 26, 2026
* Raise the ruby/spec floor to 22650 after waves 33-36

Provisional, and deliberately conservative: 22650 is the number CI's own
ratchet read on #685 (Dir.pwd) before #692 (the mspec shim repair) merged, so
it is known-good rather than predicted. The two land together for the first
time on this branch, and they interact: #685 makes Dir.pwd raise when the
working directory has been removed, while #692 restores the `after :all` hook
whose absence was what removed it. Neither branch's CI ran with the other.

So this PR is also the measurement. Its own ratchet lane reports the real
total for the combined tree, and the floor is then set to that figure in a
follow-up commit here — taken from the lane that applies it, never from a
development host, which reads a different number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Set the floor to 23113, the figure this PR's own ratchet lane measured

The provisional 22650 was the reading on #685 before #692 merged. The combined
tree reads 23113 — the same total #692 measured on its own, so #685's -122 did
NOT subtract from it.

That is informative rather than a coincidence: the 122 examples #685 removed were
the Dir specs continuing past a deleted working directory, and #692 restores the
`after :all` hook whose absence deleted it. With the hook back, the directory
survives and Dir.pwd never has to raise. The two changes address one root cause
from opposite sides and overlap instead of composing, which is why predicting the
sum would have been wrong in either direction.

Taken from the lane that applies the floor, on the merge of this branch with
main, not from a development host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Dir.pwd discards os.Getwd()'s error, inflating our own score by ~145 examples and deleting their witness

1 participant