Skip to content

Retire 2016-era code and bring the core closer to CRuby 4.0 - #119

Open
Largo wants to merge 328 commits into
masterfrom
claude/sleepy-cori-qrvpl9
Open

Largo wants to merge 328 commits into
masterfrom
claude/sleepy-cori-qrvpl9

Conversation

@Largo

@Largo Largo commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

This retires the code still unchanged since the 2016 import commit (5252433c) and brings IronRuby closer to CRuby 4.0, in five rounds. Today's master (91bb9ecb) is merged in.

2016-era lines (git blame -w, which ignores whitespace changes): 408,361 → 137,549 (−66%). The diff against master is 7,207 files, +24.5k / −280k lines.

Most of the remaining 2016 lines are working code. About 21k are upstream stdlib files identical to CRuby 4.0's, and about 7k are license headers.

Round 1: dead code

  • Unused test trees: deleted the vendored 2016 rubyspec/mspec (Tests/mspec), Tests/Legacy, Tests/Experiments, Tests/Interop and Tests/Scripts. Their tests that still check something were ported to Src/IronRuby.Tests first.
  • Old build system: deleted Build.proj, make.cmd and the nuspecs; the Makefile and Ruby.sln now drive dotnet build.
  • Dead libraries and docs: deleted ParseTree, the 2016 rb-readline (CRuby's readline gem over Reline replaces it), the Tutorial sample, and the 2007 TiddlyWiki docs.
  • Dead code paths: removed always-true FEATURE_* #ifs, dead AST nodes, and Ruby 1.8 methods.
  • Version info: assemblies carry the release version, and RUBY_REVISION is the git commit.

Round 2: 1.8/1.9-era machinery

  • Removed: taint/untrust, $SAFE, RubyCompatibility, the Thread.Abort leftovers and -save/-load.
  • Compiler fixes: rescue list evaluation order, a bare rescue calling ===, eval('super') arguments, break from forwarded blocks.
  • Library fixes: IO.popen "r+" with binary data, String#chars with U+FFFF, String#succ, const_get, Process.clock_gettime, and the Integer⊕Float out-of-range warnings.
  • RbConfig::CONFIG["ruby_version"] is now "4.0.0".

Round 3: CRuby 4.0 conformance

  • Stdlib: synced to CRuby 4.0.9 (RubyGems/Bundler 4.0.20), using Util/sync-stdlib.rb.
  • Core:
    • CRuby's encoding table;
    • a BigDecimal engine on BigInteger;
    • the platform's errno numbers and texts;
    • object-model fixes (Class.new(Class), 16-digit object ids, method lists hiding CLR members and helpers);
    • Comparable/Enumerable → IComparable/IEnumerable.
  • Error messages: CRuby 4.0 wording.
  • Prelude: about 725 dead lines removed.

Round 4: follow-ups

  • System::Int64/BigInteger name their CLR types again.
  • Library methods:
    • keyword-parameter arity errors match CRuby ([Keywords]);
    • called names through aliases ([CalledName], [SameRubyMethod]);
    • no sticky keyword-hash mark ([ReceivesKeywords]).
  • Encodings: regexp character offsets on Shift_JIS/EUC-JP, UTF-16/32 padding, converters.
  • New API: IO#timeout, Thread#set_trace_func, ObjectSpace.count_objects, GC.stat_heap, did_you_mean (lazy), Enumerator::Producer, Process::Waiter.
  • Ractor: the Ruby 4.0 API on CLR threads.
  • Startup: the precompiled startup library builds again (ir -e 1: 0.65 s → 0.49 s on the R2R host). A new DLR patch, 0103, keeps backtraces when rethrowing.
  • Platform: open/fcntl flags, File.lchmod, UDP after ICMP errors, connect timeouts per CRuby Bug #22223, regenerated AOT subclasses.
  • Regressions from rounds 1–3, found by a full ruby/spec run, are fixed.

Round 5: conformance and performance

  • Performance:
    • the keyword-mark checks cost nothing measurable;
    • const_get/const_defined? are back to their pre-round-1 speed;
    • the Util/bench geomean over 63 benchmarks is 0.984× the start of round 5, where pre-round-1 is 1.038×.
  • Backtraces:
    • correct lines in compiled (hot) code;
    • re-raise frames;
    • library frames and labels follow CRuby's rule (a probe of ~210 raising snippets went from 104 differences to 39).
  • Error reports: did_you_mean suggests locals (NameError#local_variables); error_highlight is on by default; SyntaxError reports read "syntax errors found" with prism's annotated snippet, and syntax_suggest is on. All byte-identical to CRuby on the cases checked.
  • Ractor isolation: globals, class variables, class-level ivars, constants with unshareable values, ractor-local $stdout/$VERBOSE, isolated procs, require in main. 146 of 153 checks in CRuby's bootstraptest/test_ractor.rb pass; the checks cost nothing until a Ractor starts.
  • Method#arity/#parameters: match CRuby for all but 46 of 2,111 core methods (41 of them Set), via an InternalMethods.Generated.cs signature table.
  • Fiber: resume/transfer keywords, CRuby's Fiber errors and storage, IO#write_nonblock with non-Strings, and a RubyBufferedStream offset bug.
  • .NET interop: overloads are chosen by a Ruby Array's items (String.Join(",", [1, 2]), LINQ Sum), bignums prefer double, Nullable[T].new works, BigInteger-only members and snake_case CLR names work on Integers.
  • Hidden state: about 55 prelude constants hidden (Module#__hide_constant__), internal ivars of Set, Random, Enumerator and others hidden (Marshal stays byte-compatible), autoloads for core classes answer nil, and Enumerator names are fixed.
  • ruby/spec fixes: mspec's fixnum_max helper now covers IronRuby (default.mspec), a Net::HTTP hang (IO#read after gets on a socket) is fixed, and Thread#status at thread start is fixed.

Merge with master (91bb9ecb)

  • master's C# Rational/Complex are kept, with this branch's conventions on top: hidden helpers, the Numeric owner for dup/clone, "to" wording, and [Keywords] for exception:.
  • Integer()/Float() stay this branch's C# methods.
  • BasicObject#equal? keeps master's identity method while staying one method with #== to Ruby.
  • 1.step(by:, to:) keywords and the Range#first arity were fixed after the merge.
  • ruby/spec slices show no regressions against either parent (core/rational, complex, integer, range, numeric, kernel conversions, basicobject, language).

Testing (final tree d15f678b)

  • IronRuby.Tests on net10.0: PASSED, 0 failures. net8.0 and net10.0 build with no compiler warnings.
  • Prism sweeps: 847/847, 491/491, 314/314, 0 failed. The generated files come out unchanged when regenerated.
  • CRuby-diff scripts (encodings, BigDecimal, Zlib, error messages, keyword hashes, hot-code backtraces, did_you_mean/error_highlight/SyntaxError, Rational/Complex): byte-identical output.
  • ruby/spec at dda28d04/b79c65ed, LANG=C.UTF-8 (README Status):
suite examples failing
language 2935 1 (wording, below)
command_line 175 0
security 34 0
library 6011 0
core 23130 44: 20 ObjectSpace.each_object (needs -X:ObjectSpace), 24 wording

Decision for the reviewer

"can't convert X to Y (X#m gives Z)" matches CRuby 4.0 exactly, which the README names as the reference. The pinned ruby/spec expects the 4.1 wording "into" from engines other than CRuby < 4.1; CRuby 4.0.9 fails the same examples. CI doesn't gate on the spec run. Reverting means changing one format string in RubyExceptions.CreateReturnTypeError, one prelude line, and the test expectations that pinned the old wording.

Not verified here

  • Windows and macOS: the errno tables and the macOS/FreeBSD/arm/powerpc open/fcntl values come from the platform headers, untested on those systems.

Known follow-ups

  • Line tracking: correct lines in compiled code add 4–10% on pure call microbenchmarks (super_call, lambda_call, yield). Cheaper variants would lose other threads' Thread#backtrace frames or the fiber-reclamation guarantees.
  • Ractor: moved Strings/Arrays/Hashes passed to built-ins aren't rejected.
  • Thread#raise and fibers: a Thread#raise arriving just as a thread resumes a fiber can land in the wrong one (intermittent).
  • Identity on large numbers: (2**64).equal?(2**64) is true (from master's value-type identity rule); CRuby says false.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM

claude added 30 commits November 6, 2026 02:34
- drop the always-true FEATURE_ENCODING branches (the UTF-8-only fallbacks)
- ConcurrentDictionary.GetOrAdd replaces the lazily created, locked Dictionary
- AsciiIdentity no longer keeps a 90-entry code page switch that only
  short-circuited the probe it Debug.Asserted against; the probe runs once per
  encoding and its result is cached on the RubyEncoding
- remove unused ToRegexOption and RequireAsciiIdentity, fold GetCodePage(int)
  into GetEncodingByNameInitial and GetCodePage(Encoding) into CodePage
- static readonly tables instead of lazily built ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
- The InsertAt overloads that encode a string, char[] or char into a byte[]
  had no callers, and wrote the encoded bytes at itemCount instead of index.
- ConvertAll had no callers (the BCL has Array.ConvertAll).
- Append of a string slice encodes the span instead of copying it to a
  char[] first; IsBinary is one vectorized search.
- The five ValueCompareTo copies share one span comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
RubyOptions.Compatibility always answered Default (= Ruby19), so every check
against it was constant. Drop the enum, the option and the attribute-encoding
shift, keeping the Ruby19 side of the remaining branches: binding uses the
scope's self, Open3.popen3 returns the wait thread, and the Thread.critical
release on thread exit (1.8 only) goes.

Thread.Abort: Thread#raise/#kill are cooperative (Thread.Abort throws on .NET
Core). Remove the debug -useThreadAbortForSyncRaise switch, the
ThreadAbortException handler in RubyCommandLine, and the dead block and
comments in ThreadOps that described the old scheme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Its only callers were the safe-level checks removed with $SAFE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
The fifteen identical BindDelegate bodies (interpreted rule unless
adaptive compilation is off, cached on the binder) now call one
LightBind helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Fourteen copies of lazy-create-dictionary, lock, TryGetValue, Add become
GetOrAdd with static factories, so finding an existing binder no longer
takes a lock. RubyCallSignature, part of most keys, gets a GetHashCode
and Equals(object) so hashing it no longer boxes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…rror

A method's keywords (or **nil) are lowered onto a trailing optional
parameter, so the binder's arity check reported the lowered bounds:
`def f(a, k:); f` said "given 0, expected 1..2" where MRI says
"given 0, expected 1; required keyword: k". ArgsBuilder now takes the
declared signature for such a method, words the error from it, and does
not count a trailing keyword hash as given. The wording moves to
RubyParameterSignature.DescribeExpectedArguments, which the lambda arity
check in RubyOps shares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…esolver fills in

The context, scope, call-site storage and constructor-class builders were
four copies of one class differing only in the expression they return;
they are now one builder given that expression. Also: the argument
assumptions are value tuples rather than an allocated Key per entry, a
splatted item reads through the cached IList indexer MethodInfo instead of
looking it up by name each time, and an else-if that could only be true
is an else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…er call

When a hosted scope has no variable of the name, main's method_missing
calls the next method_missing with the name inserted. InsertMethodName
already adds it to the call signature, and the signature then added it
again, so the callee saw one argument more than it got: a define_method
method_missing raised "wrong number of arguments (given 3, expected 2)"
for `foo 2`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…' check

-C is handled further up, as -Cdir and -C dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…ingletons

Static field initializers run in textual order, and the singletons' constructors
call AsciiIdentity, which encodes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…ncoding

DuplexStream.Write turned the bytes into a string with the StreamWriter's
encoding, so a write of bytes not valid UTF-8 ("\xff".b) raised
System.Text.DecoderFallbackException and others were silently altered, and
ReadByte returned a decoded char instead of a byte. Both now go to the pipe
streams under the reader and writer. Seek/Length/Position throw
NotSupportedException (the Stream contract) instead of NotImplementedException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Splat, SplatAppend, SplatPair (1.8 multiple-assignment), Create*IntegerRange,
ConvertRubySymbolToClrString (1.8 Integer-as-Symbol), ConvertSymbolToMutableString,
GetEmptyScope, MakeAbstractMethodCalledError and MakeInvalidArgumentTypesError
had no caller left: nothing emits them since the prism front end replaced the
old AST transformations. ReflectionCache.Generated.cs regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…Exception

RecursionTracker used a Dictionary<object, bool> as a set; it is now a
HashSet with the BCL's ReferenceEqualityComparer, and TrackObject is a single
Add. HasPendingAsyncException had no caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…dead paths

- -save never saved anything on .NET Core (AssemblyGen.SaveAssembly is a no-op
  without FEATURE_ASSEMBLYBUILDER_SAVE) yet kept every compiled file alive and
  recompiled them all for saving at shutdown; -load looked for an assembly
  named after the main script that could not exist. The loader keeps only the
  precompiled-file table the startup library and Util/aot register. SavePath
  stays as the hosting option Util/aot sets to compile without runtime-bound
  sites.
- $LOAD_PATH no longer gets ".": MRI dropped it in 1.9.2 and ruby4.rb already
  deleted it again for the console host, but a hosted runtime kept it.
- load_assembly's Assembly.LoadWithPartialName retry: on .NET Core that API is
  Assembly.Load again, so it only replaced the real load error.
- GetPathsToTestLoaded added the path twice for a fullPath nobody passed;
  InsertLoadPaths (unused) inserted in reverse and ignored its index;
  GetLoadPaths locked $" instead of $:; CAS-era SecurityException handlers;
  WeakReference<Loader> for the AssemblyResolve holder.
- RubyContext: library data on a ConcurrentDictionary instead of a hand-locked
  Dictionary (TryAddLibraryData had no other caller); EqualityComparer is set
  in the constructor, so its lazy getter was dead; unused
  DropClrInstanceSingleton; StringifyIdentifier(s) go through EncodeIdentifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
CRuby 4.0 says "to" in the message for a conversion method that returned the
wrong type ("into" is only for the explicit Integer()/Float() failure). Also
drop the DecoderFallbackException overload of CreateTranscodingError, which
had no callers and threw instead of returning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
CompileDebug only called lambda.Compile() since the PDB-emit path went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Thread#raise no longer wraps the raised exception in a ThreadAbortException
(.NET Core has no Thread.Abort; the exception is parked and thrown as itself),
so the "visible" and the real exception are always the same object, and the
DEBUG-only _throwingThread was never read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
DetectFileAccessPermissions answered true unconditionally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…hape.LastName/ShapeCount

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
No "python" language can ever be configured for the suite: IronPython is
not referenced or deployed, the test host ships no app.config, and the
snippets are Python 2 (print statements) that IronPython 3 would reject.
RequireInterop1/2, Backtrace5 and the Python halves of the delegate
conversion tests could only ever return early. Drop them with
Driver.RunPython and /py[-].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
….ProcessId

MRI dropped $defout in 1.9 and never had $-O (the -0 switch sets $/ and $-0):
neither is in its global_variables until assigned, and both read as nil.
$$ is read from Environment.ProcessId, without a Process object and the
CAS-era SecurityException guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
- ClrExtensionMethods5 (#if TODO): LINQ extension methods on a Ruby class
  that includes IEnumerable[T] are by design not offered (see
  RubyModule.IsScopedExtensionPlace: a Ruby class has no extension-method
  place of its own); its expected output was not even what LINQ gives.
- Dlr_Languages and OverloadResolution_ParamArrays1 had empty bodies, and
  the SuperParameterlessEval1/2 registrations named tests that do not exist.
- EqualityComparer1 was never registered; CRuby 4.0 agrees with it.
- ClrMethods2 expected empty output, so it passed whatever new.m did. It
  now pins the hide/show/hide sequence ClrOverloadInheritance4 relies on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…nd(half:), quo(x, n), new/allocate, frozen

- #** and #power are now the gem's own Ruby (lib/bigdecimal.rb, verbatim), so they
  take a BigDecimal/Float/Rational exponent and a precision; the C# Power (int only)
  is gone. BigMath.log/exp are the gem's as well, replacing the 1.9 series.
- A Rational operand of ==/<=>/</<=/>/>=, #coerce, #add/#sub/#mult/#div/#divmod/
  #remainder is expanded as + already did. BigDecimal("0.5") == Rational(1, 2)
  overflowed the stack (BigDecimal#== and Rational#== handed it back and forth).
- round(n, half: :even), quo(value, digits); the defunct #precs is removed.
- BigDecimal.new is undefined and allocate raises as in MRI; Kernel#BigDecimal (in
  bigdecimal.rb) was already the real constructor, so the C# Kernel#BigDecimal it
  shadowed is deleted. A BigDecimal reports frozen? true.
- Messages: sqrt of negative value/NaN, Negative precision for sqrt, Computation
  results in 'Infinity', X can't be coerced into BigDecimal. BASE is an Integer, and
  _dump writes MRI's word-aligned digit count ("18:0.15e1").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…or#initialize lives

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
claude added 30 commits November 7, 2026 01:49
…terns

Review finding (NOTE): on a UTF-16/32 string, sub/gsub with a String replacement raised
IndexOutOfRangeException - AppendReplacementExpression looped to replacement.Length, which
counts bytes while the content is binary, but read it with GetChar, which indexes characters
(review/bi2/g3.rb, re4.rb). The replacement is now read as characters throughout.

And `"a\r\nb".encode("UTF-16LE").gsub("\r\n".encode("UTF-16LE"), "\n".encode("UTF-16LE"))`
returned the string unchanged (review/bi2/g2.rb), as did any String pattern holding a
character Regexp.escape escapes ("." or " "): escaping binary content went byte by byte, so a
UTF-16 "." became the bytes "\\.\0", which matched nothing. A UTF-16/32 string that decodes is
now escaped by character. Both reproduced against CRuby 4.0.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE): RubyOptionsParser.IsRuntimeEncodingName let -Eexternal and
-Efilesystem (and --external-encoding=filesystem) through; MRI accepts only "locale" besides
real encoding names there and stops with "unknown encoding name - external (RuntimeError)".
Reproduced with `ruby -Eexternal -e 'p 1'` (CRuby 4.0.9) against `ir.sh -Eexternal -e 'p 1'`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (SHOULD): under LANG=C (US-ASCII, Docker's default) a script in
a non-ASCII directory failed. Reproduced with LANG=C LC_ALL=C in a temp dir
named café: require_relative raised "invalid byte sequence in US-ASCII",
the error report printed ".to_s raised an exception: Unable to translate
Unicode character é", caller_locations(0)[0].path was an invalid US-ASCII
string, and load/require of the BINARY path Dir.children returns failed.
CRuby 4.0.9 runs it all.

- RubyStackTraceBuilder: a frame with non-ASCII text is its UTF-8 bytes in a
  BINARY string under a US-ASCII path encoding (TryEncodeRawAsciiPath, as
  EncodePath does and as CRuby's backtrace strings are).
- Loader: LoadFile, the load path and autoload paths use DecodePath instead of
  ConvertToString, so a BINARY path is read as UTF-8 bytes; a non-ASCII
  feature loaded through a BINARY/US-ASCII path is recorded in the filesystem
  encoding rather than one byte per character.
- File.expand_path: a BINARY base directory under a non-BINARY path (what
  require_relative passes) is decoded as the file system's UTF-8 bytes, not
  byte-per-character ("café/lib").

__FILE__ stays a UTF-8 literal (valid, and every path API takes it); CRuby
makes it a US-ASCII string with invalid bytes.

Test: Backtrace_AsciiLocalePaths1 (sets Encoding.default_external to US-ASCII;
raises without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (BLOCKER, PR 119): RubyContext.IsVisibleInstanceVariableName hid
every instance variable spelled @__name__, so a program's @__cache__ (gems use
@__memoized__, @__Lock__) was missing from #instance_variables and #inspect,
dropped by Marshal, and not frozen by Ractor.make_shareable. Reproduced with
review/runtime/iv.rb and iv2.rb against CRuby 4.0.9: IronRuby printed [:@A],
nil after a Marshal round trip, and false for make_shareable's frozen?.

The core's state now lives under names Ruby cannot spell instead: the Prism
bridge compiles an @__name__ ivar in the Ruby-written core (IsInternalFile,
plus coverage.rb, json.rb and io/console.rb, which stand in for C extensions)
as <ir>@__name__ (RubyUtils.GetCoreStateInstanceVariableName); the C# readers
and writers (Enumerator, KeyError, SignalException, IO, Struct, Marshal's Set
table, ...) use that name, and the prelude's reflective accesses go through
__ir_hidden_ivar__/__ir_set_hidden_ivar__. Only angle-bracketed names are
hidden now, and since GetStateInstanceVariableNames includes <ir> names, a
deep freeze or Ractor copy reaches a Set's elements as MRI's traversal does.

Test: Prelude_HiddenState2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE, BigDecimal#to_s quadratic): (BigDecimal("1e1000000")+1)
.to_s("F") took 24.7 s (CRuby ~0 s), all of it in BigInteger.ToString,
which is quadratic in the digit count. Reproduced with review/libs/bd4.rb /
bd5.rb and a timing script.

#to_s and #split now go through ToDecimalString, which splits a value of
more than 2000 digits at 10**(2000 * 2**k) and writes the halves into one
char buffer, zero-padding the low ones. It is as fast as the runtime's
division: the repro takes 0.7 s on .NET 10 (Integer#to_s of the same value:
29 s) and 8.8 s on .NET 8, whose BigInteger division is itself quadratic.
Output checked byte for byte against CRuby for 33 random values of 1999 to
123457 digits with long zero runs at the split points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…ds n

Review finding (BLOCKER, concurrency/IO): IO#timeout hung forever when the
IO's own read buffer already held bytes. The wait that bounds a read
(ApplyTimeout -> WaitForEvents -> IsReady) answered "ready" on
stream.DataBuffered, so the read of the stream underneath - a pipe(2)
DescriptorStream or a socket, which have no timeout of their own - then
blocked with no bound. Reproduced with review/conc/io2.rb, io3.rb, io5.rb:
`r.timeout = 0.5; w << "partial"; r.gets` hung on IO.pipe and on
UNIXSocket.pair (CRuby 4.0.9 raises IO::TimeoutError after 0.5 s), and so
did a gets after an earlier gets had left bytes buffered.

The wait behind IO#timeout now ignores the IO's own buffer and asks the
stream underneath (IsReady/ReadyEvents/WaitForEvents take ignoreBuffer);
IO.select, IO#wait and friends still count buffered bytes as ready.

Checking the reads that need more than is buffered showed IO#read(n) made
one read of the stream and returned what it brought: `w << "ab"` then
r.read(5) answered "ab" at once instead of waiting for the other three
bytes (CRuby answers "abcde" when they come, IO::TimeoutError under a
timeout - io3.rb's last case). IO#read(n) now reads until it has n bytes
or the stream ends; #sysread stays a single read.

Regression test: Io_Timeout3. ruby/spec core/io and library/socket
unchanged (core/io: the 2 errors there are fork and a message wording).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE): _hiddenConstants was a plain HashSet that
HideConstant mutated under the class hierarchy lock while IsHiddenConstant
(const_get, const_defined?, constants, remove_const via ModuleOps) read it
without the lock - a reader racing a writer could see the set mid-resize.
Found by reading (the prelude hides its constants at startup, so it is not
reproducible on demand).

It is now replaced rather than changed, like RubyContext._hiddenConstantNames:
written with Volatile.Write under the lock, read with Volatile.Read without it.
Covered by the existing hidden-constant tests (no new behavior).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE): RubyErrno used the Darwin column on FreeBSD, whose
numbers part from macOS's above 81: ECANCELED is 85 there (89 on macOS),
EILSEQ 86, EBADMSG 89, EOVERFLOW 84, EIDRM 82, ENOTSUP and EOPNOTSUPP both 45,
and EDOOFUS 88, ENOTCAPABLE 93 and ECAPMODE 94 exist only there. So
Errno::ECANCELED::Errno was wrong, and an errno 85 from the OS raised
Errno::EBADEXEC. Found by reading against FreeBSD's sys/errno.h.

A known column is the safer choice: .NET has no way to ask the platform for an
errno's number by name, so "unknown" would leave every Errno class NOERROR.
The column is sys/errno.h of FreeBSD 13/14 (ELAST 97); messages still come
from strerror. macOS, Linux and Windows are unchanged. Not testable here (no
FreeBSD); the Linux columns are covered by the existing Errno tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (SHOULD): Socket.getaddrinfo("no-such.invalid", 80) and
Addrinfo.getaddrinfo raised "getaddrinfo: getaddrinfo: Name or service not
known" (CRuby 4.0.9: one prefix) - socket.rb prefixed a message the resolver
(BasicSocket.cs ResolutionFailure) had already prefixed. And
Socket.getaddrinfo("localhost", "nosuchservice") was a plain SocketError where
CRuby raises Socket::ResolutionError with EAI_SERVICE. Reproduced side by side
with ruby and ir (scratch fx3/sock.rb).

The resolver's message is passed on as it is, and an unknown service name is
Socket::ResolutionError (EAI_SERVICE) - which TCPServer.new's service argument
also gets, as in CRuby. Addrinfo.getaddrinfo, .tcp and .udp now read the
service the same way: an unknown name raised nothing there (port 0), and
Addrinfo.tcp("localhost", "http") had port 0 instead of 80. Regression test
Errno_SocketResolution1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE, Directory.Build.props IronRubySourceRevision): the
target ran `git rev-parse HEAD` and took Exec's ConsoleOutput, which holds
stderr as well as stdout, so anything git wrote there on success joined the
revision; and a source tree unpacked inside another git repository stamped
that repository's HEAD. Reproduced with a probe project importing the props:
with GIT_TRACE=1 the old target set SourceRevisionId to
"01:53:12.204290 git.c:463 trace: built-in: git rev-parse HEAD;5785c0c2...",
and a copy of the tree in a subdirectory of a checkout took the outer HEAD.

Now git's stderr goes to the null device, HEAD is read with
`git rev-parse --verify -q HEAD` and must look like an object name, and it
is used only when `git rev-parse --show-prefix` is empty, i.e. this
directory is the top of its work tree. (--show-prefix rather than comparing
--show-toplevel with the directory: no path normalisation, and a checkout
reached through a symlink still matches.) Outside a checkout, or nested in
another one, RUBY_REVISION stays "". Both probes give the expected result
now, and a normal build still reports 4.0.3+<sha>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review note: IronRuby's Random::Formatter (random/formatter.rb, which stands
in for random.c) answered SecureRandom.random_number(1..) and (..1) with a
Float in [0, 1); CRuby 4.0.9 raises Errno::EDOM for a range without a begin or
an end (checked with ruby for 1.., ..1, 1.0.., nil..nil, "a"..). Backwards
ranges still fall back to a Float and non-numeric ones are still
ArgumentError, as in CRuby. Regression test Random_FormatterRanges1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (SHOULD, concurrency): Ractor.store_if_absent yielded its
block while holding lock(_locals), the lock every Ractor[] and Ractor[]=
takes, so a block that waits on another thread using them deadlocked.
Reproduced with review/conc/r8.rb:
`Ractor.store_if_absent(:x) { Thread.new { Ractor[:y] = 1 }.value }` hung
(CRuby 4.0.9 prints 1).

The block now runs under a separate per-Ractor store lock (CRuby's
local_storage_store_lock), which keeps two store_if_absent calls atomic
with each other; _locals is held only for the lookup and the insert.

Regression test: Ractor_Locals2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review note: IronRuby's patch to uri/rfc3986_parser.rb (its \g subexpression
calls written out for .NET regexes) built the host pattern from three new
public constants, so URI::RFC3986_Parser.constants listed DEC_OCTET,
IPV4ADDRESS and LS32, which CRuby 4.0.9 does not have (compared with
`ruby -ruri -e 'p URI::RFC3986_Parser.constants.sort'`). They are removed once
HOST is built; nothing else uses them. VERSION.txt's entry for the patch says
so (uri/rfc3986_parser.rb and uri/mailto.rb are listed there, under "Patched
in place"). Regression test URI_PatchedParserConstants1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE, AstGenerator.NoteLocalAssignment / BindingParameters):
while the parameters were bound no assignment was counted, so one made in
an optional parameter's default value went uncounted too. Reproduced with
review/calls/g2.rb against CRuby 4.0.9:
  def m(a, b = (a = 3)) = Ractor.make_shareable(nil.instance_eval { proc { a } }).call
  m(1)   # IronRuby 3; CRuby: Ractor::IsolationError ... 'a' may be reassigned.

Parameters.TransformOptionalsInitialization now transforms each default
value with BindingParameters off and only the write of the parameter with
it on, so only the implicit binding is left uncounted. Checked against
CRuby for defaults that assign an earlier parameter, another optional, a
new local, from a nested block, from a keyword default, and in block and
lambda parameters (all now the same).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…alse

Review note (ruby4.rb): Marshal.dump(ObjectSpace::WeakKeyMap.new) succeeded,
writing the Ruby stand-in's table; CRuby 4.0.9 raises TypeError "no _dump_data
is defined for class ObjectSpace::WeakKeyMap". It now raises the same, the way
ruby4.rb's Fiber already does. And `"a\nb".each_line(chomp: false).inspect`
left out `(chomp: false)` (CRuby: #<Enumerator: "a\nb":each_line(chomp:
false)>): the enumerator now carries chomp: whenever it was given. Both
compared side by side with ruby. Regression test
Ruby4_WeakKeyMapMarshalEachLine1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…ast Class#new

Review finding (PR 119, SHOULD, performance regression from 6848da6): every Ruby
class that includes Comparable or Enumerable gets a CLR type implementing
IComparable/IEnumerable, and such classes lost the base cost of Class#new.
Reproduced with review/calls/j2.rb, j3.rb, j4.rb (Release, net10.0): 500
`Class.new { include Comparable }.new` took 9.0 s (Kernel: 0.46 s), batches of
200 Comparable classes 1.3-2.4 s and growing (plain: 0.04 s).

Two causes:
- TypeDescription.FeatureSetsMatch compared the feature lists' hash codes, which
  for the arrays RubyTypeDispenser.GetOrCreateType builds are their identities,
  so the type cache never matched a class that implements interfaces and a new
  type was emitted (Reflection.Emit, several ms) for every such class - the type
  is now shared by all classes with the same base and interfaces.
- RubyClass.TryGetObjectConstruction gave up on any type but Object/RubyObject,
  so the first new of each such class built and compiled a rule. It now takes a
  type generated for Ruby classes that derives from Object, whose instances
  ObjectConstruction allocates through a cached delegate calling its (RubyClass)
  constructor (RubyTypeDispenser.TryGetObjectAllocator) - what the rule does.

After: j2 Comparable class+new x500 0.14-0.36 s (Kernel 0.13-0.20 s), j4
batches 0.02-0.08 s (plain 0.03-0.09 s), j3 first new of 20 classes 0.004 s
(was 0.146 s). Instances are still IComparable/IEnumerable to CLR code.
Test: ClrRubyImpliedInterfaces3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (SHOULD, PR 119): Marshal.cs WriteSetObject wrote a Set's
extra instance variables inside its 'o' record next to @hash; CRuby 4.0 writes
`I o:Set 1 :@hash{...} 1 :@x ...` and, loading IronRuby's form, drops @x.
Reproduced with review/builtins/h.rb (dump with each, load the other's): the
setiv entry was not byte-identical and CRuby lost @x.

The record of a Set now holds only its table, and the set's ivars follow in
the I wrapper like any non-'o' object's; loading an 'o' record of a Set keeps
@hash and drops anything else, as CRuby does. h.rb's setiv now matches both ways.

Test: Marshal_SetIVars1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE, TracePoint.AddTraceFunc sets _targetThread after
Enable). Not reproducible as described: Enable() does not clear
_targetThread (only Disable does), and the trace-func constructor sets it
before Enable publishes the hook under the lock, so the hook was never
visible unlimited. The assignment after Enable was redundant, and it is what
made the code read as a race (it could also re-set the thread of a hook
another thread had just disabled). It is gone; the hook is enabled straight
from the constructor that limits it to its thread.

ruby/spec core/tracepoint, Kernel#set_trace_func and Thread#set_trace_func/
add_trace_func, and CoreApiTests' set_trace_func test pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (SHOULD, concurrency): Thread.main and Thread.list were not
Ractor-scoped. Reproduced with review/conc/r4.rb and r5.rb: inside
Ractor.new, Thread.main == Thread.current was false (CRuby 4.0.9: true)
and Thread.list.size was 3 with a thread alive in the main Ractor
(CRuby: 1).

Thread.main now answers the Ractor's main thread inside a Ractor, and
Thread.list, ThreadGroup#list and Thread.stop's "stopping only thread"
check count only the current Ractor's threads (MRI's
rb_ractor_living_threads of the current Ractor).

