From a07215a697f5f9df47076522fcd1cee7da53dd9c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 4 Nov 2026 11:21:06 +0100 Subject: [PATCH] $/ and $-0 stop storing the object they were handed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby 4.0's deprecated_rs_setter (io.c v4.0.9:8734) replaces the assigned String unless it is already a frozen, bare one: rb_deprecated_str_setter(val, id, &val); // TypeError, and the warning if (!NIL_P(val)) { if (rb_str_equal(val, rb_default_rs)) val = rb_default_rs; else val = rb_str_frozen_bare_string(val); } ## Three things reading the source prevented 1. ⛔ $\ AND $, DO NOT TAKE THIS SETTER. Init_IO hooks them to rb_deprecated_str_setter and only $/ and $-0 to deprecated_rs_setter. rbgo handled all four through one checkStrGvar, so the obvious edit -- the one that does not read Init_IO -- would have frozen four variables where MRI freezes two. Both are now controls, and both were checked against a real MRI 4.0.7 rather than against my reading. 2. ⛔ THE EQUALITY BRANCH RUNS FIRST, so a frozen plain "\n" -- which satisfies "frozen and has no instance variables" word for word -- is still replaced, by the canonical separator object. No example in ruby/spec reaches it: language/predefined_spec.rb uses "abc" throughout, which can only take the other branch. Proposed upstream as ruby/spec#1410. 3. BARE_STRING_P (string.c:372) is BOTH halves -- exact class String AND no instance variables -- which is what makes the three existing examples distinguish each other. ## Measured against the oracle rbgo and ruby 4.0.7 now agree byte for byte on every case, including the two controls that assert $\ and $, were left alone: ors equal?=true frozen?=false <- $\, unchanged ofs equal?=true frozen?=false <- $,, unchanged ret true <- assignment returns the value GIVEN dash0 true <- $-0 and $/ stay one object ## A limit this does not fix, named rather than papered over predefined_spec.rb's "makes a new frozen String from the assigned String" assigns a String SUBCLASS, and rbgo refuses that one step earlier -- checkStrGvar decides with a Go type assertion where MRI tests T_STRING. Filed as #884. Widening the assertion touches every "$x must be String" global, so it is a separate change and not a rider on this one. Refs #799 Refs #884 Co-Authored-By: Claude Opus 5 --- internal/vm/globals.go | 78 ++++++++++++++++++++++++- internal/vm/record_separator_test.go | 86 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 internal/vm/record_separator_test.go diff --git a/internal/vm/globals.go b/internal/vm/globals.go index e309c9de..9826458c 100644 --- a/internal/vm/globals.go +++ b/internal/vm/globals.go @@ -412,7 +412,16 @@ func (vm *VM) storeGVar(name string, v object.Value) { } vm.lastMatch = object.NilV return - case "$/", "$-0", `$\`, "$,": + case "$/", "$-0": + // ⛔ $/ and $-0 take a DIFFERENT setter from $\ and $, since 4.0, and + // reading the four as one would carry the new freezing into two + // variables MRI deliberately left alone (io.c v4.0.9 Init_IO): + // rb_define_hooked_variable("$,", …, rb_deprecated_str_setter); + // rb_define_hooked_variable("$/", …, deprecated_rs_setter); + // rb_define_hooked_variable("$-0", …, deprecated_rs_setter); + // rb_define_hooked_variable("$\\", …, rb_deprecated_str_setter); + v = vm.checkRecordSepGvar(name, v) + case `$\`, "$,": v = vm.checkStrGvar(name, v) case "$;", "$-F": v = vm.checkFieldSepGvar(name, v) @@ -451,6 +460,73 @@ func (vm *VM) checkStrGvar(name string, v object.Value) object.Value { return v } +// checkRecordSepGvar applies io.c v4.0.9 deprecated_rs_setter to $/ and $-0, +// which from Ruby 4.0 no longer store the object they were handed: +// +// rb_deprecated_str_setter(val, id, &val); // TypeError, and the warning +// if (!NIL_P(val)) { +// if (rb_str_equal(val, rb_default_rs)) val = rb_default_rs; +// else val = rb_str_frozen_bare_string(val); +// } +// +// ⛔ THE EQUALITY BRANCH COMES FIRST, and it overrides the "a frozen bare String +// is kept" rule for the one value most likely to be assigned. A frozen, plain +// "\n" satisfies that rule word for word and is still NOT what gets stored: +// +// $/ = "\n".dup.freeze +// $/.equal?(that string) # => false +// $/.equal?($/ as it was) # => true, the canonical separator +// +// Measured on ruby 4.0.7 before this was written. No example in ruby/spec +// reaches it — language/predefined_spec.rb uses "abc" throughout, which can only +// take the second branch — so the suite passes either way and the identity is +// wrong. Proposed upstream as ruby/spec#1410. +func (vm *VM) checkRecordSepGvar(name string, v object.Value) object.Value { + v = vm.checkStrGvar(name, v) + if object.IsNil(v) { + return v + } + s := v.(*object.String) + // rb_str_equal against a one-byte US-ASCII "\n": byte equality decides it. + // Any ASCII-compatible encoding holding that single byte IS == "\n", and an + // ASCII-incompatible one (UTF-16, UTF-32) cannot hold it in one byte at all, + // so there is no case where the encodings would change the answer. + if s.Str() == defaultRecordSeparator.Str() { + return defaultRecordSeparator + } + return frozenBareString(s) +} + +// frozenBareString is string.c v4.0.9 rb_str_frozen_bare_string: +// +// if (BARE_STRING_P(orig) && OBJ_FROZEN_RAW(orig)) return orig; +// return str_new_frozen(rb_cString, orig); +// +// where BARE_STRING_P (string.c:372) is `RBASIC_CLASS(str) == rb_cString && +// !rb_shape_obj_has_ivars(str)`. "Bare" is therefore BOTH halves: the exact +// class, AND no instance variables. A String subclass carrying @ivar fails both +// and a frozen plain String passes both, which is precisely what the three +// examples in language/predefined_spec.rb distinguish. +// +// ⛔ ONLY THE IVAR HALF IS TESTED HERE, and that is not an omission. In rbgo a +// *object.String is class String by construction (classOf maps the Go type +// straight to vm.cString), and an instance of a String SUBCLASS never reaches +// this function at all — checkStrGvar's type assertion rejects it first, with +// "value of $/ must be String". MRI accepts one, because its check is T_STRING +// and a subclass is still T_STRING. That divergence is a defect of its own, +// filed rather than papered over here: it is why +// predefined_spec.rb's "makes a new frozen String from the assigned String" +// still fails, and fixing it belongs with the assertion, not with this rule. +func frozenBareString(s *object.String) *object.String { + if s.Frozen && genericIvarsOf(s, false) == nil { + return s + } + out := object.NewString(s.Str()) + out.Enc = s.Enc + out.Frozen = true + return out +} + // checkFieldSepGvar applies string.c v3_4_0 rb_fs_setter to $; / $-F: nil, a // String or a Regexp is taken as is, anything else is offered #to_str // (rb_fs_check's rb_check_string_type) before the TypeError, and a non-nil diff --git a/internal/vm/record_separator_test.go b/internal/vm/record_separator_test.go new file mode 100644 index 00000000..70e88cec --- /dev/null +++ b/internal/vm/record_separator_test.go @@ -0,0 +1,86 @@ +package vm + +import "testing" + +// Ruby 4.0 stops storing the object assigned to $/ and $-0 +// (io.c v4.0.9 deprecated_rs_setter, language/predefined_spec.rb:698-722). +// +// Every expectation below was measured on a real +// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) [arm64-darwin25]` before it was +// written here, because two of them are not in ruby/spec at all. +func TestRecordSeparatorAssignment(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + { + "an unfrozen String is replaced by a frozen copy", + `$VERBOSE = nil; s = +"abc"; $/ = s; p [$/.equal?(s), $/.frozen?, $/ == s]`, + "[false, true, true]", + }, + { + "a frozen String with no instance variables is kept", + `$VERBOSE = nil; s = "abc".freeze; $/ = s; p $/.equal?(s)`, + "true", + }, + + // ⛔ NOT IN ruby/spec. The equality branch runs BEFORE the "frozen bare + // string is kept" rule, so a frozen plain "\n" — which satisfies that + // rule word for word — is still replaced, by the canonical separator. + // Every example in predefined_spec.rb uses "abc" and so can only reach + // the other branch; proposed upstream as ruby/spec#1410. + { + "a String equal to the default becomes the default object", + `$VERBOSE = nil; d = $/; s = "\n".dup.freeze; $/ = s; p [$/.equal?(s), $/.equal?(d)]`, + "[false, true]", + }, + { + "and so does an unfrozen one", + `$VERBOSE = nil; d = $/; $/ = +"\n"; p $/.equal?(d)`, + "true", + }, + + // $-0 shares the slot, so it takes the same setter. + { + "$-0 is replaced the same way", + `$VERBOSE = nil; s = +"abc"; $-0 = s; p [$-0.equal?(s), $-0.frozen?]`, + "[false, true]", + }, + { + "and still reads as the same object as $/", + `$VERBOSE = nil; $/ = +"xyz"; p $-0.equal?($/)`, + "true", + }, + + // ⛔ CONTROLS: $\ and $, take rb_deprecated_str_setter, NOT this one + // (io.c v4.0.9 Init_IO). Carrying the freezing into them would be a + // silent divergence in the opposite direction, and nothing else in the + // suite asserts it. + { + `$\ keeps the object it is given`, + `$VERBOSE = nil; s = +"abc"; $\ = s; p [$\.equal?(s), $\.frozen?]`, + "[true, false]", + }, + { + `$, keeps the object it is given`, + `$VERBOSE = nil; s = +"abc"; $, = s; p [$,.equal?(s), $,.frozen?]`, + "[true, false]", + }, + + // Unchanged by this: nil, the TypeError, and the returned value. + {"nil is accepted", `$/ = nil; p $/`, "nil"}, + { + "a non-String is still refused without #to_str", + `begin; $/ = 1; rescue TypeError => e; p e.message; end`, + `"value of $/ must be String"`, + }, + { + "assignment returns the value given, not the value stored", + `$VERBOSE = nil; s = +"xyz"; p((($/ = s)).equal?(s))`, + "true", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := runSrc(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want) + } + }) + } +}