Skip to content

wave 32: IO/Dir encodings resolve at open, and #syswrite reaches the descriptor (+56) - #668

Merged
tannevaled merged 7 commits into
mainfrom
wave32/ioenc
Sep 26, 2026
Merged

tannevaled merged 7 commits into
mainfrom
wave32/ioenc

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Wave 32, core/io / core/file / core/dir encodings and the descriptor write path.

Every item was measured per file against the MRI 4.0.5 ceiling — the same corpus run through the same spec_helper.rb shim — before being planned, and the ceiling overturned two of the briefing's targets (see Premises that did not survive measuring). The C source read for each item is named in its commit message.

Result

Whole language/ + core/ suite, 2-way, same corpus and shim: 22222 → 22278 (+56).

file before after MRI ceiling
core/io/internal_encoding_spec.rb 67 79 79 at ceiling
core/io/external_encoding_spec.rb 48 59 59 at ceiling
core/io/syswrite_spec.rb 12 18 18 at ceiling
core/io/write_nonblock_spec.rb 14 18 18 at ceiling
core/dir/children_spec.rb 9 13 13 at ceiling
core/dir/entries_spec.rb 5 7 7 at ceiling
core/dir/foreach_spec.rb 7 8 8 at ceiling
core/dir/to_path_spec.rb 2 3 3 at ceiling
core/io/buffer/empty_spec.rb 10 13 13 at ceiling
core/io/buffer/string_spec.rb 4 7 7 at ceiling
core/io/buffer/free_spec.rb 8 10 10 at ceiling
core/io/buffer/resize_spec.rb 13 15 15 at ceiling
core/io/buffer/null_spec.rb 12 13 13 at ceiling
core/file/expand_path_spec.rb 25 27 29
core/io/puts_spec.rb 14 16 17
core/io/copy_stream_spec.rb 53 54 62 rest is Tempfile, see below

Thirteen of the sixteen files are now at the ceiling the harness can express.

The five items

1. IO encodings resolve ONCE, at open. IO#internal_encoding returned nil where MRI returns the encoding, and IO#external_encoding the wrong object, both from one missing step: rbgo resolved a stream's encodings lazily, at every read, where MRI resolves them once and freezes the answer. rb_io_ext_int_to_encs is the single place the C turns a (named external, named internal) pair into what it stores on a stream, and four call sites reach it — rb_io_extract_modeenc at open with both arguments NULL, which is what snapshots the defaults; parse_mode_enc for a ":ext:int" suffix; rb_io_extract_encoding_option; and io_encoding_set. It is now reproduced once, with the tri-state the C needs (NULL = not named, Qnil = explicitly refused, or an encoding) that "" alone cannot express. It also fixes set_encoding(ext) losing default_internal, the internal_encoding: nil / external_encoding: nil asymmetry (the C tests one against Qundef and the other against Qnil, and it is load-bearing), and IO.readlines / IO.foreach, which reach rb_io_open through open_key_args and take the same snapshot.

2. #syswrite and #write_nonblock write to the DESCRIPTOR. Both went through the buffered write path, which is the one thing their names say they do not do. Four consequences: a non-String argument was Go-ToS'd rather than rb_obj_as_string'd; io_write's zero-length shortcut was applied, so a read-only stream accepted syswrite(""); the external encoding transcoded the bytes, where rb_io_write_memory writes the string's own; and nothing reached disk. Also IO.copy_stream's src_offset now tells apart the two refusals io.c makes — ArgumentError only when the source has no fptr (a StringIO, a duck-typed object), Errno::ESPIPE for a pipe, which is a real IO and gets as far as pread.

3. Dir names carry an encoding. Every name dir.c hands back goes through rb_external_str_new_with_enc; rbgo built a plain UTF-8 String, which was right by accident on a UTF-8 filesystem. The encoding: keyword is now recorded as dp->enc and applied through the block form and the Enumerator form, Encoding.default_internal is honoured, and Dir#to_path keeps the encoding of the argument (dp->path is taken before rb_str_encode_ospath). The conversion is rb_str_conv_enc, not String#encode, and that distinction is what makes it work: the C ends with /* some error, return original */. Writing it with String#encode turned four passing examples into Encoding::UndefinedConversionError — one unrepresentable name made the whole listing raise.