Regression test: Ractor_Threads1. ruby/spec core/thread and
core/threadgroup: 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…opying

Review finding (NOTE, concurrency): Shareability.IsShareable, the
make_shareable walk (Visit) and Copier.Copy recursed once per level of
nesting with no stack check, so a graph nested deeper than the thread's
stack ended the process. Reproduced with review/conc/r7.rb: an array
nested 20,000 or 100,000 deep killed ir in Ractor#send /
Ractor.make_shareable (CRuby 4.0.9 copies 20,000 levels and raises
SystemStackError at 100,000).

Ractor.shareable? and Ractor.make_shareable now walk the graph with a
stack of their own (depth first, in the order the recursion took), so
they work at any depth. The message copy, which runs #initialize_copy
and so stays a recursion on the Ruby thread, checks
TryEnsureSufficientExecutionStack and raises SystemStackError when the
stack is about to run out. It copies 15,000 levels on the main thread
and on Ruby threads (CRuby: 20,000 on the main thread, under 10,000 on
other threads); 20,000 raises SystemStackError where CRuby still copies.

Regression test: Ractor_DeepGraph1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
Review finding (NOTE, concurrency): RubyIO.CreateSharedView, which gives a
Ractor its own $stdin/$stdout/$stderr over the main Ractor's buffered
stream, shared the stream's TimeoutOwner too, so IO#timeout set on one IO
bounded the reads and writes of the other. Reproduced: with stdin a pipe
that writes after 6 s, `$stdin.timeout = 0.1` in the main Ractor made
`Ractor.new { $stdin.read(1) }` raise IO::TimeoutError (CRuby 4.0.9
waits and reads "x"); and Io_Timeout4 fails with IO::TimeoutError
without this change.

