Skip to content

wave 28: Kernel#sleep + fiber scheduler, File.open's mode/flags/perm contract, IO::Buffer.map (+90 in core/file, core/io, core/kernel) - #640

Merged
tannevaled merged 11 commits into
mainfrom
wave28/io
Sep 24, 2026
Merged

tannevaled merged 11 commits into
mainfrom
wave28/io

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

wave 28 — Kernel#sleep with a fiber scheduler, File.open's mode contract, IO::Buffer.map

core/file + core/io + core/kernel: 3925 → 4015 passing examples (+90), measured per file. No spec file lost a single example. core/file/mkfifo_spec.rb is still at 0, untouched.

spec file before after
core/kernel/sleep_spec.rb 0 15
core/io/buffer/map_spec.rb 0 14
core/file/open_spec.rb 55 78
core/io/buffer/initialize_spec.rb 5 13
core/file/new_spec.rb 18 25
core/file/truncate_spec.rb 18 23
core/file/flock_spec.rb 0 3 (2 of 5 need ruby_exe, skipped)
core/file/size_spec.rb 20 22
core/io/buffer/{shared,internal,null}_spec.rb 2 / 1 / 10 4 / 3 / 12
core/io/buffer/{empty,free,mapped,private,resize,transfer,valid}_spec.rb — +1 each

Per directory: core/file 628 → 668, core/io 1319 → 1354, core/kernel 1978 → 1993.

What was read, per method

Every change was made against the MRI C source and then byte-compared with ruby 4.0.5 on this host — stdout and error messages, raw bytes.

Kernel#sleep — process.c rb_f_sleep, scheduler.c rb_fiber_scheduler_current / _current_for_threadptr / rb_fiber_scheduler_kernel_sleepv, cont.c fiber_switch + rb_fiber_start (the th->blocking counter), time.c time_timespec, thread_sync.c rb_mutex_sleep.

core/kernel/sleep_spec.rb scored 0 not because fourteen examples failed but because ONE hung the process, and a timeout costs the whole file: Fiber.new(blocking: false) { sleep }.resume never returned, since nothing consulted Fiber.scheduler. rb_f_sleep asks rb_fiber_scheduler_current before looking at its arguments and forwards them verbatim — so sleep with no argument calls #kernel_sleep with no argument, not with nil.

The same read fixed, each byte-compared: sleep(Rational(1,999)) and sleep(obj_with_divmod) were TypeError; sleep(Float::NAN) returned 0 instead of raising RangeError; sleep(Float::INFINITY) and sleep(1e20) HUNG instead of raising; sleep(2**70) was a TypeError instead of a RangeError; sleep(1, 2) slept instead of raising ArgumentError. The result is now time(0) - beg — second boundaries crossed, not a rounded duration.

File.open / File.new — io.c rb_io_modestr_fmode, rb_io_oflags_fmode, rb_io_fmode_oflags, rb_io_extract_modeenc, validate_enc_binmode, rb_file_initialize, rb_io_s_open, rb_io_s_new; file.c rb_file_open_generic.

File.open reduced every mode to one of six fopen-style strings, so the O_CREAT/O_TRUNC/O_EXCL half was lost. It is now driven by the open(2) flag set: "wx" refuses an existing file, File::WRONLY on a missing path is ENOENT, the permission argument reaches the creation, :flags merges and re-derives the fmode, :perm/:mode report "specified twice", "rx"/"ax" are "invalid access mode" (rb_io_modestr_fmode checks modestr[0] itself), File::RDONLY|File::APPEND is READ-ONLY because O_APPEND is not an access bit, and :newline with binmode is "newline decorator with binary mode". A first argument that converts with #to_int — and fewer than three positional arguments — is a DESCRIPTOR, as rb_file_initialize has it. The block form closes through the Ruby-level #close.