4. IO::Buffer: base and size are two questions. #null? is base == NULL, #empty? is size == 0; rbgo had each exclude the other's case, so a buffer that had given up its memory answered true to one and false to the other. A slice takes parent->base + offset once, which settles two cases in opposite directions: a slice of a 0-sized buffer is null, and a slice whose parent is freed afterwards is not null — it dangles, which is what #valid? reports. rb_io_buffer_resize tests base == NULL before the external check and re-initializes with io_flags_for_size, so a resized 0-sized buffer comes back mapped and a shrunk mapped one internal. rb_io_buffer_type_string builds the String first and the buffer only aliases it, which fixes both size errors and makes #free inside the block stop losing what was written.

5. File.expand_path keeps its argument's encoding; :newline reaches the write path. rb_enc_check(fname, dirname) in append_fspath both refuses an incompatible pair and names the result's encoding. And the :newline option was parsed, validated against binmode, and then dropped; it is an ECONV_*_NEWLINE_DECORATOR the write converter applies.

Premises that did not survive measuring

  • core/io/write_spec.rb was listed as a 9-point target. The real gap is 1. MRI scores 40 through this shim, rbgo 39; 8 of the 9 failures are assertions the harness cannot express (MRI records 3 fails and 5 errors on the same file). Nothing to chase.
  • core/io/copy_stream_spec.rb's 9 are 8 + 1. Filed as Tempfile#initialize does not OR mode with RDWR|CREAT|EXCL, so mode: File::RDONLY raises ENOENT (8 ruby/spec examples) #670. Eight are one cause and it is not in copy_stream: Tempfile.new(..., mode: File::RDONLY) in the spec's setup. MRI's Tempfile#initialize ORs the mode with File::RDWR|CREAT|EXCL; the prelude passes it straight to File.open, which opens read-only on a path that does not exist yet → ENOENT in before :each, and eight examples never run. The ninth (ESPIPE) is fixed here.
  • The largest single item in core/file is not an IO defect at all. core/file/printf_spec.rb scores 0 against a ceiling of 160 — rbgo cannot parse the file. Minimal case: a do…end block inside an arrow-lambda body that is an argument of a parenthesis-less command call. foo :a, lambda { [1].each do |x| p x end } and foo(:a, -> { … }) both parse; only foo :a, -> { [1].each do |x| p x end } does not. That is 160 examples for one parser fix, in internal/compiler. Filed as Parser: a do…end block inside an arrow-lambda body fails after a paren-less command call (160 ruby/spec examples) #669.

Gates

go build ./..., go vet ./..., the full go test ./..., gofmt -l internal/vm/ empty, and the wasip1/wasm build. Coverage: the below-100 list is identical to main's — sixteen functions, the same sixteen, with the same percentages. Two new below-100 entries appeared during the work and were closed rather than rounded away: ioReadEnc's default_internal fallback turned out to be both dead and wrong once the open-time snapshot existed (io_read_encoding has no such fallback), and bufReinit's zero-size branch got the two cases MRI was witnessed on.

