Repository navigation
internal/vm: $stdout and $stderr are duck-typed — dispatch #write instead of asserting *IOObj - #697
Merged
Merged
Conversation
…pe assertion curIO type-asserted $stderr/$stdout to *IOObj and fell back to the raw file descriptor for anything else, so every interpreter-generated warning and every Kernel#p aimed at a duck-typed stream went to the terminal instead. MRI's streams are duck-typed at the language level: stdout_setter and stderr_setter (io.c:9215, :9228, v3_4_0) admit any object answering #write and reject the rest with a TypeError, and rb_io_write (io.c:2296) is unconditionally rb_funcallv(io, id_write, 1, &str) -- there is no fast path for a real IO at that level. One dispatch point, in io.go: stdoutValue/stderrValue rb_ractor_stdout / rb_ractor_stderr ioWrite rb_io_write ioWritev rb_io_writev, including its #write-arity split writeWarningStr rb_write_warning_str -> Warning.warn rb_warn reaches $stderr only through the default Warning.warn, so routing rbWarn1/rbWarning1/rbWarn there also makes an overridden Warning.warn intercept rbgo's own warnings, as MRI's does. ioPuts and Kernel#p now hand the line and its separator to ioWritev as two pieces, the shape rb_io_puts and rb_p_write build -- observable to any stream that records what it was handed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ouble Ten WITNESS cases and eight GUARD cases, and the labels are measured by running this exact file against the parent commit, not assumed: every WITNESS fails there, every GUARD passes. The double answers ONLY #write. A StringIO cannot witness any of this, because StringIO IS an *IOObj in rbgo -- it satisfied the type assertion curStderr() made and collected the output either way, which is why mspec's StringIO-based complain/output matchers never saw the defect. Every case asserts BOTH streams. The old code did not lose the text, it wrote it to the raw descriptor, so an assertion on one buffer alone passes either way -- one case here did exactly that and passed on the parent commit for the wrong reason until its verdict was routed through the other stream. Also drops nativePuts's second "lost its #write" test, now that stdSink owns that rule. Expected values are byte-for-byte from MRI 4.0.5 running the same scripts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to_s rb_io_writev's outdated-interface notice renders the receiver with `%+"PRIsVALUE"`, and the '+' flag means rb_inspect. The first version of warnOutdatedWrite used #to_s, which is observable: an object carrying @marker and its own #to_s is named `#<Object:0x... @marker=42>` by MRI 4.0.5 and "TO_S_CALLED" by that version. Adds the witness, which also reaches the singleton arm of the '.' / '#' separator choice. It asserts the prefix and suffix rather than the whole string, because the middle is a heap address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e :deprecated gate The below-100% function LIST diffed against origin/main named exactly two new entries, both mine: ioWritev at 90.9% and warnOutdatedWrite at 87.5%. Resolving each in the tree that produced its profile and aggregating the -coverpkg blocks (the profile carries one entry per test package, so a single 0 is not an uncovered block) located both. ioWritev's `len(strs) == 0` guard is unreachable: rb_io_writev is never called with argc 0 in MRI either, since rb_io_puts always builds n >= 1 and its no-argument case goes through rb_io_write. Removed rather than tested -- a branch no test can reach honestly does not belong. warnOutdatedWrite's other arm IS reachable, and is the DEFAULT state: the :deprecated category is off in Ruby 3+, which is why that notice is almost never seen. Pinned with a case that asserts silence and asserts Warning[:deprecated] is false, so the gate is a measurement rather than a comment. Both functions now read 100%, and the below-100 list is identical to origin/main's (16 entries either side). The only change to the FULL measured function set is builtins.go::stdoutValue leaving and io.go::stdoutValue arriving -- the same function, moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… doc The doc said eight fail and two pass, from before the singleton-inspect and :deprecated-gate cases were added. Re-measured against origin/main: 11 WITNESS, all failing there, and 6 GUARD, all passing. Nothing mislabelled either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled
force-pushed
the
wave40-duck-typed-stderr
branch
from
September 26, 2026 18:44
1e894a2 to
805e31e
Compare
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 defect
internal/vm/io.go'scurIOtype-asserted$stdout/$stderrto*IOObjand fellback to the raw file descriptor for anything else. MRI's standard streams are
duck-typed at the language level, so every interpreter-generated warning and every
Kernel#paimed at a stream that merely answers#writewent to the terminal insteadof to the object the program had installed.
Reproduced against MRI 4.0.5 (
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]), before this change:Why it survived
With a
StringIOin place of the duck type, both engines collect it identically --because
StringIOis an*IOObjhere and so satisfied the type assertion. Thematchers meant to catch this (mspec's
complain/output) used aStringIO, so theharness's own double could not witness the defect. The comment in
io_class.gothatjustified writing to
curStderr()said so in as many words: "still honouring a$stderrreassigned to a StringIO (mspec'scomplainmatcher)".Every WITNESS test added here therefore uses a plain object answering only
#write.The C functions read, and what each decided
Read at
ruby/rubytagv3_4_0.io.c:2296rb_io_writereturn rb_funcallv(io, id_write, 1, &str);-- an unconditional dynamic#writesend, with no fast path for a real IO.io.c:2220io_writevIO#write's own C body, reached only because method lookup on a real IO lands there, and itself ending inrb_funcallv(io, id_write, argc, argv)oncerb_io_check_ioreports "port is not IO, call write method for it". So no caller ever chooses between the two arms.io.c:2302rb_io_writev#writearity, not on the piece count:argc > 1 && rb_obj_method_arity(io, id_write) == 1gets one call per piece plus a:deprecatednotice; anything else gets one multi-argument call.io.c:9162rb_write_error_strrb_stderr_to_original_p(io.c:9130) =err == orig_stderr-- pointer identity with the original object, not "is it an IO". Testing*IOObjinstead is strictly broader, which is the defect.io.c:9215,io.c:9228stdout_setter/stderr_settermust_respond_to(id_write, val, id), so any object answering#writeis admitted and the rest are refused with aTypeErrorat assignment.io.c:8959rb_io_putsrb_default_rsalone; a line already ending in a newline writes itself alone; anything else writes line and separator as two pieces.io.c:8727rb_io_printrb_io_writeper argument with the raw object -- the#to_shappens insideIO#write(io.c:2026), so a duck's#writereceives the unconverted value. rbgo already matched.io.c:9018rb_p_writeKernel#pwrites the inspected form andrb_default_rsas two pieces. ItsT_FILE && rb_method_basic_definition_parm is not a semantic fork -- both arms produce the same bytes -- so rbgo takes the general one.error.c:374rb_write_warning_strrb_warning_warn(rb_mWarning, str), andrb_warning_warn(error.c:341) isrb_funcallv(mod, id_warn, 1, &str). So the one place isWarning.warn, one level above$stderr.error.c:466,:497,:489rb_warn/rb_warning/rb_enc_warnrb_write_warning_str, and differ only in their gate:!NIL_P(ruby_verbose)forrb_warn,RTESTforrb_warning.error.c:287rb_warning_s_warnWarning.warnbody:rb_write_error_str(str). That is the only route from a warning to$stderr.error.c:355rb_warn_categoryrb_warning_warn_arity(): a one-argumentWarning.warnoverride is called without the category keyword.error.c:420rb_category_warningRTEST(ruby_verbose)(true, not merely non-nil) and an enabled category -- and:deprecatedis off by default in Ruby 3+.process.crb_f_abortKernel#abortwrites its message withrb_io_puts(1, args, rb_ractor_stderr())--putssemantics on the duck-typed$stderr. See the defects section: rbgo's is outside this PR's cluster.The one-place dispatch decision
rb_io_writeis already the single dynamic-dispatch point in MRI, and the fast pathis not a branch any caller takes -- it is what method lookup does when the receiver
happens to be a real IO. So the rule belongs in exactly one function per C counterpart,
all in
io.go:rbWarn1,rbWarning1andrbWarnnow callwriteWarningStr;Kernel#warncalls thesame
warningWarnthey end in;ioPutsandKernel#pcallioWritev.curStdout/curStderrstay, for the callers that genuinely need a stream object (rack.errors,Puma's log sink, ARGF), and carry a comment saying they are not the way to emit output.
Two duplicated tests were removed rather than left to drift:
nativePrintandnativePutseach carried their own "lost its#write" fallback, whichstdSinknowowns. This repo has had four repeat defects in a week from a rule restated at several
sites.
Choosing
Warning.warnrather than$stderras the sink is what the C source decided,and it subsumes the original brief: the default
Warning.warnis$stderr.write(message), an ordinary send, so fixing the higher level fixes theduck-typed
$stderrand makes an overriddenWarning.warnintercept rbgo's owninternal warnings, which MRI's does and rbgo's did not.
What happens for a stream that cannot do the job
#writebut not#putswarn,p,puts,print;$stderr.puts/$stderr.printraiseNoMethodError: private method 'puts' called for an instance of Duck#writenor#putsTypeError: $stderr must have write method, Mute given, raised at the ASSIGNMENT bymust_respond_to-- never aNoMethodError#writeraisesp, out of everything#writeat all)#writeand lost it afterwardsNoMethodErrorstdSinkas an implementation choice, not a differential case.Three receivers x nine paths
Byte-for-byte against MRI 4.0.5. "duck" answers only
#write; "Tempfile" is the thirdreceiver and, unexpectedly, a second witness -- a
Tempfileis a delegator, not an*IOObj, so it was dropped exactly as the duck was.StringIOis the control thatcould not witness anything.
warn(internalrb_warn)Kernel#warn$stderr.puts$stderr.write$stderr.printpputsprint45 of 45 cells identical after the change; 6 differed before. Plus the argv shapes:
p 7, splat#write[["7", "\n"]][](leaked)[["7", "\n"]]p 7, arity-1#write[["7"], ["\n"]][](leaked)[["7"], ["\n"]]puts "hi", splat[["hi", "\n"]][["hi\n"]][["hi", "\n"]]puts "hi", arity-1[["hi"], ["\n"]][["hi\n"]][["hi"], ["\n"]]puts "hi\n"[["hi\n"]][["hi\n"]][["hi\n"]]puts ""[["\n"]][["\n"]][["\n"]]print 7, 8[["7"], ["8"]][["7"], ["8"]][["7"], ["8"]]Warning.warnsees internalrb_warn#write+Warning[:deprecated]One#write is outdated interface...Tests
internal/vm/io_duck_stream_test.go: 10 WITNESS cases and 8 GUARD cases. The labelsare measured, by running that exact file against the parent commit -- every WITNESS
fails there, every GUARD passes.
Every case asserts both streams. The old code did not lose the text, it wrote it to
the raw descriptor, so an assertion on one buffer alone passes either way. One case
here did exactly that and passed on the parent commit for the wrong reason until its
verdict was routed through the other stream; that is recorded in its comment.
Corpus
Ratchet
scripts/conformance/rubyspec/run.sh, corpus pinned87b1631, 2206 specfiles,
TIMEOUT=25, measured serially at 2-way (a parallel sweep is notreproducible on this host -- one or two sibling agents were measuring alongside).
Load average was 8.4-8.6 throughout and comparable across the pair.
The per-file table comes from a small Go sweep tool rather than a shell loop, and its
integrity guard asserts no empty key column, distinct filenames == input count
and a floor on the measurement -- not just row count, which a previous sweep here
passed green while reporting 2206 rows with every filename empty and a total of 0. It
also counts a TIMEOUT, a load with no
RBGO_RESULTline, and a file that loaded andgenuinely scored 0 as three separate things.
Against
origin/mainat41fa005mainmoved four times while this was being measured (#685, #692, #694, #684 all landed),so the pair was rebuilt and re-measured on each new base. The result is unchanged:
7269d0a+ #692's shim4f5581a41fa005(current)On the current base: 2193 files loaded either side, 5 vs 6 timeouts, 8 vs 7 no-result.
Zero files moved down in any of the three runs, and the SET of files that moved is
byte-identical between the
4f5581aand41fa005pairs -- four independently builtbinaries, the same 26 names.
core/kernel/p_spec.rbcore/module/ruby2_keywords_spec.rbcore/array/new_spec.rbcore/io/for_fd_spec.rbcore/io/new_spec.rbcore/io/open_spec.rbcore/regexp/compile_spec.rbcore/regexp/new_spec.rblanguage/constants_spec.rblanguage/optional_assignments_spec.rbcore/array/fetch_spec.rbcore/array/find_index_spec.rbcore/array/initialize_spec.rbcore/array/rindex_spec.rbcore/env/fetch_spec.rbcore/file/new_spec.rbcore/hash/fetch_spec.rbcore/io/syswrite_spec.rbcore/module/autoload_spec.rbcore/module/const_added_spec.rbcore/module/const_get_spec.rbcore/module/const_source_location_spec.rbcore/refinement/import_methods_spec.rblanguage/for_spec.rblanguage/regexp/encoding_spec.rbcore/random/bytes_spec.rbcore/random/bytes_spec.rbis not credited: it is flaky. Sampled ten times per binaryrather than concluded from one run, base gives
8 8 8 9 8 9 8 9 8 9and this PR gives8 9 8 8 9 8 9 9 9 8-- the same population. So the honest figure is +38 across 25files, and the same 25 files came out of an independent earlier pair run before #692
merged, on a separate build of both sides.
One further row changed STATUS without changing any count, and is load noise rather than
a result:
core/mutex/lock_spec.rb(no-result -> timeout, 0 either way).On the shim that was
main's before #692 mergedMeasured before #692 landed, so both sides are a different build:
7269d0a)No net change there, and the single differing file is the same
core/random/bytes_spec.rbflake, in the other direction. That the pre-#692 shimshows nothing is expected and is the whole story of this defect: that shim's double for
complainwas aStringIO, which cannot witness any of it. #692 is what introduced adouble that could.
Under #692's shim on the pre-#685 build, the same comparison gave 23097 -> 23135,
i.e. +38 across the same 25 files -- so the payoff reproduces across two different
builds of both sides.
FLOORis not touched by this PR.Gates
CGO_ENABLED=0 GOWORK=off go test -count=1 ./...-- full suite, green.CGO_ENABLED=0 go build ./...green;GOOS=wasip1 GOARCH=wasmgreen.GOOS=js GOARCH=wasmgreen. It was red when this branch opened --internal/vm/errno.go:101,108,120: undefined: syscall.ENOTRECOVERABLE / EOWNERDEAD / ETXTBSY, verified identical on an unmodifiedorigin/maincheckout and therefore nottouched here. internal/vm, ci: fix the js/wasm build, and make the guard that caught it reachable #684 has since landed and fixed it, and this branch is rebased onto it,
so the
wasm (js, browser)lane now runs for this PR too.gofmt -l internal/ cmd/empty.-raceis unavailable locally (this host needsCGO_ENABLED=0; the Xcode licence isbroken), so CI's race lane is the first to run it.
100% coverage, by the four-part recipe
-raceis unavailable locally, so this is the no-race profile; the rounded total is99.9% on BOTH sides and is deliberately not the comparison.
origin/mainon the same-coverpkg=./internal/...profile: 16 entries on each side, and the two lists areidentical.
cover -funcwas resolved in the tree that produced its profile.file::namerather thanby
file:line:-- with the line number in the key, a 160-line insertion makes everylater function look both removed and added, and the diff says nothing. Keyed
properly, there is exactly one removal:
builtins.go::stdoutValue, matched byio.go::stdoutValuearriving. Same function, moved. Nine functions added, all at100.0%.
The list diff earned its place. It named two functions this PR left below 100% that the
rounded total hid completely --
ioWritevat 90.9% andwarnOutdatedWriteat 87.5%.Locating them needed one more step than expected: a
-coverpkgprofile carries oneentry per test package, so a block with a single
0is not an uncovered block; thecounts have to be aggregated per block first. Two genuinely different causes came out:
ioWritev'slen(strs) == 0guard was unreachable, and MRI has no such caseeither (
rb_io_putsalways builds n >= 1; its no-argument case goes throughrb_io_write). Removed rather than tested.warnOutdatedWrite's:deprecated-disabled arm is reachable, and is the defaultstate. Pinned with a case asserting both the silence and
Warning[:deprecated] == false, so that gate is now measured rather than asserted in a comment.Defects found outside this PR's cluster
Each measured against MRI 4.0.5, each left alone because its owning file is not this
wave's.
Kernel#abortignores$stderrentirely, and adds a newline MRI does not.internal/vm/kernel_introspect.go:239doesfmt.Fprintln(vm.errOut, msg), whereprocess.c rb_f_abortdoesrb_io_puts(1, args, rb_ractor_stderr())--putssemantics on the duck-typed global. Measured with a
#writethat appends to a file:abort "plain message"gives MRI 14 bytes on the duck and 0 on fd 2; rbgo gives 0 onthe duck and 14 on fd 2. And
abort "with newline\n"gives MRI 13 bytes (rb_io_putsappends nothing to a line already ending in a newline) against rbgo's 14 -- a doubled
separator. Fixing it needs
ioWritevfrom this PR, so it is a natural follow-up.$stderr.cmd/rbgo/main.go:102-113writes to
os.Stderrdirectly. With$stderrbound to a duck that appends to a file,MRI puts the whole report (message, class, source line, caret) on the duck and nothing
on fd 2; rbgo puts it on fd 2 and nothing on the duck.
$stderr.internal/vm/require.go:382calls
curStderr().writeStr(...)-- the exact pattern this PR removed from its ownthree files. Measured under
$VERBOSE = true: MRI writes it to the duck, rbgo tofd 2.
internal/vm/autoload.go:271has the same shape.defproduces a PUBLIC method onObject; MRI makes it private.def myhelper(x); x; endthenObject.private_method_defined?(:myhelper)gives MRItrueand rbgofalse;public_method_defined?is the mirror image; and1.myhelper(2)succeeds in rbgo where MRI raisesNoMethodError: private method 'myhelper' called for an instance of Integer. Thisfound me rather than the reverse: it broke my first test harness, whose
respond_to?(:collected)guard answeredtruefor a private helper.at_exithandlers do not run when the program dies of an uncaught exception.MRI runs them (and prints the exception report afterwards, which is why a handler
cannot see that report); rbgo runs none.
at_exitdoes run on an explicitexit.Kernel#exit's status is lost:exit 1exits 0. MRI exits 1. Pre-existing onthe base, with and without an
at_exithandler. Caution for whoever picks this up:the obvious probe lies. Running
cmd >/dev/null 2>&1; echo $?through a shellharness reported 0 for MRI too, including for
exit 1-- it nearly became afabricated defect in the other direction. Capture the status in the SAME shell
statement that runs the process (
"$E" "$f"; st=$?) and print it from the variable.rbWarnemits no"path:lineno: "prefix, and the constant-redefinition warningis missing MRI's second line.
X = 1; X = 2under$VERBOSE = truegives MRIprog.rb:3: warning: already initialized constant Xfollowed byprog.rb:2: warning: previous definition of X was here; rbgo giveswarning: already initialized constant Xand nothing more.rbWarnis in this PR'scluster, but its nine callers are not, so adding the prefix is a separate measurement.
🤖 Generated with Claude Code