File#flock — file.c rb_file_flock. MRI does not block inside flock(2): without LOCK_NB it waits 0.1 s with rb_thread_wait_for and calls again, which is what keeps the wait interruptible, and is exactly what this VM's cooperative threads reproduce. A lock cannot be emulated (it belongs to an open file description), so a real descriptor is opened on demand and released on LOCK_UN or close, behind a platform seam that reports ENOTSUP on windows and wasm.

File#size / IO#truncate — file.c rb_file_size, io.c rb_io_truncate. Both are operations on the descriptor: the truncate reaches the file at once rather than at the next flush, and the size is read afresh (and still answers after the path is unlinked).

IO::Buffer.map / IO::Buffer.new — io_buffer.c io_buffer_map, rb_io_buffer_map, io_buffer_map_file, io_buffer_extract_size/_offset/_flags, rb_io_buffer_initialize, io_buffer_initialize, io_flags_for_size; include/ruby/io/buffer.h. A mapping does not have to be faked: this VM's file streams already ARE their bytes in memory, so a shared mapping is that byte slice and a private one is the copy MAP_PRIVATE would take. IO::Buffer.new's flags are now recorded rather than derived, which is what rippled into eleven other buffer spec files.

Two assertions corrected against the reference, not worked around

Measuring said the assertions were the thing that was wrong; both were re-measured on MRI 4.0.5 before changing anything.

  • io_modeenc_internal_test.go asserted that when BOTH the block and #close raise, the block's exception escapes. MRI answers with the one from #close — for File.open, for IO.open, and for a bare begin/raise/ensure/raise alike, because an ensure's exception supersedes the one it was unwinding. The VM now does the same.
  • io_buffer_internal_test.go asserted IO::Buffer.new(4, READONLY) is a read-only buffer. READONLY alone names no way to obtain the memory, so MRI raises AllocationError; INTERNAL|READONLY is the buffer that was meant. Both are now asserted.

Judged unreachable from these files, with the reason

  • shared open_directory, 2 examples (open_spec, new_spec) — the harness fabricates the failure. Real mspec's tmp("") returns the temp DIRECTORY; scripts/conformance/rubyspec/spec_helper.rb rewrites an empty basename to "file" and uniquifies it, so the spec opens a path that was never created. The VM opens a directory correctly (File.open(dir).class == File, and File.open(dir, "w") is EISDIR, both byte-compared).
  • accepts options as a keyword argument, 3 examples (open_spec, new_spec, write_spec) — they require File.open(f, 'w', 0755, {flags: ...}) with a BRACED hash to be a fourth POSITIONAL argument. This VM has no keyword/positional distinction at the native-method boundary (splitIOOpts takes any trailing Hash as options); the fix is in the compiler/VM, not in these files.
  • IO.write :open_args and friends, 7 examples (write_spec) — @filename is nil in them. it_behaves_like in the shim creates a CHILD context, so the shared block's before(:each) does not apply to the enclosing describe's own examples; real mspec splices the shared block into the current context. spec_helper.rb is out of scope here. Each of those calls behaves correctly when run directly.
  • shared io_copy_stream_to_io, 8 examples (copy_stream_spec) — Tempfile.new(name, mode: File::RDONLY). MRI's Tempfile ORs File::RDWR|File::CREAT|File::EXCL into the mode it was given; the pure-Ruby Tempfile in internal/vm/prelude.rb passes the mode through, so with File::RDONLY the file is never created and the open is ENOENT. One line, but prelude.rb is frozen into a generated prelude_frozen_gen.go and is not one of this branch's files. This is the single cheapest 8 examples left in core/io.
  • File.open with a block | propagates {,non-}StandardErrors produced by close, 2 examples — the fixture stores the exception in an instance variable on the File. IOObj has no instance-variable storage: ivarTable (object_model.go) has no *IOObj case, so f.instance_variable_set(:@x, 1) silently does nothing. Five additive lines in object_model.go, which is not one of this branch's files. The rest of the fixture — that #close is invoked and that its exception propagates — now passes.
  • core/file/mkfifo_spec.rb, and File.open on a FIFO — File.mkfifo was not implemented and deliberately still is not. It was left alone, but the open path was hardened first: openFileSpec no longer truncates or probes for writability anything that is not a regular file, because probing a fifo means opening it for writing, which BLOCKS until a reader appears.
  • File#flock blocking/subprocess examples, 2 of 5 — they need ruby_exe, which the shim skips.
  • IO::Buffer.map sharing across processes, 2 examples — guarded by Process.respond_to?(:fork), which is false here.
  • IO::Buffer#initialize | creates a new zero-filled buffer with default size, 1 example — needs IO::Buffer#each, which does not exist yet.