One of the new tests went green here and red on both Linux lanes on the first push, and the cause was not in its subject. wave28_file_open_mode_test.go runs Dir.chdir(scratch) with no block, and that scratch directory is removed when the test ends, so the test binary spends the rest of the package in a directory that no longer exists. Any later test that reads the working directory inherits it — and os.Getwd() after the directory is deleted still answers on darwin (stale path, nil error) and fails on Linux. With no working directory, File.expand_path has no directory to prepend, so the rb_enc_check(fname, dirname) the CompatibilityError case depends on is handed an empty string, which is compatible with everything. The two tests now chdir into a directory they created; the un-restored chdir is left alone (another wave's file, breaks nothing on main) but is named where the next test to trip over it will look.

One further platform trap came out of the same test: File.absolute_path? went through path.IsAbs, which only knows the forward-slash root, so a Windows drive-letter root — "C:/x", exactly what File.realpath hands back there — read as relative. isAbsPath exists in file.go for that reason and File.expand_path already uses it; the two have to agree or File.absolute_path?(File.expand_path(p)) is false on Windows. Fixed, with no POSIX change and no new branch (filepath.IsAbs is where the platform difference lives).

One unrelated flake shows up in the table above: core/random/bytes_spec.rb is 50/50 on main (6/6 over twelve runs of the unmodified binary). Random#bytes returns a UTF-8-tagged String, so #size counts characters and a random run sometimes forms fewer than 15 — random.go, not this branch.

Left alone on purpose

  • File.mkfifo stays unregistered — it would gain 7 and cost ~55. The NOTE in file.go is corrected: it blamed a blocking File.open, and that is no longer where the deadlock is. File.open on a FIFO returns at once and rbgo's threads really are concurrent; the hang is at CLOSE, in ioFlush's os.WriteFile, because opening a FIFO to write blocks until a reader opens it and the read side never opens the path either. Each end avoids the handshake, so neither can complete it.
  • instance_variable_set is a silent no-op on natively-backed objects. Filed as instance_variable_set is a silent no-op on File/StringIO/Dir/IO::Buffer/Array/Hash/String/Proc (ivarTable returns nil) #672. ivarTable returns nil for File, StringIO, Dir, IO::Buffer, Array, Hash, String and Proc, so the write is discarded and the read answers nil — no error either way. That is what costs core/file/open_spec.rb its two "propagates … produced by close" examples: the fixture stores the exception in @close_exception through a singleton attr_accessor, and it never arrives. File.open's own ensure machinery is correct and is witnessed re-raising a #close exception when the override raises unconditionally. object_model.go and internal/object, outside this file set.
  • IO::Buffer.for(str) { } does not lock str. rbgo has no rb_str_locktmp, which needs a flag on object.String and a check in every mutator — internal/object and string_*.go, outside this file set. Filed as No rb_str_locktmp: IO::Buffer.for(str) { } does not lock str against modification #671; worth 1 example here and more in pack/unpack.
  • A full non-blocking pipe. ioShortWrite models write(2)'s short write but leaves an already-full pipe alone: MRI's answer could not be witnessed, because a second syswrite to a filled non-blocking pipe never returned under ruby 4.0.5 here. Guessing Errno::EAGAIN would be inventing a semantics.

🤖 Generated with Claude Code

tannevaled and others added 5 commits September 26, 2026 09:12
`IO#internal_encoding` returned nil where MRI returns the encoding, and
`IO#external_encoding` returned the wrong Encoding object. Both come from the
same missing step: rbgo resolved a stream's encodings LAZILY, at every read,
while MRI resolves them once and then freezes the answer.

C read for this: io.c rb_io_ext_int_to_encs, rb_io_extract_modeenc,
parse_mode_enc, rb_io_extract_encoding_option, io_encoding_set,
rb_io_external_encoding, rb_io_internal_encoding, io_read_encoding,
io_input_encoding, open_key_args (ruby/ruby v3_4_0).

rb_io_ext_int_to_encs is the single place MRI turns a (named external, named
internal) pair into the two encodings it stores on a stream. Four callers reach
it — rb_io_extract_modeenc at open (with BOTH arguments NULL, which is what
snapshots the defaults), parse_mode_enc for a mode string's ":ext:int" suffix,
rb_io_extract_encoding_option for the :encoding family, and io_encoding_set for
#set_encoding — and rbgo had no equivalent: each site recorded only what was
literally named.

ioExtIntToEncs now reproduces it, with the tri-state the C needs (NULL = not
named, Qnil = explicitly refused, or an encoding) that "" alone cannot express.
Two rules carry the behaviour:

  - an ASCII-8BIT external drops the internal encoding outright;
  - when the external was DEFAULTED and no internal survives, MRI records
    NOTHING (enc == enc2 == NULL) so the stream keeps following
    Encoding.default_external as it changes. Every other resolution FREEZES the
    pair — which is why a stream opened while default_internal is set reports
    that internal encoding for ever after.

Consequences fixed beyond the two spec files:

  - `io.set_encoding Encoding::IBM866` under default_internal = UTF-8 leaves the
    stream transcoding IBM866 -> UTF-8; naming the external alone does not clear
    the internal one.
  - `internal_encoding: nil` REFUSES the default_internal snapshot, while
    `external_encoding: nil` is the same as omitting the option. The C tests the
    first against Qundef and the second against Qnil; the asymmetry is
    load-bearing.
  - IO.readlines / IO.foreach reach rb_io_open through open_key_args, so they
    take the same snapshot: lines are transcoded, not merely tagged.

ioReadEnc loses its Encoding.default_internal fallback. io_read_encoding has no
such thing — only the EXTERNAL side falls back, and only to
default_external — and with the snapshot in place the fallback was both dead in
the suite and wrong: it let a default changed after the open start transcoding a
stream that was not already doing it. The fmode/FMODE_SETENC_BY_BOM argument of
the C function is not reproduced either: rbgo strips a "BOM|" prefix during name
lookup and never carries the flag that far, so the parameter would have no
caller.

ruby/spec core/io, measured per file against MRI 4.0.5 through the same shim:

  core/io/internal_encoding_spec.rb   67 -> 79   (MRI ceiling 79)
  core/io/external_encoding_spec.rb   48 -> 59   (MRI ceiling 59)

Both files are now AT the ceiling. Whole language+core suite 22222 -> 22245,
with no other file moving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C read for this: io.c rb_io_syswrite, io_write_nonblock, rb_io_write_memory,
copy_stream_fallback, maygvl_copy_stream_read (ruby/ruby v3_4_0).

Both methods went through rbgo's ordinary buffered write path, which is the one
thing their names say they do not do. Four consequences, all of them measured
against MRI 4.0.5:

  - a non-String argument was Go-ToS'd instead of rb_obj_as_string'd, so a
    mock's #to_s was never called;
  - io_write's zero-length shortcut was applied, so a read-only stream accepted
    `syswrite("")` where rb_io_check_writable refuses it (io_write has that
    shortcut; these two do not);
  - the stream's external encoding TRANSCODED the bytes, where
    rb_io_write_memory writes the string's own; a stream set to IBM437 turned
    "ƒ" into [159] instead of writing [198, 146];
  - the bytes stayed in the buffer, so another open of the same path could not
    see them.

wbufDirty stands in for fptr->wbuf.len so rb_io_syswrite's "syswrite for
buffered IO" warning can fire — and only then: a #read leaves nothing buffered
and a sync stream has already flushed.

ioShortWrite models write(2)'s short write on a non-blocking pipe, which is what
lets a 2 MiB syswrite return something strictly between 0 and 2 MiB. An ALREADY
FULL pipe is left alone rather than made to fail: MRI's answer could not be
witnessed, because a second syswrite to a filled non-blocking pipe never
returned under ruby 4.0.5 here. Guessing Errno::EAGAIN would be inventing a
semantics.

IO.copy_stream's src_offset now tells apart the two refusals io.c makes.
copy_stream_fallback raises ArgumentError "cannot specify src_offset for non-IO"
only when the source has NO fptr — a StringIO or a duck-typed object. A pipe IS
an IO, reaches maygvl_copy_stream_read and preads, which on a pipe fails with
ESPIPE.

Also: the File.mkfifo NOTE is corrected. It blamed a blocking File.open, and
re-measuring shows that is no longer where the deadlock is — File.open on a FIFO
returns at once (openFileSpec's `special` branch) and rbgo's threads really are
concurrent. The hang is at CLOSE, in ioFlush's os.WriteFile, because opening a
FIFO to write blocks until a reader opens it and the READ side never opens the
path either. Each end avoids the handshake, so neither can complete it. The note
now says where to look; File.mkfifo stays unregistered (it would gain 7 and cost
~55).

ruby/spec, per file, against the MRI 4.0.5 ceiling through the same shim:

  core/io/syswrite_spec.rb         12 -> 18   (ceiling 18) AT ceiling
  core/io/write_nonblock_spec.rb   14 -> 18   (ceiling 18) AT ceiling
  core/io/copy_stream_spec.rb      53 -> 54   (ceiling 62)

Whole language+core suite 22245 -> 22256, no file regressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (+8)

C read for this: dir.c dir_initialize (dp->enc), dir_read, dir_each_entry,
dir_s_children, and string.c rb_external_str_new_with_enc /
rb_external_str_with_enc / rb_str_conv_enc_opts (ruby/ruby v3_4_0).

Every name dir.c hands back goes through rb_external_str_new_with_enc. rbgo
built a plain UTF-8 String instead, which was right by accident on a UTF-8
filesystem and wrong everywhere else:

  - the `encoding:` keyword of Dir.new / Dir.open / Dir.entries / Dir.children /
    Dir.foreach / Dir.each_child was parsed and then ignored — dir_initialize's
    `NIL_P(enc) ? rb_filesystem_encoding() : rb_to_encoding(enc)` is now recorded
    on the handle as dp->enc and applied to every name it yields, through the
    block form AND the Enumerator form (the block-less readers dropped it on the
    floor separately);
  - Encoding.default_internal did nothing, where
    rb_external_str_with_enc CONVERTS to it;
  - `Dir#to_path` lost the encoding of the argument. dp->path is
    `rb_str_dup_frozen(dirname)` taken BEFORE rb_str_encode_ospath, so the
    String that was passed comes back — a Go string cannot carry that, and the
    handle now keeps the object.

The conversion is rb_str_conv_enc, NOT String#encode, and that distinction is
what makes it work at all: rb_str_conv_enc_opts ends with
"/* some error, return original */ return str;". A name that cannot be
represented in the internal encoding is returned AS IS, in the filesystem
encoding. Writing it with String#encode instead turned four passing examples
into Encoding::UndefinedConversionError errors — a directory holding one
unrepresentable name made the whole listing raise, where MRI lists it and moves
on. rb_external_str_with_enc's own special case is kept too: a US-ASCII handle
encoding over non-ASCII bytes gives ASCII-8BIT.

ruby/spec, per file, against the MRI 4.0.5 ceiling through the same shim:

  core/dir/children_spec.rb    9 -> 13   (ceiling 13) AT ceiling
  core/dir/entries_spec.rb     5 ->  7   (ceiling  7) AT ceiling
  core/dir/foreach_spec.rb     7 ->  8   (ceiling  8) AT ceiling
  core/dir/to_path_spec.rb     2 ->  3   (ceiling  3) AT ceiling

Whole language+core suite 22256 -> 22263, no file regressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C read for this: io_buffer.c rb_io_buffer_null_p, rb_io_buffer_empty_p,
io_buffer_free, rb_io_buffer_free, rb_io_buffer_resize, io_buffer_resize_copy,
io_flags_for_size, io_buffer_initialize, rb_io_buffer_type_string,
io_buffer_for_yield_instance (ruby/ruby v3_4_0).

#null? is `buffer->base == NULL` and #empty? is `buffer->size == 0`. rbgo had
each EXCLUDE the other's case — `empty?` was `!freed && len == 0`, `null?` was
`freed || null` — so a buffer that had given up its memory answered true to one
and false to the other, where MRI answers true to both. #free, #resize(0) and
#transfer all leave base NULL and size 0.

A slice takes `parent->base + offset` ONCE, when it is cut. That decides two
cases at the same time, in opposite directions: a slice of a 0-sized buffer IS
null (NULL plus zero is NULL), and a slice whose parent is freed AFTERWARDS is
NOT null — it dangles, which is what #valid? reports. Recursing to the parent at
call time gets the first right and the second wrong; the flag is captured at
slice time instead.

rb_io_buffer_resize tests base == NULL FIRST, before the external check, and
re-initializes with io_flags_for_size(size). rbgo kept the old flags, so
`IO::Buffer.new(0).resize(PAGE_SIZE)` stayed internal where MRI reports mapped,
and a mapped buffer shrunk below a page stayed mapped where MRI reports
internal. mremap is a Linux extension and rbgo has no mapping to remap, so a
mapped buffer always takes io_buffer_resize_copy — which is also the branch MRI
takes on the platform the shim pins (darwin).

rb_io_buffer_type_string builds the String FIRST, with
`rb_str_new(NULL, RB_NUM2LONG(length))`, and the buffer only aliases it. Three
consequences rbgo had wrong: a bignum length is RB_NUM2LONG's RangeError and a
negative one rb_str_new's ArgumentError, BOTH before the block is looked for
(rbgo asked for the block first, and got a Go makeslice panic text for the
negative case); and a #free inside the block cannot lose what was written,
because the String owns the bytes — `.string(4) { |b| b.set_string("meat");
b.free }` returned "" instead of "meat". #free also refuses a locked buffer
("Buffer is locked!").

ruby/spec, per file, against the MRI 4.0.5 ceiling through the same shim:

  core/io/buffer/empty_spec.rb    10 -> 13  (ceiling 13) AT ceiling
  core/io/buffer/string_spec.rb    4 ->  7  (ceiling  7) AT ceiling
  core/io/buffer/free_spec.rb      8 -> 10  (ceiling 10) AT ceiling
  core/io/buffer/resize_spec.rb   13 -> 15  (ceiling 15) AT ceiling
  core/io/buffer/null_spec.rb     12 -> 13  (ceiling 13) AT ceiling

Whole language+core suite 22263 -> 22275, no file regressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…write path (+4)

C read for this: file.c rb_file_expand_path_internal, append_fspath,
fs_enc_check, check_path_encoding, and io.c rb_io_extract_modeenc's
TEXTMODE_NEWLINE_DECORATOR_ON_WRITE / ECONV_CRLF_NEWLINE_DECORATOR
(ruby/ruby v3_4_0).

File.expand_path built a plain UTF-8 String. rb_file_expand_path_internal starts
from `enc = rb_enc_get(fname)` and an ABSOLUTE path keeps it; a relative one
grows a directory in front of it, and append_fspath then runs
rb_enc_check(fname, dirname) over the pair. That single call does two things
rbgo did neither of: it REFUSES an incompatible combination — an ASCII path
expanded while Encoding.default_external is UTF-16BE is
Encoding::CompatibilityError, because the directory cannot be spliced onto it —
and otherwise it names the result's encoding, so "./a" tagged CP1251 comes back
CP1251 and a path of raw bytes comes back BINARY.

The directory carries the filesystem encoding, so the argument has to be coerced
ONCE and kept as a String: filePathString is filePathArg that hands back what it
coerced, rather than sending #to_path a second time to ask for its encoding.
filePathArg is now that, plus .Str().

The :newline option was parsed, validated against binmode, and then dropped.
io.c turns it into an ECONV_*_NEWLINE_DECORATOR bit that the WRITE converter
applies, so `File.open(p, "wt", newline: :crlf)` writes "\r\n" for every "\n" the
stream is asked to write — through #write, #puts, #print, #printf and #<< alike,
since they all go through the converter. :lf and :universal have no write-side
effect (universal is a READ decorator), and #syswrite / #write_nonblock are not
decorated at all: they go to the descriptor, which is the same boundary
rb_io_write_memory sits on.

ruby/spec, per file, against the MRI 4.0.5 ceiling through the same shim:

  core/file/expand_path_spec.rb   25 -> 27  (ceiling 29)
  core/io/puts_spec.rb            14 -> 16  (ceiling 17)

Whole language+core suite 22275 -> 22278, no file regressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled and others added 2 commits September 26, 2026 10:53
Both went green here and red on the two Linux CI lanes of #668
(`TestFileExpandPathEncoding/incompatible_default_external_raises` and
`TestFileRealpathRelativeUsesTheCwd`). The cause is not in either test's subject.

wave28_file_open_mode_test.go runs `Dir.chdir(scratch)` with NO block, and that
scratch directory is removed when the test ends, so the test binary spends the
rest of the package in a directory that no longer exists. Any later test that
reads the working directory inherits it.

That is invisible on macOS and fatal on Linux, and the difference is in
os.Getwd(): after the directory is deleted it still ANSWERS on darwin (verified —
it returns the stale path with a nil error) and FAILS on Linux. With no working
directory, File.expand_path has no directory to prepend — so the
rb_enc_check(fname, dirname) that the CompatibilityError case depends on is
handed an empty string, which is compatible with everything and does not
raise — and File.realpath cannot resolve a relative name at all.

So the local green was for the wrong reason. Both tests now chdir into a
directory they created themselves, which is also what keeps them exercising
fileExpand's working-directory branch on purpose rather than by inheritance.
chdirInto restores to THIS file's own source directory rather than to the
previous working directory, because the previous one is the deleted path being
escaped; runtime.Caller gives a path that is guaranteed to exist.

The un-restored chdir in wave28_file_open_mode_test.go is left alone — it belongs
to another wave's file and breaks nothing on main — but it is now named where the
next test to trip over it will look.

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

`File.absolute_path?` went through `path.IsAbs`, which only knows the
forward-slash root. rbgo keeps paths forward-slashed internally, so a Windows
drive-letter root — "C:/x", which is exactly what File.realpath hands back
there — reads as RELATIVE.

isAbsPath exists in this file for that reason and its comment says so, and
File.expand_path already uses it. The two have to agree or
`File.absolute_path?(File.expand_path(p))` is false on Windows, which is both
wrong and a trap for any test that pairs them: the new
TestFileRealpathRelativeUsesTheCwd did exactly that, and would have failed on
the windows lane alone.

No POSIX change — for a forward-slash path the two terms of isAbsPath agree, and
`[File.absolute_path?("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/a/b"), File.absolute_path?("a/b"),
File.absolute_path?(File.expand_path("x"))]` is `[true, false, true]` on MRI
4.0.5 and on rbgo alike. core/io + core/file + core/dir + core/filetest measured
per file before and after: no file moves.

There is no Windows MRI here to witness the drive-letter case directly; the
warrant is MRI's documented `File.absolute_path?` plus isAbsPath's own stated
purpose in this file. It adds no branch of its own — filepath.IsAbs is where the
platform difference lives — so it leaves the coverage list unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 6f5c655 into main Sep 26, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave32/ioenc branch September 26, 2026 10:14
tannevaled added a commit that referenced this pull request Sep 26, 2026
Three units, +283 measured, and the first wave whose numbers are trustworthy
throughout: #660 repaired the harness at the end of wave 31, so this is the
first measurement in the campaign with no known vacuous assertions under it.

  #666  core/kernel, numerics, symbols (+162). 45 of those were INVISIBLE
        rather than failing: core/integer/{pow,exponent}_spec.rb each scored 0
        because the corpus contains `100000000 ** 1000000000` and rbgo computed
        it unbounded until the harness killed the process. bignum.c's
        BIGLEN_LIMIT refuses it. core/rational and core/builtin_constants are
        now AT the ceiling.
  #668  IO/Dir encodings resolved once at open, #syswrite reaching the
        descriptor, IO::Buffer's base/size distinction (+56). THIRTEEN of the
        sixteen files it touched now sit exactly at the ceiling the harness can
        express.
  #665  singleton classes of nil/true/false, post parameters in Method#arity
        AND #parameters together, and `!~`/`!`/`!=` DISPATCHED rather than
        synthesised — one cause, three defects, found by asking whether #663's
        neighbours shared its shape.

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22489, 22489, 22488. The
floor is set thirteen below the lowest.

Three of my briefing targets dissolved under measurement again — the third wave
running. core/io/write_spec's "9 failures" is a real gap of 1 (MRI fails the
other eight through this shim); core/array/fill_spec's "9" is a gap of ZERO
(ceiling 38, rbgo 38); core/numeric/step_spec's 12.700000000000001 is what MRI
prints. Running MRI through our own shim for a per-file ceiling is what catches
these, and it is now done before planning rather than after.

The largest lever left is not in any of these lanes: core/file/printf_spec.rb
scores 0 against a ceiling of 160 because rbgo cannot parse a do...end block
inside an arrow-lambda that is a paren-less command argument. One parser fix,
160 examples — issue #669.

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