From 66351e8ab8684ffe0727a9051f36a2d0459af378 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:17:13 +0000 Subject: [PATCH 1/7] Broken String patterns as MRI treats them; Thread.stop alone is a ThreadError Two gaps left open after #103. String patterns whose bytes are not valid in their encoding - #sub, #sub!, #gsub, #gsub! and #scan given such a String now raise the RegexpError Regexp.new does: "invalid multibyte character: /\xFF/". That is string.c's get_pat_quoted. They used to raise "ArgumentError: invalid byte sequence in UTF-8", or, with a valid receiver, match nothing at all. The check lives in RubyRegex.FromLiteralPattern, used by the String-to-Regexp conversion behind these five methods (and only them) and by the two block overloads. Those overloads built their regexp with .NET's Regex.Escape (the "TODO:" there). - #index, #rindex, #byteindex, #byterindex, #[], #slice, #slice!, #[]=, #partition and #rpartition now find nothing for such a needle, as rb_strseq_index and rb_str_rindex give up on a broken one. IronRuby found the bytes. #include? already did this. Thread.stop - Thread.stop with no other thread alive raises MRI's ThreadError "stopping only thread\n\tnote: use sleep to stop forever" (rb_thread_stop). It used to sleep forever. Resolves the "TODO: MRI throws an exception if you try to stop the main thread". - A fiber runs on a CLR thread of its own here, but belongs to the thread that owns it. Thread.list and ThreadGroup#list listed that CLR thread too, while the fiber ran and while it was suspended: 2 threads where MRI has 1. They no longer do (FiberCore.Owner). - Two runtimes in one process are two VMs. Thread.list, ThreadGroup#list and the Thread.stop check now leave out the threads another runtime's Thread.new made (RubyThreadInfo.Context). Without this the test hung in IronRuby.Tests, where earlier tests leave threads of their own runtimes behind. Testing - New IronRuby.Tests cases String_BrokenPattern1 and Thread_StopAlone1, registered in RubyTests.cs. Their expected output is CRuby 3.3's, verbatim. CRuby 4.0's string.c and thread.c were checked for the same rules. - IronRuby.Tests (Debug, net8.0): PASSED, 0 FAILED. - ruby/spec at the CI pin: core/string (3963 examples: 0 failures; 2 errors from mspec's fixnum_max helper, which does not know this engine), core/regexp (256), core/thread (395), core/threadgroup (8), core/fiber (188), core/symbol (270), core/matchdata (185), library/stringscanner (240): 0 failures. - Initializers.Generated.cs regenerated with regen-initializers.sh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/RubyTests.cs | 2 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 120 +++++++++++++++++++++ Src/Libraries/Builtins/MutableStringOps.cs | 30 ++++-- Src/Libraries/Builtins/RubyRegexOps.cs | 18 +--- Src/Libraries/Builtins/ThreadGroup.cs | 4 +- Src/Libraries/Builtins/ThreadOps.Fiber.cs | 5 + Src/Libraries/Builtins/ThreadOps.cs | 42 +++++++- Src/Libraries/Initializers.Generated.cs | 6 +- Src/Ruby/Builtins/RubyRegex.cs | 33 ++++++ Src/Ruby/Runtime/RubyOps.cs | 2 +- 10 files changed, 230 insertions(+), 32 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 489494483..4326203eb 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -857,6 +857,8 @@ public Tests(Driver/*!*/ driver) { File_StatPredicatesToIo2, IO_SysOpenArguments1, IO_SysOpenPermission1, + String_BrokenPattern1, + Thread_StopAlone1, Range_IntegerFastPaths1, Method_LightFrame1, Method_LightFrame2, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index f81f8ce36..3f609af63 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2808,6 +2808,126 @@ public void IO_SysOpenPermission1() { "); } + /// + /// A String pattern whose bytes are not valid in its encoding, as MRI treats it: #sub, #gsub, their + /// bang forms and #scan raise Regexp.new's RegexpError (string.c's get_pat_quoted), where IronRuby said + /// ArgumentError or, with a valid receiver, matched nothing; the searches - #index, #rindex, #byteindex, + /// #byterindex, #[], #slice, #slice!, #[]=, #partition, #rpartition - find nothing (rb_strseq_index), + /// where IronRuby found the bytes. + /// + public void String_BrokenPattern1() { + TestOutput(@" +require 'ruby4' +def show(l); v = begin; yield; rescue Exception => e; ""#{e.class}: #{e.message}""; end; puts ""#{l}: #{v.inspect}""; end +s = ""a\xffb\xffc""; pat = ""\xff"" +show(""Regexp.new"") { Regexp.new(pat) } +show(""sub"") { s.sub(pat, ""Z"") } +show(""sub!"") { s.dup.sub!(pat, ""Z"") } +show(""sub blk"") { s.sub(pat) { ""Z"" } } +show(""gsub"") { s.gsub(pat, ""Z"") } +show(""gsub!"") { s.dup.gsub!(pat) { ""Z"" } } +show(""gsub enum"") { s.gsub(pat).to_a } +show(""gsub hash"") { s.gsub(pat, {}) } +show(""scan"") { s.scan(pat) } +show(""scan blk"") { s.scan(pat) { } } +show(""split"") { s.split(pat) } +show(""index"") { s.index(pat) } +show(""include?"") { s.include?(pat) } +show(""start_with?"") { s.start_with?(pat) } +show(""partition"") { s.partition(pat) } +show(""count"") { s.count(pat) } +show(""binary ok"") { ""a\xffb"".b.gsub(""\xff"".b, ""Z"") } +show(""other enc broken"") { ""a\xffb"".force_encoding(""EUC-JP"").gsub(""\xff"".force_encoding(""EUC-JP""), ""Z"") } +show(""to_str broken"") { o = Object.new; def o.to_str; ""\xff""; end; ""a"".gsub(o, ""Z"") } +show(""valid receiver"") { ""abc"".gsub(""\xff"", ""Z"") } +show(""mid"") { ""x"".sub(""a\xe3b"", ""Z"") } +show(""index"") { s.index(pat) } +show(""index off"") { s.index(pat, 2) } +show(""rindex"") { s.rindex(pat) } +show(""byteindex"") { s.byteindex(pat) } +show(""byterindex"") { s.byterindex(pat) } +show(""include?"") { s.include?(pat) } +show(""[]"") { s[pat] } +show(""slice"") { s.slice(pat) } +show(""slice!"") { t = s.dup; [t.slice!(pat), t] } +show(""[]="") { t = s.dup; t[pat] = ""Z""; t } +show(""partition"") { s.partition(pat) } +show(""rpartition"") { s.rpartition(pat) } +show(""valid in broken"") { s.index(""b"") } +show(""broken mid"") { ""ab\xe3"".index(""\xe3"") } +", @" +Regexp.new: ""RegexpError: invalid multibyte character: /\\xFF/"" +sub: ""RegexpError: invalid multibyte character: /\\xFF/"" +sub!: ""RegexpError: invalid multibyte character: /\\xFF/"" +sub blk: ""RegexpError: invalid multibyte character: /\\xFF/"" +gsub: ""RegexpError: invalid multibyte character: /\\xFF/"" +gsub!: ""RegexpError: invalid multibyte character: /\\xFF/"" +gsub enum: ""RegexpError: invalid multibyte character: /\\xFF/"" +gsub hash: ""RegexpError: invalid multibyte character: /\\xFF/"" +scan: ""RegexpError: invalid multibyte character: /\\xFF/"" +scan blk: ""RegexpError: invalid multibyte character: /\\xFF/"" +split: ""ArgumentError: invalid byte sequence in UTF-8"" +index: nil +include?: false +start_with?: false +partition: [""a\xFFb\xFFc"", """", """"] +count: ""ArgumentError: invalid byte sequence in UTF-8"" +binary ok: ""aZb"" +other enc broken: ""RegexpError: invalid multibyte character: /\\xFF/"" +to_str broken: ""RegexpError: invalid multibyte character: /\\xFF/"" +valid receiver: ""RegexpError: invalid multibyte character: /\\xFF/"" +mid: ""RegexpError: invalid multibyte character: /a\\xE3b/"" +index: nil +index off: nil +rindex: nil +byteindex: nil +byterindex: nil +include?: false +[]: nil +slice: nil +slice!: [nil, ""a\xFFb\xFFc""] +[]=: ""IndexError: string not matched"" +partition: [""a\xFFb\xFFc"", """", """"] +rpartition: ["""", """", ""a\xFFb\xFFc""] +valid in broken: 2 +broken mid: nil +"); + } + + /// + /// Thread.stop with no other thread alive is MRI's ThreadError "stopping only thread" (rb_thread_stop), + /// where it slept forever. A fiber runs on a CLR thread of its own here but is part of the thread that + /// owns it, so neither it nor its thread counts as another thread - for Thread.stop, nor for Thread.list + /// and ThreadGroup#list, which used to list it. + /// + public void Thread_StopAlone1() { + TestOutput(@" +require 'ruby4' +def show(l); v = begin; yield; rescue => e; ""#{e.class}: #{e.message.inspect}""; end; puts ""#{l}: #{v.inspect}""; end +show(""alone"") { Thread.stop } +show(""list"") { Thread.list.size } +show(""in fiber: list"") { Fiber.new { Thread.list.size }.resume } +show(""in fiber: group list"") { Fiber.new { ThreadGroup::Default.list.size }.resume } +show(""in fiber: stop"") { Fiber.new { Thread.stop }.resume } +f = Fiber.new { Fiber.yield }; f.resume +show(""suspended fiber: list"") { [Thread.list.size, Thread.list == [Thread.main]] } +show(""another thread stops"") { t = Thread.new { Thread.stop; :woke }; Thread.pass until t.status == ""sleep""; t.run; t.value } +show(""main stops, another wakes it"") { t = Thread.new { Thread.pass until Thread.main.status == ""sleep""; Thread.main.wakeup }; Thread.stop; t.join; :ok } +show(""fiber of another thread"") { Thread.new { Fiber.new { Thread.list.size }.resume }.value } +f.resume +", @" +alone: ""ThreadError: \""stopping only thread\\n\\tnote: use sleep to stop forever\"""" +list: 1 +in fiber: list: 1 +in fiber: group list: 1 +in fiber: stop: ""ThreadError: \""stopping only thread\\n\\tnote: use sleep to stop forever\"""" +suspended fiber: list: [1, true] +another thread stops: :woke +main stops, another wakes it: :ok +fiber of another thread: 2 +"); + } + /// /// Range#first(n) and #last(n) of an Integer range are counted rather than walked - #last(n) /// built the whole range first, so (1..10**9).last(3) ran out of memory - and #last takes diff --git a/Src/Libraries/Builtins/MutableStringOps.cs b/Src/Libraries/Builtins/MutableStringOps.cs index 21f01ab17..72f1b1b61 100644 --- a/Src/Libraries/Builtins/MutableStringOps.cs +++ b/Src/Libraries/Builtins/MutableStringOps.cs @@ -932,6 +932,9 @@ public static MutableString RemoveSubstringInPlace(MutableString/*!*/ self, [Not // CLR indices of the character representations: a byte-level operation (#start_with?, // #getbyte) may have left either one as bytes. + if (IsBrokenNeedle(searchStr)) { + return null; + } self.PrepareForCharacterRead(); searchStr.PrepareForCharacterRead(); int index = self.IndexOf(searchStr); @@ -1068,7 +1071,7 @@ public static MutableString GetSubstring(ConversionStorage/*!*/ fixnumCast, [RubyMethod("[]")] [RubyMethod("slice")] public static MutableString GetSubstring(MutableString/*!*/ self, [NotNull]MutableString/*!*/ searchStr) { - return (self.IndexOf(searchStr) != -1) ? searchStr.CloneDerived() : null; + return (!IsBrokenNeedle(searchStr) && self.IndexOf(searchStr) != -1) ? searchStr.CloneDerived() : null; } [RubyMethod("[]")] @@ -1273,7 +1276,7 @@ public static MutableString ReplaceSubstring(MutableString/*!*/ self, // see RemoveSubstringInPlace(self, searchStr) self.PrepareForCharacterRead(); substring.PrepareForCharacterRead(); - int index = self.IndexOf(substring); + int index = IsBrokenNeedle(substring) ? -1 : self.IndexOf(substring); if (index == -1) { throw RubyExceptions.CreateIndexError("string not matched"); } @@ -3111,8 +3114,8 @@ public static object BlockReplaceFirst(ConversionStorage/*!*/ tos object blockResult; MutableString result; - // TODO: - var regex = new RubyRegex(MutableString.CreateMutable(Regex.Escape(matchString.ToString()), matchString.Encoding), RubyRegexOptions.NONE); + // The same literal pattern the String forms without a block get from the Regexp conversion. + var regex = RubyRegex.FromLiteralPattern(matchString); return BlockReplaceFirst(tosConversion, scope, self, block, regex, out blockResult, out result) ? blockResult : (result ?? self.CloneDerived()); } @@ -3124,8 +3127,8 @@ public static object BlockReplaceAll(ConversionStorage/*!*/ tosCo object blockResult; MutableString result; - // TODO: - var regex = new RubyRegex(MutableString.CreateMutable(Regex.Escape(matchString.ToString()), matchString.Encoding), RubyRegexOptions.NONE); + // The same literal pattern the String forms without a block get from the Regexp conversion. + var regex = RubyRegex.FromLiteralPattern(matchString); self.TrackChanges(); object r = BlockReplaceAll(tosConversion, scope, self, block, regex, out blockResult, out result) ? blockResult : (result ?? self.CloneDerived()); @@ -3282,6 +3285,15 @@ public static object ReplaceAllInPlace(ConversionStorage/*!*/ tos #region index, rindex + /// + /// MRI's rb_strseq_index and rb_str_rindex give up on a needle whose bytes are not valid in + /// its encoding: not found, rather than a search for the bytes. So do #index, #rindex, + /// #byteindex, #byterindex, #include?, #[], #slice, #slice!, #[]=, #partition and #rpartition. + /// + private static bool IsBrokenNeedle(MutableString/*!*/ needle) { + return needle.ContainsInvalidCharacters(); + } + // encoding aware [RubyMethod("index")] public static object Index(MutableString/*!*/ self, @@ -3292,6 +3304,9 @@ public static object Index(MutableString/*!*/ self, } self.RequireCompatibleEncoding(substring); + if (IsBrokenNeedle(substring)) { + return null; + } substring.PrepareForCharacterRead(); int result = self.IndexOf(substring, self.ToClrIndex(start)); @@ -3338,6 +3353,9 @@ public static object LastIndexOf(MutableString/*!*/ self, } self.RequireCompatibleEncoding(substring); + if (IsBrokenNeedle(substring)) { + return null; + } // A substring with a pair cannot occur in a receiver without one, so its CLR length - // no scan of a fresh literal needed - is good enough for the checks below then. int subCharCount = self.KnowsCharIndexIsClrIndex diff --git a/Src/Libraries/Builtins/RubyRegexOps.cs b/Src/Libraries/Builtins/RubyRegexOps.cs index 4719d4b93..3f39d0af3 100644 --- a/Src/Libraries/Builtins/RubyRegexOps.cs +++ b/Src/Libraries/Builtins/RubyRegexOps.cs @@ -96,27 +96,11 @@ internal static bool NormalizeGroupIndex(ref int index, int groupCount) { public static RubyRegex/*!*/ Create(RubyClass/*!*/ self, [DefaultProtocol, NotNull]MutableString/*!*/ pattern, [Optional]object options, [DefaultProtocol, Optional]MutableString encoding) { - // MRI rejects a pattern that is not valid in its own encoding before compiling it. - if (pattern.Encoding != RubyEncoding.Binary && pattern.ContainsInvalidCharacters()) { - throw new RegexpError("invalid multibyte character: /" + EscapeNonAsciiBytes(pattern) + "/"); - } + RubyRegex.RequireValidPattern(pattern); double? timeout = TakeTimeout(self.Context, ref options); return new RubyRegex(pattern, MakeOptions(self.Context, options, encoding)) { Timeout = timeout }; } - // For an error message about bytes that do not decode: ASCII as is, the rest as \xHH. - private static string/*!*/ EscapeNonAsciiBytes(MutableString/*!*/ str) { - var result = new System.Text.StringBuilder(); - foreach (byte b in str.ToByteArray()) { - if (b < 0x80) { - result.Append((char)b); - } else { - result.Append("\\x").Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); - } - } - return result.ToString(); - } - [RubyConstructor] public static RubyRegex/*!*/ Create(RubyClass/*!*/ self, DynamicNull pattern, [Optional]object options, [Optional]object encoding) { throw RubyExceptions.CreateImplicitConversionError("nil", "String"); diff --git a/Src/Libraries/Builtins/ThreadGroup.cs b/Src/Libraries/Builtins/ThreadGroup.cs index 79c748167..643deba5e 100644 --- a/Src/Libraries/Builtins/ThreadGroup.cs +++ b/Src/Libraries/Builtins/ThreadGroup.cs @@ -62,8 +62,8 @@ public static bool IsEnclosed([NotNull]ThreadGroup/*!*/ self) { } [RubyMethod("list")] - public static RubyArray/*!*/ List([NotNull]ThreadGroup/*!*/ self) { - ThreadOps.RubyThreadInfo[] threads = ThreadOps.RubyThreadInfo.Threads; + public static RubyArray/*!*/ List(RubyContext/*!*/ context, [NotNull]ThreadGroup/*!*/ self) { + ThreadOps.RubyThreadInfo[] threads = ThreadOps.RubyThreadInfo.GetRubyThreads(context); RubyArray result = new RubyArray(threads.Length); foreach (ThreadOps.RubyThreadInfo threadInfo in threads) { Thread thread = threadInfo.Thread; diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs index 74353e6ee..a95a0fc34 100644 --- a/Src/Libraries/Builtins/ThreadOps.Fiber.cs +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -229,6 +229,11 @@ public object Handle { } } + /// The thread this fiber belongs to, which is Thread.current inside it. + internal Thread/*!*/ Owner { + get { return _owner; } + } + internal FiberCore(RubyContext context, object handle, Thread owner, bool root) { _mailbox = new FiberMailbox(); _owner = owner; diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index e3d18f5f8..f930cf0f3 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -207,6 +207,13 @@ internal Thread Thread { internal Exception Exception { get; set; } internal object Result { get; set; } internal bool CreatedFromRuby { get; set; } + + /// + /// The runtime whose Thread.new made this thread; null for a thread Ruby did not create + /// (a host's, the main thread). Each RubyContext is a VM of its own, as far as which + /// threads it has goes. + /// + internal RubyContext Context { get; set; } internal bool ExitRequested { get; set; } internal MutableString Name { get; set; } internal bool ReportOnException { get; set; } @@ -316,6 +323,29 @@ internal static RubyThreadInfo[] Threads { } } + /// + /// The Ruby threads of a runtime, as Thread.list and ThreadGroup#list see them: + /// less the threads another runtime's Thread.new made - two + /// ScriptRuntimes in one process are two VMs, and neither lists the other's threads - and + /// less the CLR threads that run (or hold suspended) a fiber of another thread's. A fiber + /// has a CLR thread of its own here, but in MRI it is part of the thread that owns it, and + /// Thread.current inside it is that thread. + /// + internal static RubyThreadInfo[]/*!*/ GetRubyThreads(RubyContext/*!*/ context) { + RubyThreadInfo[] all = Threads; + var result = new List(all.Length); + foreach (RubyThreadInfo info in all) { + if (info.Context != null && info.Context != context) { + continue; + } + FiberCore fiber = info.FiberExecution; + if (fiber == null || fiber.Owner == info.Thread) { + result.Add(info); + } + } + return result.ToArray(); + } + /// /// We do not use Thread.Sleep here as another thread can call Thread#wakeup/Thread#run. Instead, we use our own /// lock which can be signalled from another thread. @@ -986,10 +1016,10 @@ public static void Critical(RubyContext/*!*/ context, object self, bool value) { // exclusive // fork [RubyMethod("list", RubyMethodAttributes.PublicSingleton)] - public static RubyArray/*!*/ List(object self) { + public static RubyArray/*!*/ List(RubyContext/*!*/ context, object self) { RubyThreadInfo.RegisterCurrentThread(); - RubyThreadInfo[] threads = RubyThreadInfo.Threads; + RubyThreadInfo[] threads = RubyThreadInfo.GetRubyThreads(context); RubyArray result = new RubyArray(threads.Length); foreach (RubyThreadInfo threadInfo in threads) { Thread thread = threadInfo.Thread; @@ -1097,6 +1127,7 @@ private static Action CreateThreadTerminator(RubyContext context) { // #inspect, #priority, #report_on_exception - has to be in place before Start(). RubyThreadInfo info = RubyThreadInfo.FromThread(result); info.CreatedFromRuby = true; + info.Context = context; info.Group = group; info.Priority = creator.Priority; info.Location = GetCallerLocation(context); @@ -1308,6 +1339,12 @@ public static void Yield(object self) { [RubyMethod("stop", RubyMethodAttributes.PublicSingleton)] public static void Stop(RubyContext/*!*/ context, object self) { + // MRI's rb_thread_stop: with no other thread alive nothing could ever wake this one. + // (Kernel#sleep has no such check: a signal can end it.) + RubyThreadInfo.RegisterCurrentThread(); + if (RubyThreadInfo.GetRubyThreads(context).Length <= 1) { + throw new ThreadError("stopping only thread\n\tnote: use sleep to stop forever"); + } if (context.CriticalThread == Thread.CurrentThread) { SetCritical(context, false); } @@ -1316,7 +1353,6 @@ public static void Stop(RubyContext/*!*/ context, object self) { internal static void DoSleep() { RubyThreadInfo.RegisterCurrentThread(); - // TODO: MRI throws an exception if you try to stop the main thread RubyThreadInfo info = RubyThreadInfo.FromThread(Thread.CurrentThread); info.Sleep(); // Waking up is a safe point: a signal that arrived while we slept runs its trap handler diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 235c6dde9..5c4bc05e4 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -10461,7 +10461,7 @@ private static void LoadThread_Class(IronRuby.Builtins.RubyModule/*!*/ module) { DefineLibraryMethod(module, "list", 0x61, 0x00000000U, - new Func(IronRuby.Builtins.ThreadOps.List) + new Func(IronRuby.Builtins.ThreadOps.List) ); DefineLibraryMethod(module, "main", 0x61, @@ -10537,8 +10537,8 @@ private static void LoadThreadGroup_Instance(IronRuby.Builtins.RubyModule/*!*/ m ); DefineLibraryMethod(module, "list", 0x51, - 0x00000001U, - new Func(IronRuby.Builtins.ThreadGroup.List) + 0x00000002U, + new Func(IronRuby.Builtins.ThreadGroup.List) ); DefineLibraryMethod(module, "to_s", 0x51, diff --git a/Src/Ruby/Builtins/RubyRegex.cs b/Src/Ruby/Builtins/RubyRegex.cs index b74b568ff..54e201c44 100644 --- a/Src/Ruby/Builtins/RubyRegex.cs +++ b/Src/Ruby/Builtins/RubyRegex.cs @@ -1541,6 +1541,39 @@ private static int SkipToUnescapedForwardSlash(MutableString/*!*/ pattern, int p return str.EscapeRegularExpression(); } + /// + /// MRI refuses a pattern whose bytes are not valid in its own encoding before compiling it: + /// Regexp.new, and #sub, #gsub, their bang forms and #scan given a String (string.c's + /// get_pat_quoted), all raise "RegexpError: invalid multibyte character: /\xFF/". + /// + public static void RequireValidPattern(MutableString/*!*/ pattern) { + if (pattern.Encoding != RubyEncoding.Binary && pattern.ContainsInvalidCharacters()) { + throw new RegexpError("invalid multibyte character: /" + EscapeNonAsciiBytes(pattern) + "/"); + } + } + + // For an error message about bytes that do not decode: ASCII as is, the rest as \xHH. + private static string/*!*/ EscapeNonAsciiBytes(MutableString/*!*/ str) { + var result = new StringBuilder(); + foreach (byte b in str.ToByteArray()) { + if (b < 0x80) { + result.Append((char)b); + } else { + result.Append("\\x").Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + } + return result.ToString(); + } + + /// + /// A String used as a pattern by #sub, #gsub, #sub!, #gsub! and #scan: matched literally, and + /// refused first if it is not valid in its encoding, as MRI does. + /// + public static RubyRegex/*!*/ FromLiteralPattern(MutableString/*!*/ pattern) { + RequireValidPattern(pattern); + return new RubyRegex(Escape(pattern), RubyRegexOptions.NONE); + } + private static int SkipNonSpecial(string/*!*/ pattern, int i, out char escaped) { while (i < pattern.Length) { char c = pattern[i]; diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 92291f213..4f0356065 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -3747,7 +3747,7 @@ public static MutableString TryToStringValidator(string/*!*/ className, object o [Emitted] // ProtocolConversionAction public static RubyRegex/*!*/ ToRegexValidator(string/*!*/ className, object obj) { - return new RubyRegex(RubyRegex.Escape(ToStringValidator(className, obj)), RubyRegexOptions.NONE); + return RubyRegex.FromLiteralPattern(ToStringValidator(className, obj)); } /// From 72116c1138e09f30ad643e7401e21fe6eff89111 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:25:42 +0000 Subject: [PATCH 2/7] Windows: File.link/symlink/readlink work; the rest of MRI's Windows stubs match What CRuby 4.0 does on Windows, from win32/Makefile.sub (the HAVE_* list), win32/win32.c and the rb_f_notimplement definitions in dir.c, file.c and process.c. Implemented, as win32.c does them (they raised NotImplementedError): - File.link: CreateHardLinkW. - File.symlink: CreateSymbolicLinkW, through File/Directory. CreateSymbolicLink. The target's slashes are turned to backslashes; a directory link is made when the target names a directory; unprivileged creation is allowed where Windows allows it. An empty link is ENOENT and an empty target EINVAL. - File.readlink: the target of a symbolic link or a junction, with backslashes as slashes. Anything else that exists is EINVAL; nothing at all is ENOENT. - Win32 errors map to the errno win32.c's map_errno gives: ENOENT, EACCES (incl. ERROR_PRIVILEGE_NOT_HELD), EXDEV, EEXIST, ENOTDIR, ELOOP, and EINVAL for the rest. As on POSIX, an existing target is reported by its own path and anything else by both paths. MRI's rb_f_notimplement stubs on Windows: NotImplementedError "() function is unimplemented on this machine", and #respond_to? false ([RubyNotImplemented(OnWindowsOnly = true)], or Process's WINDOWS_NOT_IMPLEMENTED_METHODS__): - Dir.fchdir, Dir.for_fd and Dir#fileno threw CLR NotImplementedException, which `rescue NotImplementedError` does not catch. Dir.chroot threw InvalidOperationException. - File.lutime set the times through the BCL, so it changed the target's times rather than the link's. File.mkfifo raised correctly but answered #respond_to? with true. - Process.getpgid, setpgid, getsid, setsid, getpgrp, setpgrp, groups, groups=, initgroups, maxgroups, maxgroups=, uid=, gid=, euid= and egid= join getrlimit, setrlimit, getpriority and setpriority, which were stubs already. Nothing changes off Windows. Testing - New IronRuby.Tests case Windows_NotImplementedAndLinks1 (Windows only): the stubs' #respond_to? and errors, and link, symlink (file, directory, dangling) and readlink with their errors. Its expected output is MRI's as its win32 sources give it, not a recorded run. The link and symlink part is identical to what CRuby 3.3 prints for the same script on Linux. - Linux: a #respond_to? probe over 70 methods is unchanged. IronRuby.Tests (Debug, net8.0): PASSED, 0 FAILED. ruby/spec core/dir (333 examples), core/file (937), core/process (365) and library/etc (50): 0 failures. - Not run on Windows here: CI's windows-latest jobs are the first run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 69 ++++++++++++++ Src/Libraries/Builtins/Dir.cs | 18 +++- Src/Libraries/Builtins/FileOps.cs | 121 ++++++++++++++++++++++++ Src/StdLib/ironruby/ruby4.rb | 10 +- 5 files changed, 213 insertions(+), 6 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 4326203eb..53607da8a 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -859,6 +859,7 @@ public Tests(Driver/*!*/ driver) { IO_SysOpenPermission1, String_BrokenPattern1, Thread_StopAlone1, + Windows_NotImplementedAndLinks1, Range_IntegerFastPaths1, Method_LightFrame1, Method_LightFrame2, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 3f609af63..9385b27bb 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2928,6 +2928,75 @@ public void Thread_StopAlone1() { "); } + /// + /// What MRI does on Windows with the functions win32/Makefile.sub leaves out, and with the ones + /// win32.c supplies. Dir.fchdir, Dir.for_fd, Dir#fileno, Dir.chroot, File.lutime, File.mkfifo and the + /// process-group and set-id parts of Process are rb_f_notimplement stubs there: NotImplementedError, + /// and #respond_to? false. Dir's threw CLR NotImplementedException or InvalidOperationException, and + /// all of them answered #respond_to? with true. File.link, File.symlink and File.readlink work there, + /// through CreateHardLinkW, CreateSymbolicLinkW and the reparse point; they raised NotImplementedError. + /// Windows only. The expected output is MRI's as its win32 sources give it, not a recorded run. + /// + public void Windows_NotImplementedAndLinks1() { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) { + return; + } + TestOutput(@" +require 'ruby4' +require 'tmpdir' +require 'fileutils' +def show(l); v = begin; yield; rescue Exception => e; e.is_a?(SystemCallError) ? e.class : ""#{e.class}: #{e.message}""; end; puts ""#{l}: #{v.inspect}""; end +show(""stubs respond_to?"") { [Dir.respond_to?(:fchdir), Dir.respond_to?(:for_fd), Dir.respond_to?(:chroot), Dir.open(""."") { |x| x.respond_to?(:fileno) }, File.respond_to?(:lutime), File.respond_to?(:mkfifo), Process.respond_to?(:getpgid), Process.respond_to?(:setsid), Process.respond_to?(:groups), Process.respond_to?(:uid=)] } +show(""links respond_to?"") { [File.respond_to?(:link), File.respond_to?(:symlink), File.respond_to?(:readlink)] } +show(""Dir.fchdir"") { Dir.fchdir(0) } +show(""Dir.for_fd"") { Dir.for_fd(0) } +show(""Dir#fileno"") { Dir.open(""."") { |x| x.fileno } } +show(""Dir.chroot"") { Dir.chroot(""."") } +show(""File.lutime"") { File.lutime(nil, nil, ""x"") } +show(""File.mkfifo"") { File.mkfifo(""x"") } +show(""Process.getpgid"") { Process.getpgid(0) } +show(""Process.uid="") { Process.uid = 0 } +dir = Dir.mktmpdir +begin + Dir.chdir(dir) do + File.write(""f"", ""x"") + Dir.mkdir(""sub"") + show(""link"") { File.link(""f"", ""h""); File.read(""h"") } + show(""link existing"") { File.link(""f"", ""h"") } + show(""link missing"") { File.link(""nope"", ""h2"") } + show(""symlink to file"") { File.symlink(""f"", ""s""); [File.symlink?(""s""), File.readlink(""s""), File.read(""s"")] } + show(""symlink to dir"") { File.symlink(""sub"", ""sd""); [File.symlink?(""sd""), File.directory?(""sd""), File.readlink(""sd"")] } + show(""symlink dangling"") { File.symlink(""sub/x"", ""sn""); [File.symlink?(""sn""), File.readlink(""sn"")] } + show(""symlink existing"") { File.symlink(""f"", ""s"") } + show(""readlink not a link"") { File.readlink(""f"") } + show(""readlink missing"") { File.readlink(""nope"") } + end +ensure + FileUtils.rm_rf(dir) rescue nil +end +", @" +stubs respond_to?: [false, false, false, false, false, false, false, false, false, false] +links respond_to?: [true, true, true] +Dir.fchdir: ""NotImplementedError: fchdir() function is unimplemented on this machine"" +Dir.for_fd: ""NotImplementedError: for_fd() function is unimplemented on this machine"" +Dir#fileno: ""NotImplementedError: fileno() function is unimplemented on this machine"" +Dir.chroot: ""NotImplementedError: chroot() function is unimplemented on this machine"" +File.lutime: ""NotImplementedError: lutime() function is unimplemented on this machine"" +File.mkfifo: ""NotImplementedError: mkfifo() function is unimplemented on this machine"" +Process.getpgid: ""NotImplementedError: getpgid() function is unimplemented on this machine"" +Process.uid=: ""NotImplementedError: uid=() function is unimplemented on this machine"" +link: ""x"" +link existing: Errno::EEXIST +link missing: Errno::ENOENT +symlink to file: [true, ""f"", ""x""] +symlink to dir: [true, true, ""sub""] +symlink dangling: [true, ""sub/x""] +symlink existing: Errno::EEXIST +readlink not a link: Errno::EINVAL +readlink missing: Errno::ENOENT +"); + } + /// /// Range#first(n) and #last(n) of an Integer range are counted rather than walked - #last(n) /// built the whole range first, so (1..10**9).last(3) ran out of memory - and #last takes diff --git a/Src/Libraries/Builtins/Dir.cs b/Src/Libraries/Builtins/Dir.cs index a5383205d..e88757a70 100644 --- a/Src/Libraries/Builtins/Dir.cs +++ b/Src/Libraries/Builtins/Dir.cs @@ -160,10 +160,17 @@ private static object ChangeDirectory(PlatformAdaptationLayer/*!*/ pal, string/* } } + // On Windows these four are MRI's rb_f_notimplement stubs (no chroot, fchdir, dirfd or + // fdopendir there): NotImplementedError, and #respond_to? denies them. + private static Exception/*!*/ Unimplemented(string/*!*/ function) { + return new NotImplementedError(function + "() function is unimplemented on this machine"); + } + [RubyMethod("chroot", RubyMethodAttributes.PublicSingleton)] + [RubyNotImplemented(OnWindowsOnly = true)] public static int ChangeRoot(ConversionStorage/*!*/ toPath, RubyClass/*!*/ self, object dir) { if (!Posix.IsAvailable) { - throw new InvalidOperationException(); + throw Unimplemented("chroot"); } var path = Protocols.CastToPath(toPath, dir); string strPath = self.Context.DecodePath(path); @@ -180,9 +187,10 @@ public static int ChangeRoot(ConversionStorage/*!*/ toPath, RubyC /// lasts for the block only, and the block's value is the result. /// [RubyMethod("fchdir", RubyMethodAttributes.PublicSingleton)] + [RubyNotImplemented(OnWindowsOnly = true)] public static object ChangeDirectoryToDescriptor(BlockParam block, RubyClass/*!*/ self, [DefaultProtocol]int fd) { if (!Posix.IsAvailable) { - throw new NotImplementedException("fchdir"); + throw Unimplemented("fchdir"); } var pal = self.Context.Platform; @@ -509,15 +517,17 @@ public static void Close(RubyDir/*!*/ self) { /// is opened the first time it is asked for and closed with the Dir. /// [RubyMethod("fileno")] + [RubyNotImplemented(OnWindowsOnly = true)] public static int FileNo(RubyDir/*!*/ self) { self.ThrowIfClosed(); return self.GetFileDescriptor(); } [RubyMethod("for_fd", RubyMethodAttributes.PublicSingleton)] + [RubyNotImplemented(OnWindowsOnly = true)] public static RubyDir/*!*/ ForFileDescriptor(RubyClass/*!*/ self, [DefaultProtocol]int fd) { if (!Posix.IsAvailable) { - throw new NotImplementedException("Dir.for_fd"); + throw Unimplemented("for_fd"); } // There is no fdopendir here, so the descriptor is turned back into the path it @@ -661,7 +671,7 @@ private int GetFileDescriptor() { return _fd; } if (!Posix.IsAvailable) { - throw new NotImplementedException("Dir#fileno"); + throw Unimplemented("fileno"); } string path = ImmediateClass.Context.DecodePath(_dirName); diff --git a/Src/Libraries/Builtins/FileOps.cs b/Src/Libraries/Builtins/FileOps.cs index 7dbe27c9f..3ba4446ec 100644 --- a/Src/Libraries/Builtins/FileOps.cs +++ b/Src/Libraries/Builtins/FileOps.cs @@ -1196,6 +1196,103 @@ private static extern bool LockFileEx(Microsoft.Win32.SafeHandles.SafeFileHandle private static extern bool UnlockFileEx(Microsoft.Win32.SafeHandles.SafeFileHandle handle, int reserved, uint lengthLow, uint lengthHigh, ref System.Threading.NativeOverlapped overlapped); + [System.Runtime.InteropServices.DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateHardLinkW(string newFileName, string existingFileName, IntPtr securityAttributes); + + /// + /// The errno win32.c's map_errno gives a Win32 error, for the ones File.link, File.symlink + /// and File.readlink can meet; EINVAL for the rest, as map_errno does. + /// + private static int Win32ErrorToErrno(int error) { + switch (error) { + case 2: // ERROR_FILE_NOT_FOUND + case 3: // ERROR_PATH_NOT_FOUND + case 15: // ERROR_INVALID_DRIVE + case 161: // ERROR_BAD_PATHNAME + case 206: // ERROR_FILENAME_EXCED_RANGE + return Posix.ENOENT; + case 5: // ERROR_ACCESS_DENIED + case 32: // ERROR_SHARING_VIOLATION + case 33: // ERROR_LOCK_VIOLATION + case 1314: // ERROR_PRIVILEGE_NOT_HELD + return Posix.EACCES; + case 17: // ERROR_NOT_SAME_DEVICE + return Posix.EXDEV; + case 80: // ERROR_FILE_EXISTS + case 183: // ERROR_ALREADY_EXISTS + return Posix.EEXIST; + case 267: // ERROR_DIRECTORY + return Posix.ENOTDIR; + case 1921: // ERROR_CANT_RESOLVE_FILENAME + return Posix.ELOOP; + default: + return Posix.EINVAL; + } + } + + // MRI reports an existing target by its own path, anything else by both paths. + private static Exception/*!*/ WindowsLinkError(int win32Error, string/*!*/ strOld, string/*!*/ strNew) { + int errno = Win32ErrorToErrno(win32Error); + return Posix.Error(errno, errno == Posix.EEXIST ? strNew : "(" + strOld + ", " + strNew + ")"); + } + + /// + /// win32.c's symlink(): the target with its slashes turned around, a directory link when the + /// target names a directory (relative to the current directory, as there), created without + /// the privilege where Windows allows that - which the BCL asks for itself. + /// + private static void WindowsSymLink(string/*!*/ strOld, string/*!*/ strNew) { + if (strNew.Length == 0) { + throw Posix.Error(Posix.ENOENT, "(" + strOld + ", " + strNew + ")"); + } + if (strOld.Length == 0) { + throw Posix.Error(Posix.EINVAL, "(" + strOld + ", " + strNew + ")"); + } + string target = strOld.Replace('/', '\\'); + try { + if (Directory.Exists(target)) { + Directory.CreateSymbolicLink(strNew, target); + } else { + File.CreateSymbolicLink(strNew, target); + } + } catch (UnauthorizedAccessException) { + throw WindowsLinkError(5, strOld, strNew); + } catch (IOException e) { + throw WindowsLinkError(e.HResult & 0xFFFF, strOld, strNew); + } + } + + /// + /// win32.c's readlink(): the target of a symbolic link or a junction, with backslashes as + /// slashes; EINVAL for anything that is neither, ENOENT for nothing at all. + /// + private static string/*!*/ WindowsReadLink(string/*!*/ strPath) { + FileAttributes attributes; + try { + // the attributes of the link itself, which may dangle + attributes = File.GetAttributes(strPath); + } catch (FileNotFoundException) { + throw Posix.Error(Posix.ENOENT, strPath); + } catch (DirectoryNotFoundException) { + throw Posix.Error(Posix.ENOENT, strPath); + } catch (UnauthorizedAccessException) { + throw Posix.Error(Posix.EACCES, strPath); + } catch (IOException e) { + throw Posix.Error(Win32ErrorToErrno(e.HResult & 0xFFFF), strPath); + } + + string target = null; + if ((attributes & FileAttributes.ReparsePoint) != 0) { + FileSystemInfo info = (attributes & FileAttributes.Directory) != 0 + ? (FileSystemInfo)new DirectoryInfo(strPath) : new FileInfo(strPath); + target = info.LinkTarget; + } + if (target == null) { + throw Posix.Error(Posix.EINVAL, strPath); + } + return target.Replace('\\', '/'); + } + /// /// flock on Windows, as CRuby's win32.c does it: LockFileEx/UnlockFileEx over the /// whole file, shared or exclusive. A blocking lock polls with FAIL_IMMEDIATELY for @@ -1250,6 +1347,9 @@ private static object WindowsFileLock(RubyFile/*!*/ self, int operation) { public static MutableString/*!*/ Readlink(ConversionStorage/*!*/ toPath, RubyClass/*!*/ self, object path) { string strPath = self.Context.DecodePath(Protocols.CastToPath(toPath, path)); if (!Posix.IsAvailable) { + if (OperatingSystem.IsWindows()) { + return self.Context.EncodePath(WindowsReadLink(strPath)); + } throw new IronRuby.Builtins.NotImplementedError("readlink() function is unimplemented on this machine"); } @@ -1266,6 +1366,13 @@ public static int Link(ConversionStorage/*!*/ toPath, RubyClass/* string strOld = self.Context.DecodePath(Protocols.CastToPath(toPath, oldPath)); string strNew = self.Context.DecodePath(Protocols.CastToPath(toPath, newPath)); if (!Posix.IsAvailable) { + if (OperatingSystem.IsWindows()) { + // win32.c's link(): CreateHardLinkW, new name first. + if (!CreateHardLinkW(strNew, strOld, IntPtr.Zero)) { + throw WindowsLinkError(Marshal.GetLastWin32Error(), strOld, strNew); + } + return 0; + } throw new IronRuby.Builtins.NotImplementedError("link() function is unimplemented on this machine"); } @@ -1282,6 +1389,10 @@ public static int SymLink(ConversionStorage/*!*/ toPath, RubyClas string strOld = self.Context.DecodePath(Protocols.CastToPath(toPath, oldPath)); string strNew = self.Context.DecodePath(Protocols.CastToPath(toPath, newPath)); if (!Posix.IsAvailable) { + if (OperatingSystem.IsWindows()) { + WindowsSymLink(strOld, strNew); + return 0; + } throw new IronRuby.Builtins.NotImplementedError("symlink() function is unimplemented on this machine"); } @@ -1292,7 +1403,9 @@ public static int SymLink(ConversionStorage/*!*/ toPath, RubyClas return 0; } + // MRI on Windows has no mkfifo: the rb_f_notimplement stub, which #respond_to? denies. [RubyMethod("mkfifo", RubyMethodAttributes.PublicSingleton, BuildConfig = "FEATURE_FILESYSTEM")] + [RubyNotImplemented(OnWindowsOnly = true)] public static int MakeFifo(ConversionStorage/*!*/ toPath, RubyClass/*!*/ self, object path, [DefaultParameterValue(0x1B6 /* 0666 */)]int mode) { @@ -1499,10 +1612,18 @@ public static int UpdateTimes(ConversionStorage/*!*/ toPath, Ruby return UpdateTimes(toPath, self, accessTime, modifiedTime, paths, true); } + // MRI on Windows has neither lutimes nor utimensat: File.lutime is the rb_f_notimplement + // stub there, which #respond_to? denies. Setting the times through the BCL would set the + // target's, not the link's. [RubyMethod("lutime", RubyMethodAttributes.PublicSingleton, BuildConfig = "FEATURE_FILESYSTEM")] + [RubyNotImplemented(OnWindowsOnly = true)] public static int UpdateLinkTimes(ConversionStorage/*!*/ toPath, RubyClass/*!*/ self, object accessTime, object modifiedTime, params object[]/*!*/ paths) { + if (OperatingSystem.IsWindows()) { + throw new IronRuby.Builtins.NotImplementedError("lutime() function is unimplemented on this machine"); + } + return UpdateTimes(toPath, self, accessTime, modifiedTime, paths, false); } diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 3f8554fcb..a54b21b83 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -9536,8 +9536,14 @@ def self.waitall # and that function is exactly the case respond_to? answers false for - so portable # code asks whether the feature is there rather than whether the name is. # On Windows the resource-limit and priority functions are the same kind of stub in - # MRI; the libc behind ours is not there to call. - WINDOWS_NOT_IMPLEMENTED_METHODS__ = [:getrlimit, :setrlimit, :getpriority, :setpriority].freeze unless const_defined?(:WINDOWS_NOT_IMPLEMENTED_METHODS__) + # MRI; the libc behind ours is not there to call. So are the process-group, session, + # supplementary-group and set-id ones: win32/Makefile.sub defines none of getpgid, + # setpgid, getsid, setsid, getpgrp, setpgrp, getgroups, setgroups, initgroups or the + # setuid/setgid family, and process.c makes each of these methods rb_f_notimplement. + WINDOWS_NOT_IMPLEMENTED_METHODS__ = [:getrlimit, :setrlimit, :getpriority, :setpriority, + :getpgid, :setpgid, :getsid, :setsid, :getpgrp, :setpgrp, + :groups, :groups=, :initgroups, :maxgroups, :maxgroups=, + :uid=, :gid=, :euid=, :egid=].freeze unless const_defined?(:WINDOWS_NOT_IMPLEMENTED_METHODS__) unless const_defined?(:NOT_IMPLEMENTED_METHODS) NOT_IMPLEMENTED_METHODS = (::File::ALT_SEPARATOR == "\\" ? [:daemon, :fork, :_fork] + WINDOWS_NOT_IMPLEMENTED_METHODS__ : [:daemon, :fork, :_fork]).freeze From 476ca3bf360e6eb574fda8b93c5ddfc4c82553d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:41:52 +0000 Subject: [PATCH 3/7] Windows: File.symlink?, File.lstat(...).symlink? and File.ftype see links Found by the first Windows CI run of Windows_NotImplementedAndLinks1. Every other line of it matched, but File.symlink? was false for a symbolic link IronRuby had just made. Off POSIX a File::Stat wraps a FileInfo or DirectoryInfo, with no mode bits, so #symlink? could only ever answer false. The lstat path ignored followLinks too. An lstat now looks at the path itself first: a symbolic link or junction (dangling or not) gets a StatInfo marked IsLink, as win32.c's lstat answers for the link. #symlink? reads the mark, and #ftype says "link". File.stat of a link still follows it, and its #symlink? is false. Testing - Windows_NotImplementedAndLinks1 also checks File.lstat/File.stat #symlink? and File.ftype of file, directory and dangling links. The same script on Linux prints the same under CRuby 3.3 and IronRuby. - Linux: IronRuby.Tests (Debug, net8.0): PASSED, 0 FAILED. ruby/spec core/file/lstat_spec.rb, symlink_spec.rb, ftype_spec.rb, core/file/stat (118 examples) and core/filetest (101): 0 failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/Runtime/MiscTests.cs | 2 ++ Src/Libraries/Builtins/FileOps.cs | 37 ++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 9385b27bb..00a1095d6 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2967,6 +2967,7 @@ public void Windows_NotImplementedAndLinks1() { show(""symlink to file"") { File.symlink(""f"", ""s""); [File.symlink?(""s""), File.readlink(""s""), File.read(""s"")] } show(""symlink to dir"") { File.symlink(""sub"", ""sd""); [File.symlink?(""sd""), File.directory?(""sd""), File.readlink(""sd"")] } show(""symlink dangling"") { File.symlink(""sub/x"", ""sn""); [File.symlink?(""sn""), File.readlink(""sn"")] } + show(""lstat and stat"") { [File.lstat(""s"").symlink?, File.stat(""s"").symlink?, File.ftype(""s""), File.lstat(""sd"").symlink?, File.ftype(""sn"")] } show(""symlink existing"") { File.symlink(""f"", ""s"") } show(""readlink not a link"") { File.readlink(""f"") } show(""readlink missing"") { File.readlink(""nope"") } @@ -2991,6 +2992,7 @@ public void Windows_NotImplementedAndLinks1() { symlink to file: [true, ""f"", ""x""] symlink to dir: [true, true, ""sub""] symlink dangling: [true, ""sub/x""] +lstat and stat: [true, false, ""link"", true, ""link""] symlink existing: Errno::EEXIST readlink not a link: Errno::EINVAL readlink missing: Errno::ENOENT diff --git a/Src/Libraries/Builtins/FileOps.cs b/Src/Libraries/Builtins/FileOps.cs index 3ba4446ec..0523d0c31 100644 --- a/Src/Libraries/Builtins/FileOps.cs +++ b/Src/Libraries/Builtins/FileOps.cs @@ -1790,6 +1790,10 @@ internal sealed class StatInfo : FileSystemInfo { // way, which is what makes File.stat(...).instance_of?(File::Stat) true. internal readonly FileSystemInfo Legacy; + // Off Unix: an lstat of a symbolic link (or junction), which the BCL object cannot say + // by itself - File.symlink? and File.lstat(...).symlink? read it. + internal bool IsLink; + internal StatInfo(string/*!*/ path, Posix.StatData/*!*/ data) { Data = data; OriginalPath = path; @@ -1921,6 +1925,14 @@ internal static bool TryCreate(RubyContext/*!*/ context, string/*!*/ path, bool } PlatformAdaptationLayer pal = context.Platform; + if (!followLinks) { + // lstat: a link answers for itself, dangling or not, as win32.c's does + FileSystemInfo link = TryGetLink(path); + if (link != null) { + result = new StatInfo(link) { IsLink = true }; + return true; + } + } if (pal.FileExists(path)) { result = new StatInfo(new FileInfo(path)); } else if (pal.DirectoryExists(path)) { @@ -1934,6 +1946,21 @@ internal static bool TryCreate(RubyContext/*!*/ context, string/*!*/ path, bool return true; } + // The link at path itself if it is a symbolic link or a junction; null otherwise. + private static FileSystemInfo TryGetLink(string/*!*/ path) { + try { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) == 0) { + return null; + } + FileSystemInfo info = (attributes & FileAttributes.Directory) != 0 + ? (FileSystemInfo)new DirectoryInfo(path) : new FileInfo(path); + return info.LinkTarget != null ? info : null; + } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException || e is NotSupportedException) { + return null; + } + } + private static Posix.StatData D(FileSystemInfo/*!*/ self) { var si = self as StatInfo; return si != null ? si.Data : null; @@ -2094,13 +2121,21 @@ public static bool IsSocket(FileSystemInfo/*!*/ self) { [RubyMethod("symlink?")] public static bool IsSymLink(FileSystemInfo/*!*/ self) { var d = D(self); - return d != null && d.FileType == Posix.S_IFLNK; + if (d == null) { + var si = self as StatInfo; + return si != null && si.IsLink; + } + return d.FileType == Posix.S_IFLNK; } [RubyMethod("ftype")] public static MutableString/*!*/ FileType(FileSystemInfo/*!*/ self) { var d = D(self); if (d == null) { + var si = self as StatInfo; + if (si != null && si.IsLink) { + return MutableString.CreateAscii("link"); + } return MutableString.CreateAscii(IsFile(self) ? "file" : "directory"); } switch (d.FileType) { From b6de3fceb51d495b62ed14906690c6a58d66d3ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:22:47 +0000 Subject: [PATCH 4/7] Review fixes: stable fiber-worker and host-thread counting, lazy gsub enumerators, link-aware lstat From CodeRabbit's review of #110. Thread lists and Thread.stop - A CLR thread that has run a non-root fiber is marked IsFiberWorker for good. FiberCore.Activate sets the mark. Between two fibers, __fiber_park__ clears FiberExecution before it sets IsIdleFiberWorker, and a Thread.stop in that gap (the resumer runs at once) counted the worker as a second Ruby thread and slept forever. - A thread no Thread.new made (a host's) now belongs to the runtime it is the main thread of, or else to the one asking from it. Before, every such thread counted for every runtime, so two runtimes on two host threads each listed both main threads, and Thread.stop alone slept. - RubyThreadInfo holds its runtime weakly. _mapping never drops an entry, so a strong reference kept every runtime a Thread.new ran in alive for the life of the process. gsub / gsub! enumerators - "abc".gsub("\xFF") (and gsub!) returns the Enumerator again, and the pattern is converted and checked when it runs, as str_gsub returns the Enumerator before get_pat_quoted. A non-pattern is get_pat_quoted's "wrong argument type Integer (expected Regexp)". The overloads take the block too, because the binder may choose them for a call with one, and then do the work at once. Windows - File.symlink looks a relative target up from the link's directory to decide whether to make a directory link, as ruby/ruby master's win32.c now does; 4.0.0 looked in the current directory. The target is still passed on as given. - An lstat of a link is the link: #file? and #directory? are false and #mode has S_IFLNK, as #symlink? and #ftype already said. Tests - Thread_StopAlone1's two waits have a deadline and kill their thread, so a Thread.stop that raises where it should sleep fails the test instead of hanging the run. Output unchanged, and the same as CRuby 3.3's. - String_BrokenPattern1 checks that the enumerators are lazy. - Windows_NotImplementedAndLinks1 makes a relative directory link in a subdirectory. The same line prints the same under CRuby and IronRuby on Linux. Testing (Linux, net8.0) - IronRuby.Tests: PASSED, 0 FAILED. - A gsub/gsub! script of 21 cases (lazy enumerators, nil/Integer/#to_str patterns, blocks, break, $~, bang forms) matches CRuby line for line. - ruby/spec: core/string/gsub_spec.rb (77 examples), sub_spec.rb (66), scan_spec.rb (18), core/thread (395), core/fiber (188), core/file/lstat_spec.rb and core/file/stat (118): 0 failures. - Initializers.Generated.cs regenerated with regen-initializers.sh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/Runtime/MiscTests.cs | 10 ++++-- Src/Libraries/Builtins/FileOps.cs | 34 +++++++++++-------- Src/Libraries/Builtins/MutableStringOps.cs | 38 ++++++++++++++++++---- Src/Libraries/Builtins/ThreadOps.Fiber.cs | 5 +++ Src/Libraries/Builtins/ThreadOps.cs | 33 ++++++++++++++++--- Src/Libraries/Initializers.Generated.cs | 8 ++--- 6 files changed, 98 insertions(+), 30 deletions(-) diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 00a1095d6..2a42b42cf 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2827,6 +2827,7 @@ public void String_BrokenPattern1() { show(""gsub"") { s.gsub(pat, ""Z"") } show(""gsub!"") { s.dup.gsub!(pat) { ""Z"" } } show(""gsub enum"") { s.gsub(pat).to_a } +show(""gsub enum is lazy"") { [""abc"".gsub(pat).class, ""abc"".dup.gsub!(pat).class] } show(""gsub hash"") { s.gsub(pat, {}) } show(""scan"") { s.scan(pat) } show(""scan blk"") { s.scan(pat) { } } @@ -2863,6 +2864,7 @@ public void String_BrokenPattern1() { gsub: ""RegexpError: invalid multibyte character: /\\xFF/"" gsub!: ""RegexpError: invalid multibyte character: /\\xFF/"" gsub enum: ""RegexpError: invalid multibyte character: /\\xFF/"" +gsub enum is lazy: [Enumerator, Enumerator] gsub hash: ""RegexpError: invalid multibyte character: /\\xFF/"" scan: ""RegexpError: invalid multibyte character: /\\xFF/"" scan blk: ""RegexpError: invalid multibyte character: /\\xFF/"" @@ -2911,8 +2913,10 @@ public void Thread_StopAlone1() { show(""in fiber: stop"") { Fiber.new { Thread.stop }.resume } f = Fiber.new { Fiber.yield }; f.resume show(""suspended fiber: list"") { [Thread.list.size, Thread.list == [Thread.main]] } -show(""another thread stops"") { t = Thread.new { Thread.stop; :woke }; Thread.pass until t.status == ""sleep""; t.run; t.value } -show(""main stops, another wakes it"") { t = Thread.new { Thread.pass until Thread.main.status == ""sleep""; Thread.main.wakeup }; Thread.stop; t.join; :ok } +# bounded, so that a Thread.stop that raised where it should sleep fails the test instead of hanging it +def wait_for(seconds = 10); deadline = Time.now + seconds; Thread.pass until yield || Time.now > deadline; end +show(""another thread stops"") { t = Thread.new { Thread.stop; :woke }; begin; wait_for { !t.alive? || t.status == ""sleep"" }; t.run if t.alive?; t.value; ensure; t.kill; end } +show(""main stops, another wakes it"") { t = Thread.new { wait_for { Thread.main.status == ""sleep"" }; Thread.main.wakeup }; begin; Thread.stop; t.join; :ok; ensure; t.kill; end } show(""fiber of another thread"") { Thread.new { Fiber.new { Thread.list.size }.resume }.value } f.resume ", @" @@ -2967,6 +2971,7 @@ public void Windows_NotImplementedAndLinks1() { show(""symlink to file"") { File.symlink(""f"", ""s""); [File.symlink?(""s""), File.readlink(""s""), File.read(""s"")] } show(""symlink to dir"") { File.symlink(""sub"", ""sd""); [File.symlink?(""sd""), File.directory?(""sd""), File.readlink(""sd"")] } show(""symlink dangling"") { File.symlink(""sub/x"", ""sn""); [File.symlink?(""sn""), File.readlink(""sn"")] } + show(""relative symlink in a subdirectory"") { Dir.mkdir(""d2""); Dir.mkdir(""d2/tgt""); File.symlink(""tgt"", ""d2/ln""); [File.symlink?(""d2/ln""), File.directory?(""d2/ln""), File.readlink(""d2/ln"")] } show(""lstat and stat"") { [File.lstat(""s"").symlink?, File.stat(""s"").symlink?, File.ftype(""s""), File.lstat(""sd"").symlink?, File.ftype(""sn"")] } show(""symlink existing"") { File.symlink(""f"", ""s"") } show(""readlink not a link"") { File.readlink(""f"") } @@ -2992,6 +2997,7 @@ public void Windows_NotImplementedAndLinks1() { symlink to file: [true, ""f"", ""x""] symlink to dir: [true, true, ""sub""] symlink dangling: [true, ""sub/x""] +relative symlink in a subdirectory: [true, true, ""tgt""] lstat and stat: [true, false, ""link"", true, ""link""] symlink existing: Errno::EEXIST readlink not a link: Errno::EINVAL diff --git a/Src/Libraries/Builtins/FileOps.cs b/Src/Libraries/Builtins/FileOps.cs index 0523d0c31..1d2a1e447 100644 --- a/Src/Libraries/Builtins/FileOps.cs +++ b/Src/Libraries/Builtins/FileOps.cs @@ -1238,8 +1238,10 @@ private static int Win32ErrorToErrno(int error) { /// /// win32.c's symlink(): the target with its slashes turned around, a directory link when the - /// target names a directory (relative to the current directory, as there), created without - /// the privilege where Windows allows that - which the BCL asks for itself. + /// target names a directory, created without the privilege where Windows allows that - + /// which the BCL asks for itself. A relative target is looked up from the link's directory, + /// where the link will resolve it, as ruby/ruby master's win32.c does (4.0.0 looked in the + /// current directory, and made a file link to a directory that is not there). /// private static void WindowsSymLink(string/*!*/ strOld, string/*!*/ strNew) { if (strNew.Length == 0) { @@ -1249,8 +1251,12 @@ private static void WindowsSymLink(string/*!*/ strOld, string/*!*/ strNew) { throw Posix.Error(Posix.EINVAL, "(" + strOld + ", " + strNew + ")"); } string target = strOld.Replace('/', '\\'); + string link = strNew.Replace('/', '\\'); + bool independent = (target.Length >= 2 && char.IsAsciiLetter(target[0]) && target[1] == ':') || target[0] == '\\'; + int separator = link.LastIndexOf('\\'); + string probe = (!independent && separator >= 0) ? link.Substring(0, separator + 1) + target : target; try { - if (Directory.Exists(target)) { + if (Directory.Exists(probe)) { Directory.CreateSymbolicLink(strNew, target); } else { File.CreateSymbolicLink(strNew, target); @@ -2097,13 +2103,13 @@ public static object RDeviceIdMinor(FileSystemInfo/*!*/ self) { [RubyMethod("directory?")] public static bool IsDirectory(FileSystemInfo/*!*/ self) { var d = D(self); - return d != null ? d.FileType == Posix.S_IFDIR : (L(self) is DirectoryInfo); + return d != null ? d.FileType == Posix.S_IFDIR : (!IsLinkStat(self) && L(self) is DirectoryInfo); } [RubyMethod("file?")] public static bool IsFile(FileSystemInfo/*!*/ self) { var d = D(self); - return d != null ? d.FileType == Posix.S_IFREG : (L(self) is FileInfo); + return d != null ? d.FileType == Posix.S_IFREG : (!IsLinkStat(self) && L(self) is FileInfo); } [RubyMethod("pipe?")] @@ -2121,19 +2127,20 @@ public static bool IsSocket(FileSystemInfo/*!*/ self) { [RubyMethod("symlink?")] public static bool IsSymLink(FileSystemInfo/*!*/ self) { var d = D(self); - if (d == null) { - var si = self as StatInfo; - return si != null && si.IsLink; - } - return d.FileType == Posix.S_IFLNK; + return d != null ? d.FileType == Posix.S_IFLNK : IsLinkStat(self); + } + + // Off Unix: an lstat of a symbolic link or junction (StatInfo.IsLink). + private static bool IsLinkStat(FileSystemInfo/*!*/ self) { + var si = self as StatInfo; + return si != null && si.IsLink; } [RubyMethod("ftype")] public static MutableString/*!*/ FileType(FileSystemInfo/*!*/ self) { var d = D(self); if (d == null) { - var si = self as StatInfo; - if (si != null && si.IsLink) { + if (IsLinkStat(self)) { return MutableString.CreateAscii("link"); } return MutableString.CreateAscii(IsFile(self) ? "file" : "directory"); @@ -2332,7 +2339,8 @@ public static int Mode(FileSystemInfo/*!*/ self) { if (d != null) { return d.Mode; } - int mode = (L(self) is FileInfo) ? 0x8000 : 0x4000; + // an lstat of a link describes the link: S_IFLNK, not the type of what it points at + int mode = IsLinkStat(self) ? Posix.S_IFLNK : (L(self) is FileInfo) ? 0x8000 : 0x4000; mode |= 0x100; // S_IREAD; if ((L(self).Attributes & FileAttributes.ReadOnly) == 0) { mode |= 0x80; // S_IWRITE; diff --git a/Src/Libraries/Builtins/MutableStringOps.cs b/Src/Libraries/Builtins/MutableStringOps.cs index d31a0c00c..802c79ac8 100644 --- a/Src/Libraries/Builtins/MutableStringOps.cs +++ b/Src/Libraries/Builtins/MutableStringOps.cs @@ -3279,18 +3279,44 @@ public static object ReplaceFirst(RubyScope/*!*/ scope, MutableString/*!*/ self, throw RubyExceptions.CreateArgumentError("wrong number of arguments (given 1, expected 2)"); } + // The pattern is converted - and a String one checked (RubyRegex.FromLiteralPattern) - when + // the Enumerator runs, not when it is made: MRI's str_gsub returns the Enumerator before + // get_pat_quoted, so "abc".gsub("\xFF") is an Enumerator whose #to_a raises. [RubyMethod("gsub")] - public static object ReplaceAll(ConversionStorage/*!*/ tosConversion, RubyScope/*!*/ scope, - MutableString/*!*/ self, [DefaultProtocol, NotNull]RubyRegex/*!*/ pattern) { + // + // The binder may pick these for a call with a block too (an object parameter needs no + // conversion), so they do the work at once when there is one, as MRI does. + public static object ReplaceAll(ConversionStorage/*!*/ tosConversion, ConversionStorage/*!*/ stringTryCast, + RubyScope/*!*/ scope, BlockParam block, MutableString/*!*/ self, object pattern) { + if (block != null) { + return BlockReplaceAll(tosConversion, scope, block, self, ToPattern(stringTryCast, pattern)); + } scope.MarkCaptured(); - return new Enumerator((_, block) => BlockReplaceAll(tosConversion, scope, block, self, pattern)); + return new Enumerator((_, b) => BlockReplaceAll(tosConversion, scope, b, self, ToPattern(stringTryCast, pattern))); } [RubyMethod("gsub!")] - public static object ReplaceAllInPlace(ConversionStorage/*!*/ tosConversion, RubyScope/*!*/ scope, - MutableString/*!*/ self, [DefaultProtocol, NotNull]RubyRegex/*!*/ pattern) { + public static object ReplaceAllInPlace(ConversionStorage/*!*/ tosConversion, ConversionStorage/*!*/ stringTryCast, + RubyScope/*!*/ scope, BlockParam block, MutableString/*!*/ self, object pattern) { + if (block != null) { + return BlockReplaceInPlace(tosConversion, scope, block, self, ToPattern(stringTryCast, pattern), true); + } scope.MarkCaptured(); - return new Enumerator((_, block) => BlockReplaceInPlace(tosConversion, scope, block, self, pattern, true)); + return new Enumerator((_, b) => BlockReplaceInPlace(tosConversion, scope, b, self, ToPattern(stringTryCast, pattern), true)); + } + + // MRI's get_pat_quoted: a Regexp as it is, a String (or what #to_str makes one) as a + // literal pattern, anything else Check_Type's TypeError. + private static RubyRegex/*!*/ ToPattern(ConversionStorage/*!*/ stringTryCast, object pattern) { + var regex = pattern as RubyRegex; + if (regex != null) { + return regex; + } + MutableString str = Protocols.TryCastToString(stringTryCast, pattern); + if (str == null) { + throw RubyExceptions.CreateUnexpectedTypeError(stringTryCast.Context, pattern, "Regexp"); + } + return RubyRegex.FromLiteralPattern(str); } #endregion diff --git a/Src/Libraries/Builtins/ThreadOps.Fiber.cs b/Src/Libraries/Builtins/ThreadOps.Fiber.cs index a95a0fc34..acabbc088 100644 --- a/Src/Libraries/Builtins/ThreadOps.Fiber.cs +++ b/Src/Libraries/Builtins/ThreadOps.Fiber.cs @@ -269,6 +269,11 @@ private void Activate() { var info = _threadInfo ?? (_threadInfo = RubyThreadInfo.FromThread(Thread.CurrentThread)); info[_currentKey] = handle; info.FiberExecution = this; + if (!_root) { + // set before it is ever cleared again, so that no moment between two fibers - + // FiberExecution gone, not yet parked - counts this thread as a Ruby thread + info.IsFiberWorker = true; + } RubyUtils.SetFiberOwnerThread(_owner); _mailbox.GetOwnerInfo(_owner).ActiveFiberThread = Thread.CurrentThread; } diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index f930cf0f3..3f9570c22 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -213,7 +213,24 @@ internal Thread Thread { /// (a host's, the main thread). Each RubyContext is a VM of its own, as far as which /// threads it has goes. /// - internal RubyContext Context { get; set; } + internal RubyContext Context { + get { + RubyContext context; + return (_context != null && _context.TryGetTarget(out context)) ? context : null; + } + set { _context = (value != null) ? new WeakReference(value) : null; } + } + + // Weak: _mapping keeps every entry for good, and a strong reference here would keep a + // runtime the host has let go of alive for as long as the process runs. + private WeakReference _context; + + /// + /// A CLR thread that has run a fiber of some thread's (ThreadOps.Fiber.cs). It is never a + /// Ruby thread itself, before, between or after its fibers - Thread.current inside a + /// fiber is the thread that owns it - so the Ruby thread lists leave it out for good. + /// + internal volatile bool IsFiberWorker; internal bool ExitRequested { get; set; } internal MutableString Name { get; set; } internal bool ReportOnException { get; set; } @@ -325,9 +342,9 @@ internal static RubyThreadInfo[] Threads { /// /// The Ruby threads of a runtime, as Thread.list and ThreadGroup#list see them: - /// less the threads another runtime's Thread.new made - two - /// ScriptRuntimes in one process are two VMs, and neither lists the other's threads - and - /// less the CLR threads that run (or hold suspended) a fiber of another thread's. A fiber + /// less the threads of another runtime - two ScriptRuntimes in one + /// process are two VMs, and neither lists the other's threads - and less the CLR threads + /// that run (or have run, or hold suspended) a fiber of a thread's. A fiber /// has a CLR thread of its own here, but in MRI it is part of the thread that owns it, and /// Thread.current inside it is that thread. /// @@ -335,7 +352,13 @@ internal static RubyThreadInfo[] Threads { RubyThreadInfo[] all = Threads; var result = new List(all.Length); foreach (RubyThreadInfo info in all) { - if (info.Context != null && info.Context != context) { + if (info.IsFiberWorker) { + continue; + } + // A thread no Thread.new made (a host's) belongs to the runtime it is the main + // thread of, or else to the one asking from it now. + RubyContext owner = info.Context; + if (owner != null ? owner != context : (info.Thread != context.MainThread && info.Thread != Thread.CurrentThread)) { continue; } FiberCore fiber = info.FiberExecution; diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index c7191593b..994d1904e 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -8204,18 +8204,18 @@ private static void LoadString_Instance(IronRuby.Builtins.RubyModule/*!*/ module ); DefineLibraryMethod(module, "gsub", 0x51, - 0x00000014U, 0x00000014U, 0x00100060U, 0x00040008U, + 0x00000014U, 0x00000014U, 0x00100060U, 0x00000000U, new Func, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object>(IronRuby.Builtins.MutableStringOps.BlockReplaceAll), new Func, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.MutableString, IronRuby.Builtins.MutableString, System.Object>(IronRuby.Builtins.MutableStringOps.BlockReplaceAll), new Func, IronRuby.Runtime.BinaryOpStorage, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyScope, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object, IronRuby.Builtins.MutableString>(IronRuby.Builtins.MutableStringOps.ReplaceAll), - new Func, IronRuby.Runtime.RubyScope, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object>(IronRuby.Builtins.MutableStringOps.ReplaceAll) + new Func, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.MutableString, System.Object, System.Object>(IronRuby.Builtins.MutableStringOps.ReplaceAll) ); DefineLibraryMethod(module, "gsub!", 0x51, - 0x00080014U, 0x00100060U, 0x00040008U, + 0x00080014U, 0x00100060U, 0x00000000U, new Func, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object>(IronRuby.Builtins.MutableStringOps.BlockReplaceAllInPlace), new Func, IronRuby.Runtime.BinaryOpStorage, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyScope, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object, IronRuby.Builtins.MutableString>(IronRuby.Builtins.MutableStringOps.ReplaceAllInPlace), - new Func, IronRuby.Runtime.RubyScope, IronRuby.Builtins.MutableString, IronRuby.Builtins.RubyRegex, System.Object>(IronRuby.Builtins.MutableStringOps.ReplaceAllInPlace) + new Func, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, IronRuby.Builtins.MutableString, System.Object, System.Object>(IronRuby.Builtins.MutableStringOps.ReplaceAllInPlace) ); DefineLibraryMethod(module, "hex", 0x51, From ac976ee88859949c19218fb8851ac4b598fd179e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:40:48 +0000 Subject: [PATCH 5/7] Second review: frozen gsub!, encoding check order, Windows stat of a link, symlink test needs the right From CodeRabbit's second review of #110. - gsub! raises FrozenError on a frozen receiver before anything else, the Enumerator included, as rb_str_gsub_bang checks first. The deferred overload returned an Enumerator, or with a block converted the pattern first. - String#slice!, #[] / #slice and #[]= with a String now check encoding compatibility first, as rb_str_index does (rb_enc_check). An incompatible needle, broken or not, is Encoding::CompatibilityError. It used to be nil or "string not matched". The broken case came in with this PR; the valid one was older. - Windows: File.stat (and every other following stat) of a symbolic link answers for what the link leads to, as stat(2) does: its type and size. A dangling link is ENOENT. The BCL's FileInfo of a link describes the link itself, so File.size of a link was the link's own length. - Windows: the mode of an lstat of a link has S_IEXEC as well as S_IFLNK, as win32.c's fileattr_to_unixmode gives it. - The symlink cases moved from Windows_NotImplementedAndLinks1 to a new Windows_SymLinks1. It runs only where the process may make symbolic links (an administrator, or Developer Mode), as found by making one. Elsewhere File.symlink is EACCES, correctly, and the old test would have failed on the environment rather than the code. The new test also checks lstat of a link (neither file? nor directory?, S_IFLNK and S_IEXEC), stat following a link (file?, size, directory?), and stat of a dangling link (ENOENT). Testing (Linux, net8.0) - A probe of the frozen gsub! and encoding-order cases (13 lines) matches CRuby 3.3 line for line, as do the earlier String_BrokenPattern1, Thread_StopAlone1 and gsub-enumerator scripts. - Windows_SymLinks1's script prints the same under CRuby and IronRuby on Linux. - IronRuby.Tests: PASSED, 0 FAILED. - ruby/spec: core/string slice_spec.rb (44), element_set_spec.rb (86), gsub_spec.rb (77), index_spec.rb (34), element_reference_spec.rb (61; 1 error from mspec's fixnum_max helper, as before), core/file/stat (118) and lstat_spec.rb: 0 failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 72 ++++++++++++++++++---- Src/Libraries/Builtins/FileOps.cs | 28 +++++++-- Src/Libraries/Builtins/MutableStringOps.cs | 6 ++ 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 53607da8a..763cfcbe4 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -860,6 +860,7 @@ public Tests(Driver/*!*/ driver) { String_BrokenPattern1, Thread_StopAlone1, Windows_NotImplementedAndLinks1, + Windows_SymLinks1, Range_IntegerFastPaths1, Method_LightFrame1, Method_LightFrame2, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 2a42b42cf..7e6084e5d 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2937,9 +2937,10 @@ public void Thread_StopAlone1() { /// win32.c supplies. Dir.fchdir, Dir.for_fd, Dir#fileno, Dir.chroot, File.lutime, File.mkfifo and the /// process-group and set-id parts of Process are rb_f_notimplement stubs there: NotImplementedError, /// and #respond_to? false. Dir's threw CLR NotImplementedException or InvalidOperationException, and - /// all of them answered #respond_to? with true. File.link, File.symlink and File.readlink work there, - /// through CreateHardLinkW, CreateSymbolicLinkW and the reparse point; they raised NotImplementedError. - /// Windows only. The expected output is MRI's as its win32 sources give it, not a recorded run. + /// all of them answered #respond_to? with true. File.link and File.readlink work there, + /// through CreateHardLinkW and the reparse point; they raised NotImplementedError. File.symlink's + /// cases are Windows_SymLinks1, which needs the right to make symbolic links. Windows only. The + /// expected output is MRI's as its win32 sources give it, not a recorded run. /// public void Windows_NotImplementedAndLinks1() { if (Environment.OSVersion.Platform != PlatformID.Win32NT) { @@ -2968,12 +2969,6 @@ public void Windows_NotImplementedAndLinks1() { show(""link"") { File.link(""f"", ""h""); File.read(""h"") } show(""link existing"") { File.link(""f"", ""h"") } show(""link missing"") { File.link(""nope"", ""h2"") } - show(""symlink to file"") { File.symlink(""f"", ""s""); [File.symlink?(""s""), File.readlink(""s""), File.read(""s"")] } - show(""symlink to dir"") { File.symlink(""sub"", ""sd""); [File.symlink?(""sd""), File.directory?(""sd""), File.readlink(""sd"")] } - show(""symlink dangling"") { File.symlink(""sub/x"", ""sn""); [File.symlink?(""sn""), File.readlink(""sn"")] } - show(""relative symlink in a subdirectory"") { Dir.mkdir(""d2""); Dir.mkdir(""d2/tgt""); File.symlink(""tgt"", ""d2/ln""); [File.symlink?(""d2/ln""), File.directory?(""d2/ln""), File.readlink(""d2/ln"")] } - show(""lstat and stat"") { [File.lstat(""s"").symlink?, File.stat(""s"").symlink?, File.ftype(""s""), File.lstat(""sd"").symlink?, File.ftype(""sn"")] } - show(""symlink existing"") { File.symlink(""f"", ""s"") } show(""readlink not a link"") { File.readlink(""f"") } show(""readlink missing"") { File.readlink(""nope"") } end @@ -2994,17 +2989,72 @@ public void Windows_NotImplementedAndLinks1() { link: ""x"" link existing: Errno::EEXIST link missing: Errno::ENOENT +readlink not a link: Errno::EINVAL +readlink missing: Errno::ENOENT +"); + } + + /// + /// File.symlink and the File::Stat of a link on Windows, as win32.c has them: CreateSymbolicLinkW + /// with a directory link for a directory target (looked up from the link's directory, as ruby/ruby + /// master does), readlink of the reparse point, an lstat that describes the link itself (S_IFLNK and + /// S_IEXEC, neither file? nor directory?), and a stat that follows it - ENOENT for a dangling one. + /// Windows only, and only where this process may make symbolic links (an administrator, or Developer + /// Mode): elsewhere File.symlink is EACCES, as in MRI, and there is nothing here to check. The + /// expected output is MRI's as its win32 sources give it; the same script prints the same under + /// CRuby and IronRuby on Linux. + /// + public void Windows_SymLinks1() { + if (Environment.OSVersion.Platform != PlatformID.Win32NT || !CanCreateSymbolicLinks()) { + return; + } + TestOutput(@" +require 'ruby4' +require 'tmpdir' +require 'fileutils' +def show(l); v = begin; yield; rescue Exception => e; e.is_a?(SystemCallError) ? e.class : ""#{e.class}: #{e.message}""; end; puts ""#{l}: #{v.inspect}""; end +dir = Dir.mktmpdir +begin + Dir.chdir(dir) do + File.write(""f"", ""x"") + Dir.mkdir(""sub"") + show(""symlink to file"") { File.symlink(""f"", ""s""); [File.symlink?(""s""), File.readlink(""s""), File.read(""s"")] } + show(""symlink to dir"") { File.symlink(""sub"", ""sd""); [File.symlink?(""sd""), File.directory?(""sd""), File.readlink(""sd"")] } + show(""symlink dangling"") { File.symlink(""sub/x"", ""sn""); [File.symlink?(""sn""), File.readlink(""sn"")] } + show(""relative symlink in a subdirectory"") { Dir.mkdir(""d2""); Dir.mkdir(""d2/tgt""); File.symlink(""tgt"", ""d2/ln""); [File.symlink?(""d2/ln""), File.directory?(""d2/ln""), File.readlink(""d2/ln"")] } + show(""lstat and stat"") { [File.lstat(""s"").symlink?, File.stat(""s"").symlink?, File.ftype(""s""), File.lstat(""sd"").symlink?, File.ftype(""sn"")] } + show(""lstat of a link"") { st = File.lstat(""s""); [st.file?, st.directory?, st.symlink?, st.mode & 0170000 == 0120000, st.mode & 0100 != 0] } + show(""stat follows the link"") { [File.stat(""s"").file?, File.size(""s""), File.stat(""sd"").directory?] } + show(""stat of a dangling link"") { File.stat(""sn"") } + show(""symlink existing"") { File.symlink(""f"", ""s"") } + end +ensure + FileUtils.rm_rf(dir) rescue nil +end +", @" symlink to file: [true, ""f"", ""x""] symlink to dir: [true, true, ""sub""] symlink dangling: [true, ""sub/x""] relative symlink in a subdirectory: [true, true, ""tgt""] lstat and stat: [true, false, ""link"", true, ""link""] +lstat of a link: [false, false, true, true, true] +stat follows the link: [true, 1, true] +stat of a dangling link: Errno::ENOENT symlink existing: Errno::EEXIST -readlink not a link: Errno::EINVAL -readlink missing: Errno::ENOENT "); } + private static bool CanCreateSymbolicLinks() { + string link = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ir-symlink-probe-" + Guid.NewGuid().ToString("N")); + try { + System.IO.File.CreateSymbolicLink(link, "target"); + System.IO.File.Delete(link); + return true; + } catch (Exception) { + return false; + } + } + /// /// Range#first(n) and #last(n) of an Integer range are counted rather than walked - #last(n) /// built the whole range first, so (1..10**9).last(3) ran out of memory - and #last takes diff --git a/Src/Libraries/Builtins/FileOps.cs b/Src/Libraries/Builtins/FileOps.cs index 1d2a1e447..4d9df39a6 100644 --- a/Src/Libraries/Builtins/FileOps.cs +++ b/Src/Libraries/Builtins/FileOps.cs @@ -1931,13 +1931,28 @@ internal static bool TryCreate(RubyContext/*!*/ context, string/*!*/ path, bool } PlatformAdaptationLayer pal = context.Platform; - if (!followLinks) { - // lstat: a link answers for itself, dangling or not, as win32.c's does - FileSystemInfo link = TryGetLink(path); - if (link != null) { + FileSystemInfo link = TryGetLink(path); + if (link != null) { + if (!followLinks) { + // lstat: a link answers for itself, dangling or not, as win32.c's does result = new StatInfo(link) { IsLink = true }; return true; } + // stat: what the link leads to - the BCL's FileInfo of a link describes the link + // (its length, its times) - and ENOENT for a link that leads nowhere + FileSystemInfo target; + try { + target = link.ResolveLinkTarget(true); + } catch (IOException e) { + errno = Win32ErrorToErrno(e.HResult & 0xFFFF); + return false; + } + if (target == null || !target.Exists) { + errno = Posix.ENOENT; + return false; + } + result = new StatInfo(target); + return true; } if (pal.FileExists(path)) { result = new StatInfo(new FileInfo(path)); @@ -2339,8 +2354,9 @@ public static int Mode(FileSystemInfo/*!*/ self) { if (d != null) { return d.Mode; } - // an lstat of a link describes the link: S_IFLNK, not the type of what it points at - int mode = IsLinkStat(self) ? Posix.S_IFLNK : (L(self) is FileInfo) ? 0x8000 : 0x4000; + // an lstat of a link describes the link: S_IFLNK, and S_IEXEC as win32.c gives a link, + // not the type of what it points at + int mode = IsLinkStat(self) ? Posix.S_IFLNK | 0x40 : (L(self) is FileInfo) ? 0x8000 : 0x4000; mode |= 0x100; // S_IREAD; if ((L(self).Attributes & FileAttributes.ReadOnly) == 0) { mode |= 0x80; // S_IWRITE; diff --git a/Src/Libraries/Builtins/MutableStringOps.cs b/Src/Libraries/Builtins/MutableStringOps.cs index 802c79ac8..81a942010 100644 --- a/Src/Libraries/Builtins/MutableStringOps.cs +++ b/Src/Libraries/Builtins/MutableStringOps.cs @@ -932,6 +932,8 @@ public static MutableString RemoveSubstringInPlace(MutableString/*!*/ self, [Not // CLR indices of the character representations: a byte-level operation (#start_with?, // #getbyte) may have left either one as bytes. + // rb_str_index: incompatible encodings are an error before anything is searched + self.RequireCompatibleEncoding(searchStr); if (IsBrokenNeedle(searchStr)) { return null; } @@ -1071,6 +1073,7 @@ public static MutableString GetSubstring(ConversionStorage/*!*/ fixnumCast, [RubyMethod("[]")] [RubyMethod("slice")] public static MutableString GetSubstring(MutableString/*!*/ self, [NotNull]MutableString/*!*/ searchStr) { + self.RequireCompatibleEncoding(searchStr); return (!IsBrokenNeedle(searchStr) && self.IndexOf(searchStr) != -1) ? searchStr.CloneDerived() : null; } @@ -1287,6 +1290,7 @@ public static MutableString ReplaceSubstring(MutableString/*!*/ self, [NotNull]MutableString/*!*/ substring, [DefaultProtocol, NotNull]MutableString/*!*/ value) { // see RemoveSubstringInPlace(self, searchStr) + self.RequireCompatibleEncoding(substring); self.PrepareForCharacterRead(); substring.PrepareForCharacterRead(); int index = IsBrokenNeedle(substring) ? -1 : self.IndexOf(substring); @@ -3298,6 +3302,8 @@ public static object ReplaceAll(ConversionStorage/*!*/ tosConvers [RubyMethod("gsub!")] public static object ReplaceAllInPlace(ConversionStorage/*!*/ tosConversion, ConversionStorage/*!*/ stringTryCast, RubyScope/*!*/ scope, BlockParam block, MutableString/*!*/ self, object pattern) { + // rb_str_gsub_bang checks before anything else, the Enumerator included + self.RequireNotFrozen(); if (block != null) { return BlockReplaceInPlace(tosConversion, scope, block, self, ToPattern(stringTryCast, pattern), true); } From e49a8d1447288606a594eca08a320aa51c6dc4e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:34:39 +0000 Subject: [PATCH 6/7] Third review: count the runtime's main thread, keep EACCES on Windows stat - Thread.list/ThreadGroup#list/Thread.stop: register the runtime's main thread before enumerating, so a host that creates the runtime on one thread and runs Ruby on another still sees Thread.main (and Thread.stop no longer raises "stopping only thread" there). - Windows stat through a link: ask the file system for the target's attributes instead of FileSystemInfo.Exists, so a denied target reports EACCES, not ENOENT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 28 +++++++++++++++++++++++++ Src/Libraries/Builtins/FileOps.cs | 22 ++++++++++++++++++- Src/Libraries/Builtins/ThreadOps.cs | 6 ++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 763cfcbe4..818de2473 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -859,6 +859,7 @@ public Tests(Driver/*!*/ driver) { IO_SysOpenPermission1, String_BrokenPattern1, Thread_StopAlone1, + Thread_HostMainThread1, Windows_NotImplementedAndLinks1, Windows_SymLinks1, Range_IntegerFastPaths1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 7e6084e5d..2f4620460 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2902,6 +2902,34 @@ public void String_BrokenPattern1() { /// owns it, so neither it nor its thread counts as another thread - for Thread.stop, nor for Thread.list /// and ThreadGroup#list, which used to list it. /// + /// + /// A host that creates the runtime on one thread and runs Ruby on another: the creating + /// thread is Thread.main and is listed even though no Ruby code ever ran on it. + /// + public void Thread_HostMainThread1() { + Microsoft.Scripting.Hosting.ScriptEngine engine = null; + var created = new System.Threading.ManualResetEvent(false); + var done = new System.Threading.ManualResetEvent(false); + var creator = new System.Threading.Thread(() => { + engine = Ruby.CreateEngine(); + created.Set(); + done.WaitOne(); + }); + creator.IsBackground = true; + creator.Start(); + try { + Assert(created.WaitOne(30000)); + var result = engine.Execute(@" +[Thread.list.include?(Thread.main), Thread.list.size, Thread.current != Thread.main] +"); + Assert(((RubyArray)result)[0].Equals(true)); + Assert(((RubyArray)result)[1].Equals(2)); + Assert(((RubyArray)result)[2].Equals(true)); + } finally { + done.Set(); + } + } + public void Thread_StopAlone1() { TestOutput(@" require 'ruby4' diff --git a/Src/Libraries/Builtins/FileOps.cs b/Src/Libraries/Builtins/FileOps.cs index 4d9df39a6..5069d863c 100644 --- a/Src/Libraries/Builtins/FileOps.cs +++ b/Src/Libraries/Builtins/FileOps.cs @@ -1943,13 +1943,33 @@ internal static bool TryCreate(RubyContext/*!*/ context, string/*!*/ path, bool FileSystemInfo target; try { target = link.ResolveLinkTarget(true); + } catch (UnauthorizedAccessException) { + errno = Posix.EACCES; + return false; } catch (IOException e) { errno = Win32ErrorToErrno(e.HResult & 0xFFFF); return false; } - if (target == null || !target.Exists) { + if (target == null) { + errno = Posix.ENOENT; + return false; + } + // FileSystemInfo.Exists says false for any failure; ask the file system so a + // denied target reports EACCES, not ENOENT + try { + File.GetAttributes(target.FullName); + } catch (UnauthorizedAccessException) { + errno = Posix.EACCES; + return false; + } catch (FileNotFoundException) { + errno = Posix.ENOENT; + return false; + } catch (DirectoryNotFoundException) { errno = Posix.ENOENT; return false; + } catch (IOException e) { + errno = Win32ErrorToErrno(e.HResult & 0xFFFF); + return false; } result = new StatInfo(target); return true; diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index 457d38d1d..5a72d2374 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -349,6 +349,12 @@ internal static RubyThreadInfo[] Threads { /// Thread.current inside it is that thread. /// internal static RubyThreadInfo[]/*!*/ GetRubyThreads(RubyContext/*!*/ context) { + // The runtime's main thread counts even when no Ruby code has run on it yet + // (a host that creates the runtime on one thread and runs Ruby on another). + Thread main = context.MainThread; + if (main != null && main.IsAlive) { + FromThread(main); + } RubyThreadInfo[] all = Threads; var result = new List(all.Length); foreach (RubyThreadInfo info in all) { From 0d95a5f1a7257b4dd92dc9a2508437c09a2424d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:53:33 +0000 Subject: [PATCH 7/7] Thread.list: a runtime's main thread is its own, whoever made it A runtime created on a thread another runtime's Thread.new made records that thread as its main thread, but the thread's owner is the other runtime, so Thread.list there left Thread.main out. The main thread is now always listed; the owner check applies to the other threads. Also move Thread_StopAlone1's doc comment back onto it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQju1jrNNxqsQbg2YTbPf --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 39 +++++++++++++++++++++---- Src/Libraries/Builtins/ThreadOps.cs | 7 +++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 7ee435683..71564ec72 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -860,6 +860,7 @@ public Tests(Driver/*!*/ driver) { String_BrokenPattern1, Thread_StopAlone1, Thread_HostMainThread1, + Thread_HostMainThread2, Windows_NotImplementedAndLinks1, Windows_SymLinks1, Range_IntegerFastPaths1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 9ccc53075..f95a30f2f 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2896,12 +2896,6 @@ public void String_BrokenPattern1() { "); } - /// - /// Thread.stop with no other thread alive is MRI's ThreadError "stopping only thread" (rb_thread_stop), - /// where it slept forever. A fiber runs on a CLR thread of its own here but is part of the thread that - /// owns it, so neither it nor its thread counts as another thread - for Thread.stop, nor for Thread.list - /// and ThreadGroup#list, which used to list it. - /// /// /// A host that creates the runtime on one thread and runs Ruby on another: the creating /// thread is Thread.main and is listed even though no Ruby code ever ran on it. @@ -2930,6 +2924,39 @@ public void Thread_HostMainThread1() { } } + /// + /// A runtime created on a thread another runtime's Thread.new made: that thread is the new + /// runtime's Thread.main and is listed there, whoever made it. + /// + public void Thread_HostMainThread2() { + Microsoft.Scripting.Hosting.ScriptEngine engine = null; + var created = new System.Threading.ManualResetEvent(false); + var done = new System.Threading.ManualResetEvent(false); + Action create = () => { + engine = Ruby.CreateEngine(); + created.Set(); + done.WaitOne(); + }; + var scope = Engine.CreateScope(); + scope.SetVariable("create", create); + Engine.Execute("$host_main_thread2 = Thread.new { create.invoke }", scope); + try { + Assert(created.WaitOne(30000)); + var result = (RubyArray)engine.Execute("[Thread.list.include?(Thread.main), Thread.list.size]"); + Assert(result[0].Equals(true)); + Assert(result[1].Equals(2)); + } finally { + done.Set(); + Engine.Execute("$host_main_thread2.join; $host_main_thread2 = nil"); + } + } + + /// + /// Thread.stop with no other thread alive is MRI's ThreadError "stopping only thread" (rb_thread_stop), + /// where it slept forever. A fiber runs on a CLR thread of its own here but is part of the thread that + /// owns it, so neither it nor its thread counts as another thread - for Thread.stop, nor for Thread.list + /// and ThreadGroup#list, which used to list it. + /// public void Thread_StopAlone1() { TestOutput(@" require 'ruby4' diff --git a/Src/Libraries/Builtins/ThreadOps.cs b/Src/Libraries/Builtins/ThreadOps.cs index 5a72d2374..2c940243d 100644 --- a/Src/Libraries/Builtins/ThreadOps.cs +++ b/Src/Libraries/Builtins/ThreadOps.cs @@ -361,10 +361,11 @@ internal static RubyThreadInfo[] Threads { if (info.IsFiberWorker) { continue; } - // A thread no Thread.new made (a host's) belongs to the runtime it is the main - // thread of, or else to the one asking from it now. + // The runtime's main thread is always its own, even when another runtime's Thread.new + // made it; any other thread belongs to the runtime that made it, or, made by none (a + // host's), to the one asking from it now. RubyContext owner = info.Context; - if (owner != null ? owner != context : (info.Thread != context.MainThread && info.Thread != Thread.CurrentThread)) { + if (info.Thread != context.MainThread && (owner != null ? owner != context : info.Thread != Thread.CurrentThread)) { continue; } FiberCore fiber = info.FiberExecution;