Gates

  • CGO_ENABLED=0 go build ./..., GOOS=wasip1 GOARCH=wasm, and GOOS=windows GOARCH=amd64 all build.
  • CGO_ENABLED=0 GOWORK=off go test ./... — full suite green, no crash.
  • gofmt -l internal/vm/ empty.
  • Coverage checked per function, not on the rounded total: every function this wave added or touched is at 100%. Moving File.open off the mode string had left flagsToMode at 0% and vmodeString at 27% — IO.sysopen/IO#reopen/IO.popen still reach them and now exercise them. Three branches this wave wrote turned out unreachable and were removed rather than left untested (raiseOpenErrno's EISDIR/EEXIST arms, timeIntervalFormat's -Inf, ioFlushClose).
  • runtime.GOOS / GOARCH guards on the flock and permission assertions; the windows and wasm seams stay thin.

🤖 Generated with Claude Code

tannevaled and others added 7 commits September 23, 2026 22:45
…e_interval

core/kernel/sleep_spec.rb scored 0 — not because fourteen of its fifteen
examples failed, but because one of them hung the process and a timeout
costs a whole file. `Fiber.new(blocking: false) { sleep }.resume` never
returned: rbgo's sleep parked the thread until a Thread#wakeup that could
not arrive, since nothing consulted Fiber.scheduler.

Read for this change:
  - process.c rb_f_sleep: it asks rb_fiber_scheduler_current BEFORE looking
    at its arguments, and forwards them verbatim with
    rb_fiber_scheduler_kernel_sleepv — so `sleep` with no argument calls
    #kernel_sleep with no argument, not with nil, and neither the arity
    check nor the duration coercion runs on that path. Its result is
    time(0) - beg: whole second boundaries crossed, not a rounded duration.
  - scheduler.c rb_fiber_scheduler_current / _for_threadptr: the scheduler
    answers only while th->blocking is zero; rb_fiber_scheduler_get (which
    is what Fiber.scheduler exposes) answers unconditionally.
  - cont.c fiber_switch / rb_fiber_start: th->blocking is raised on
    entering a blocking fiber and lowered on leaving one, so "th->blocking
    == 0" is exactly "the fiber running now is non-blocking".
  - time.c time_timespec(num, interval=TRUE), reached through
    rb_time_interval: Integer/Float seconds with arg_range_check, the
    `f != t.tv_sec` RangeError for a Float that cannot be held in a time_t,
    and the #divmod(1) fallback that turns a Rational — or any object
    defining #divmod — into [whole seconds, fraction].
  - thread_sync.c rb_mutex_sleep: validates its timeout with the same
    rb_time_interval, so the two now share one coercion.

core/kernel/sleep_spec.rb 0 -> 15. No other file in core/file, core/io or
core/kernel moved.

Also fixed on the way, each byte-compared against MRI 4.0.5:
  sleep(Rational(1,999)) and sleep(obj_with_divmod) were TypeError;
  sleep(Float::NAN) returned 0 instead of raising RangeError;
  sleep(Float::INFINITY) and sleep(1e20) HUNG instead of raising RangeError;
  sleep(2**70) was a TypeError instead of a RangeError;
  sleep(1, 2) slept instead of raising ArgumentError.