An IO that shares its stream now binds the stream to its own timeout
(or to none) whenever it takes the stream for an operation.

Regression test: Io_Timeout4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
… round trips

Review note (PR 119): loading an exception CRuby dumped left :bt_locations
as a visible instance variable (Marshal.cs CreateException), and a NoMethodError
CRuby dumped did not load at all (its message is a NameError::message 'u'
record), nor did NameError's name / NoMethodError's args and private_call?
come back - they became ivars named "name", "args". Reproduced by dumping
raised and constructed NameError/NoMethodError/KeyError/StopIteration with
CRuby 4.0.9 and loading them in IronRuby (review/builtins/h.rb and a variant).

- "bt_locations" is dropped and a loaded exception answers nil for
  #backtrace_locations, as CRuby's does.
- A NameError::message record loads as its string (MRI's name_err_mesg_load).
- name, args and private_call? are read into, and written from, the error's
  own state, in CRuby's order: NameError.new('x', :foo) and NoMethodError.new
  dump byte-identical to CRuby.
- The prelude's @__backtrace_locations/@__backtrace_from_strings become
  @__..__ core state, so #backtrace_locations no longer leaves a visible ivar
  and Marshal no longer has to filter them.

Test: Marshal_NameError1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
…g in

The Windows test-suite step of PR 119 ran for 90 minutes until a newer push
cancelled it; its results.txt only held progress dots, so nothing said which
test hung. The run now has a 25-minute timeout (the suite takes 2-4 minutes)
and prints each test's name as it starts (/verbose), so the "did not finish"
branch's tail of results.txt names the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPwZf4gAJD4kdk9eEzGgYM
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.

2 participants