Skip to content

Ruby 4.0: $/ and $-0 stop storing the object they were handed - #886

Merged
tannevaled merged 1 commit into
mainfrom
record-separator-4-0
Nov 4, 2026
Merged

tannevaled merged 1 commit into
mainfrom
record-separator-4-0

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Ruby 4.0's deprecated_rs_setter (io.c v4.0.9:8734) stops storing the String it was handed, unless that String is already frozen and bare:

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);
}

Four of the 27 examples from the 4.0 move (#799).

⛔ $\ and $, do NOT take this setter

Init_IO hooks them to rb_deprecated_str_setter, and only $/ and $-0 to deprecated_rs_setter:

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);

rbgo handled all four through one checkStrGvar. The obvious edit — the one that does not read Init_IO — would have frozen four variables where MRI freezes two, silently. Both are now controls, and I checked them against a real MRI rather than against my reading of the C:

            MRI 4.0.7                     rbgo
ors   equal?=true frozen?=false      equal?=true frozen?=false
ofs   equal?=true frozen?=false      equal?=true frozen?=false
ret   true                           true
dash0 true                           true

⛔ The equality branch runs first

A frozen, plain "\n" satisfies "frozen and has no instance variables" word for word and is still replaced — by the canonical separator object. Every example in language/predefined_spec.rb uses "abc", which can only reach the other branch, so the suite passes either way and the identity is wrong.

Measured on ruby 4.0.7 before being written here, and proposed upstream as ruby/spec#1410 so the next implementation does not have to find it the same way.

BARE_STRING_P (string.c:372) is both halves — exact class String and no instance variables — which is exactly what makes the three existing examples distinguish each other.

A limit 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. So that example still fails, for a reason that has nothing to do with freezing.

Filed as #884 rather than fixed here — widening the assertion touches every "$x must be String" global, which is a different change and should not ride along inside this one.

Tests

Eleven cases, including the two $\/$, controls, the canonicalisation in both frozen and unfrozen form, $-0 sharing the slot, nil, the TypeError without #to_str, and the fact that assignment returns the value given rather than the value stored.

Refs #799, #884

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 6eb7e4a into main Nov 4, 2026
26 checks passed
@tannevaled
tannevaled deleted the record-separator-4-0 branch November 4, 2026 11:44
tannevaled added a commit that referenced this pull request Nov 4, 2026
… setter (#886) (#888)

Two sentences had fallen behind the code they describe.

**NDArray.** The line read "(axis, keepdims: true)", so the only documented way
to reduce along an axis was the positional one. #883 taught the reductions and
cumsum NumPy's `axis:` spelling, and cumsum was not in the sentence at all.
Verified against rbgo built at 6eb7e4a:

    a.sum                   => 15.0
    a.sum(axis: 0).to_a     => [3.0, 5.0, 7.0]
    a.sum(0).to_a           => [3.0, 5.0, 7.0]
    a.cumsum(axis: 1).to_a  => [0.0, 1.0, 3.0, 3.0, 7.0, 12.0]
    a.argmax(axis: 1).to_a  => [2.0, 2.0]
    a.sum(0, axis: 0)       => ArgumentError: axis given both positionally and
                               as a keyword

**Predefined globals.** The list claims each global gets "the setter MRI's
variable table gives it rather than one untyped slot", then groups `$;`, `$,`
and `$/` as though one setter covered them. Init_IO wires `$,` and `$\` to
rb_str_setter and `$/`/`$-0` to deprecated_rs_setter, and #886 made that split
real: `$/` now stores a frozen plain String of the bytes it was given instead
of the object itself. The README now says so, because that difference is
exactly the claim the sentence makes.

rubydiff against MRI 4.0.7, same program, five lines of output:

    AGREE   exit 0
      A rbgo-main   ruby 4.0.9 [arm64-darwin]
      B ruby@4.0    ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM

The same program under the system ruby 2.6.10 diverges on exactly the two
`$/` lines and agrees on the two `$,`/`$\` lines, which is the control: the
freezing is 4.0 behaviour, not something rbgo invented.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Nov 5, 2026
…t does (#897)

Closes #884. `language/predefined_spec.rb` goes 143 → 145, errors 6 → 4.

## How this was found

By counting, not by reading the backlog. #799's table credits #886 with 4
examples of `language/predefined_spec.rb`; running the file against current
main says otherwise:

    RBGO_RESULT pass=143 fail=16 error=6
    RBGO_DETAIL error TypeError  Predefined global $/  | makes a new frozen String from the assigned String  value of $/ must be String
    RBGO_DETAIL error TypeError  Predefined global $-0 | makes a new frozen String from the assigned String  value of $-0 must be String

#886 closed TWO of the four, not four, and said so at the time — the String
subclass example was named in its body as a known limitation and filed as #884.
The tally in the issue kept the old number, and a pass count cannot tell the
difference.

## The cause is one assertion, and it was in five places

    if _, ok := v.(*object.String); !ok { raise("TypeError", ...) }

An exact-type assertion. MRI's are RB_TYPE_P(val, T_STRING) in rb_str_setter
and rb_check_string_type in rb_fs_check, and both are TRUE for a subclass. rbgo
represents an instance of `Class.new(String)` as an RObject wrapping a String,
so the assertion missed every one of them. stringTyped is that test.

## The five globals do not do the same thing afterwards

Measured on MRI 4.0.7, assigning the same `Class.new(String).new("abc")` with
an @ivar:

| global | setter | result |
|---|---|---|
| `$/`, `$-0` | deprecated_rs_setter | a frozen BARE String — class String, no ivar |
| `$,`, `$\` | rb_str_setter | the object as handed over, unfrozen |
| `$;` | rb_fs_setter | the object as handed over, unfrozen |

A fix that froze and copied everywhere would pass the two spec examples and
silently rewrite the other three. The test table asserts each separately.

## $; was wrong differently, and worse

It did not refuse the subclass — it fell through to #to_str, which a String
subclass answers. The assignment SUCCEEDED and quietly replaced the object with
a plain String. The others at least raised.

## frozenBareString's comment had outlived its reason

It said only the ivar half of BARE_STRING_P was tested, because a subclass
"never reaches this function at all". That was true while checkStrGvar rejected
it. Now it does reach it, and without the class test the subclass instance
would be returned AS IS — ivar and all. The function takes the original value
as well as the String inside it, and tests both halves.

## Measured

rubydiff against MRI 4.0.7 on all five globals plus the non-String refusals:
AGREE.

specdelta, provenanced, over language, core/string, core/io and core/kernel:
442 files, control 9666, treatment 9668, +2, 1 file moved, 0 regressed.

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