Known divergence left alone: Mutex#sleep returns 0 where MRI returns nil
when the sleep was not interrupted (rb_mutex_sleep's `if (!woken)`), and
IO.select's timeout still reaches only the numeric half of the coercion
because it is coerced from spawn.go, which this change does not own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
File.open reduced every mode to one of six fopen-style strings and opened
from that, so the O_CREAT / O_TRUNC / O_EXCL half of a mode was lost:
"wx" did not refuse an existing file, File::WRONLY on a missing path
created it instead of raising ENOENT, the permission argument never
reached the creation, and the :flags option was ignored outright.

Read for this change:
  - io.c rb_io_modestr_fmode: 'r' is FMODE_READABLE, 'w' is
    WRITABLE|TRUNC|CREATE, 'a' is WRITABLE|APPEND|CREATE, and 'x' is
    FMODE_EXCL — but it checks modestr[0] itself, which is why "rx" and
    "ax" are "invalid access mode", not an exclusive open.
  - io.c rb_io_oflags_fmode and rb_io_fmode_oflags: the two directions
    between an fmode and an open(2) flag set. O_APPEND is NOT part of the
    access bits, so File::RDONLY|File::APPEND is a read-only stream and
    writing to it is an IOError.
  - io.c rb_io_extract_modeenc: rb_scan_args "12:", :mode ("mode specified
    twice"), :flags (OR-ed into the flags, with the fmode then re-derived
    and the encoding left standing), :perm ("perm specified twice").
  - io.c validate_enc_binmode and rb_econv_prepare_options: "newline
    decorator with binary mode", and "unexpected value for newline option".
  - file.c rb_file_open_generic: what open(2) then does with those flags.

ioModeSpec now carries the create/trunc/excl bits and an oflags() that is
rb_io_fmode_oflags, so the integer form, the string form and the :flags
option all meet in one place instead of three.

core/file/open_spec.rb 55 -> 72, core/file/new_spec.rb 18 -> 21. Nothing
else in core/file, core/io or core/kernel moved.

Two smaller things the same read turned up, each byte-compared with MRI:
  - a stream's write permission is granted at OPEN, so File.new(p, "w",
    0444) can be written through the handle it returns while a fresh
    File.open(p, "w") on it is EACCES. A buffer-backed stream defers the
    write to close, so ioFlush now replays that grant rather than losing it;
  - O_APPEND moves only WRITES to the end: File.open(p, "a+").read returned
    "" where MRI returns the file, and #pos on a fresh "a" stream read as
    the file's size instead of 0.

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

core/file/flock_spec.rb scored 0 because File#flock did not exist, and
three more specs asked the buffer a question only the file could answer.

Read for this change:
  - file.c rb_file_flock: it flushes a writable stream, then calls flock(2)
    in a LOOP — with LOCK_NB a refusal (EWOULDBLOCK/EAGAIN/EACCES) is
    `false`, and WITHOUT it MRI waits 0.1 s with rb_thread_wait_for and
    tries again. MRI polls here rather than blocking in the syscall, which
    is what keeps the wait interruptible, and is exactly what this VM's
    cooperative threads can reproduce.
  - thread.c rb_thread_wait_for / sleep_hrtime: the wait that poll uses,
    now a named threadWaitFor shared with Kernel#sleep, so the waiting
    thread reports "sleep" and a Thread#kill reaches it.
  - io.c rb_io_truncate: ftruncate(2) on the descriptor, so the file is
    that size AT ONCE — File.size sees it before any flush — and
    GetOpenFile runs before rb_io_check_writable, so a closed stream is
    "closed stream" whatever mode it had.
  - file.c rb_file_size: an fstat on the OPEN descriptor, so it reports a
    change another stream made and keeps answering after the path is gone.

A lock cannot be emulated: flock(2) is a property of an open file
description. These streams are buffer-backed and have no descriptor (see
ioFd), so File#flock opens a real one on the path, holds it for as long as
the lock is, and drops it on LOCK_UN or on close. lockFdFn / flockFn /
closeFdFn / flockAgainErrs are the platform seam; the windows and wasm
variants report ENOTSUP, and wasip1 has no EWOULDBLOCK at all, which is
why the retry list is per platform rather than written into the shared code.

  core/file/flock_spec.rb     0 -> 3  (2 of the 5 need ruby_exe, skipped)
  core/file/truncate_spec.rb 18 -> 23
  core/file/size_spec.rb     20 -> 22

Nothing else in core/file, core/io or core/kernel moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
core/io/buffer/map_spec.rb scored 0 — IO::Buffer.map did not exist — and
the state predicates around it answered a fixed false, so eleven further
buffer spec files each lost an example or two to the same gap.

Read for this change:
  - io_buffer.c io_buffer_map and rb_io_buffer_map: rb_check_arity(1, 4),
    and with no size the whole file, which is why a zero-length file has
    nothing to map ("Invalid negative or zero file size!").
  - io_buffer.c io_buffer_extract_size / _offset / _flags: three numbers
    with three different refusals — "not an Integer" for a size, rb_num2long's
    "no implicit conversion from X" for an offset, and "Flags can't be
    negative!" before the mask that deliberately ignores unknown bits.
  - io_buffer.c io_buffer_map_file: MAP_SHARED with PROT_READ|PROT_WRITE
    unless PRIVATE or READONLY was asked for. That is what makes a shared
    writable mapping of a stream not opened for writing the EACCES the
    spec calls "a SystemCallError unless read-only". A non-private mapping
    is marked EXTERNAL and SHARED; a private one is neither.
  - io_buffer.c rb_io_buffer_initialize + io_buffer_initialize +
    io_flags_for_size: with no flags the SIZE chooses — below a page the
    buffer is internal, from a page upwards it is MAPPED. With flags,
    INTERNAL or MAPPED must be among them (they are the only two ways to
    get the memory: otherwise AllocationError), and every other bit is
    simply RECORDED, so INTERNAL|SHARED is an internal buffer that reports
    #shared?. A size of zero allocates nothing and the flags are not
    consulted at all, not even READONLY.
  - include/ruby/io/buffer.h enum rb_io_buffer_flags for the values.

A mapping cannot be faked, but it does not have to be: this VM's file
streams already ARE their bytes in memory (see IOObj), so a shared mapping
is that byte slice — writes through the buffer are the stream's writes —
while a private mapping takes the copy MAP_PRIVATE would.

  core/io/buffer/map_spec.rb         0 -> 14  (2 need fork, skipped)
  core/io/buffer/initialize_spec.rb  5 -> 13
  core/io/buffer/shared_spec.rb      2 -> 4
  core/io/buffer/internal_spec.rb    1 -> 3
  core/io/buffer/null_spec.rb       10 -> 12
  and +1 each in empty_spec, free_spec, mapped_spec, private_spec,
  resize_spec, transfer_spec and valid_spec.

Nothing in core/file, core/io or core/kernel moved down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read for this change:
  - io.c rb_file_initialize: with FEWER THAN THREE positional arguments, a
    first argument that converts through rb_check_to_int is a DESCRIPTOR
    and the call is io_initialize on it, not an open of a path. The
    permission argument is what rules that out, which is why
    File.new(fd, mode, perm) is the ordinary TypeError about the path.
  - io.c rb_io_s_open: the block form closes through the RUBY-level #close
    in an ensure, so a subclass override runs.
  - io.c rb_io_s_new: it warns when handed a block it will never call.
  - io.c io_initialize for the fd wrapper this VM already had for IO.new,
    now shared with File.open/File.new rather than duplicated.

core/file/open_spec.rb 72 -> 78, core/file/new_spec.rb 21 -> 25.

Two existing assertions were corrected against MRI 4.0.5 rather than
worked around, because measuring said they were the thing that was wrong:

  - io_modeenc_internal_test.go asserted that when BOTH the block and
    #close raise, the BLOCK's exception escapes. MRI answers with the one
    from #close — for File.open, for IO.open, and for a bare
    begin/raise/ensure/raise alike, because an ensure's exception
    supersedes the one it was unwinding. The VM now does the same.
  - io_buffer_internal_test.go asserted IO::Buffer.new(4, READONLY) is a
    read-only buffer. READONLY alone names no way to obtain the memory, so
    MRI raises AllocationError; INTERNAL|READONLY is the buffer meant, and
    both are now asserted.

Also hardened on the way, before anything could reach it: openFileSpec no
longer truncates or probes for writability anything that is not a regular
file. Probing a fifo means opening it for writing, which BLOCKS until a
reader appears — an open that never returns, which is how an earlier
wave's File.mkfifo cost this project 55 examples.

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

The coverage gate reads a rounded total, so a function at 0% can hide
inside a green number. Checked per FUNCTION instead: moving File.open off
the fopen-style mode string left flagsToMode at 0% and vmodeString at 27%,
because File.open had been their only busy caller. IO.sysopen, IO#reopen
and IO.popen still reach them, so they are exercised there now.

Three branches this wave wrote turned out to be unreachable, and were
removed rather than left as untested code:
  - raiseOpenErrno's EISDIR and EEXIST arms: openFileSpec raises both
    itself, from the flags and the stat, before any call can report them;
  - timeIntervalFormat's "-Inf": arg_range_check refuses a negative
    interval before the range check can see it;
  - ioFlushClose, whose only caller is now the shared ioYieldAndClose.

timeIntervalFromDivmod now reads its quotient with repeatLong rather than
a switch of its own: NUM2TIMET IS rb_num2long, so "no implicit conversion
of String into Integer" and "no implicit conversion from nil to integer"
come out right by construction instead of by a second spelling of the rule.
Both were byte-compared with MRI 4.0.5.

Every function this wave added or touched is now at 100%; the two left
under it in these files (defStringIORead, inspectStr) are untouched here.
Full suite green, and the wasip1 and windows lanes build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wasip1 lane failed to compile the tests: `undefined: syscall.EWOULDBLOCK`
at three call sites. WASI's errno enum has no separate EWOULDBLOCK — errno.go
says so in the comment beside `errnoAliases`, and filestat_wasm.go leaves
`flockAgainErrs` empty for the same reason.

The stub only needs an error value the retry list will match, not the real
errno of any platform. `syscall.EAGAIN` is defined on wasip1, windows and unix
alike, and on unix `flockAgainErrs` already carries it beside EWOULDBLOCK, so
the POSIX lanes exercise the same path as before.

Checked with `go vet ./internal/vm/` (which type-checks the test files) for
wasip1/wasm, windows/amd64 and linux/amd64.

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

Three failures, three different causes.

The ubuntu lane failed the 100% gate at 99.9%. Diffing its below-100 list
against main's shows exactly two functions newly under: IO#truncate's
"not opened for writing" guard (io.go) and IO.select's NaN-timeout RangeError
(spawn.go) — the second in a file this branch never touched, which is the
orphaning signature: the change stopped exercising a path, it did not break it.
Both answer exactly as MRI 4.0.5 does, verified byte for byte; they had simply
lost their only caller. Whitebox tests added for each.

The windows lane failed two perm assertions. Windows has no POSIX mode bits —
its security model is ACL-based, and Go maps perm to nothing finer than a
read-only flag — so a created file reads back 0666 whatever was asked for. MRI
cannot honour 0744 there either, but there is no Windows MRI on this host to
witness what it DOES report, so the two cases move into their own POSIX-guarded
test naming issue #644, rather than being pinned to whatever Windows returns.

The windows lane also failed three tests on TempDir cleanup, not on any
assertion: they deliberately leave a File open (File.open(fh.fileno) needs fh
alive) and Windows will not delete a file with an open handle. The underlying
gap is the VM's, not the tests' — rbgo closes no IO at interpreter shutdown
where MRI closes every one — and it is now issue #643. fdOpenScratch removes
its directory best-effort and says so, so these cases report on what they test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled and others added 3 commits September 24, 2026 09:37
My previous attempt at these two coverage gaps missed both, and the way it
missed them is worth recording.

IO#truncate: I read the uncovered block as the "not opened for writing" guard
and wrote a File test for it. The uncovered block was the line ABOVE — the
"closed stream" guard — and the method is registered inside defStringIORead,
where it serves BOTH a StringIO and a path-backed File. Three cases are needed,
not one: a closed File (closed stream), a read-only StringIO (not opened for
writing), and a writable File (the ftruncate half, where the file on disk is
that size at once). All three verified against MRI 4.0.5.

IO.select's NaN guard is DEAD CODE, not an uncovered branch. It reads
`if f := mutexSleepDur(args[3]); math.IsNaN(f)`, but mutexSleepDur is
rb_time_interval and RAISES RangeError("NaN out of Time range") itself for a NaN
or an infinity rather than returning one. The check after it could never run —
which is why my test saw the right error and the branch still read zero: the
message came from timeInterval, not from here. Deleted rather than covered.

One divergence found and recorded, not papered over: MRI's rb_io_truncate is
ftruncate(2) on the DESCRIPTOR, so it succeeds after the path is unlinked.
rbgo's IOObj is buffer-backed with a synthetic fd, truncates by path, and
answers ENOENT. Same root cause as File#stat on an unlinked file; added to
issue #635, and the test says so where it asserts rbgo's answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more tests failed the windows lane on TempDir cleanup rather than on any
assertion — the same cause the fd-open cases already carried a note about: they
hold a File open (the permission cases keep a handle across a chmod), rbgo
closes no IO at interpreter shutdown where MRI closes every one (#643), and
Windows will not delete a file with an open handle.

Fixing the second helper by copying the first one's explanation would have left
the same paragraph in two places to drift apart, which is the defect this wave
already found in File::Stat#inspect — the member list written twice and
disagreeing at the one member that can be nil. So the rule moves into
ioScratchDir and both helpers call it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 2a93ce0 into main Sep 24, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave28/io branch September 24, 2026 11:07
tannevaled added a commit that referenced this pull request Sep 24, 2026
Four units, and one of them was a dependency away:

  #639  regexp search anchoring and encoding, Refinement#import_methods,
        pack/unpack
  #645  THE LINE MAP. __LINE__ evaluated to 0 everywhere because the ISeq
        carried no line table — 101 examples across five directories were
        blocked on it, and three earlier agents each reported it as the wall
        they could not pass. The wall was one repository down: the parser's
        AST carried no position information at all, 55 node types with no
        line field, and the lexer discarded every token's line at the AST
        boundary. go-ruby-parser v0.3.0 adds ast.Program.Lines; this PR
        builds MRI's compressed insns_info/positions table on it.
        language/line_spec 0/6 -> 6/6, backtraces carry real lines, frame
        labels name blocks the way MRI does.
  #640  Kernel#sleep with the fiber scheduler (sleep_spec 0 -> 15, one
        example had been hanging the whole file), File.open's mode/flags/perm
        contract driven by the open(2) flag set rather than a six-way mode
        string, IO::Buffer.map                     (+90 across core/file,
                                                    core/io, core/kernel)
  #537, #527 already in the previous floor's tree

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22153, 22152, 22152. The
floor is set twelve below the lowest, the margin rule the last three bumps
used: enough for the run-to-run jitter, not enough to hide the loss of a spec
file. #615 remains open and still costs a file when it fires.

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.

1 participant