Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Build/dlr-patches/0104-SharedIO-output-set-by-host.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Largo <andi@idogawa.com>
Date: Sun, 8 Nov 2026 02:30:00 +0100
Subject: SharedIO: say whether a host set the output streams

Console.OpenStandardOutput returns Stream.Null when it cannot use the
handle (a pipe whose reader has gone), and a host that wants the output
discarded passes Stream.Null to SetOutput itself: the same object, so a
language cannot tell the two apart. IsOutputSetByHost and
IsErrorOutputSetByHost say which it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
diff --git a/src/core/Microsoft.Scripting/Runtime/SharedIO.cs b/src/core/Microsoft.Scripting/Runtime/SharedIO.cs
index 4ab7605..d2dfa35 100644
--- a/src/core/Microsoft.Scripting/Runtime/SharedIO.cs
+++ b/src/core/Microsoft.Scripting/Runtime/SharedIO.cs
@@ -80,6 +80,21 @@ namespace Microsoft.Scripting.Runtime {

private Encoding? _inputEncoding;

+ // set by SetOutput / SetErrorOutput, cleared by RedirectToConsole
+ private bool _outputSetByHost;
+ private bool _errorSetByHost;
+
+ /// <summary>
+ /// Whether <see cref="OutputStream"/> is one a host set with <see cref="SetOutput"/>, rather
+ /// than the one opened for the process's standard output. The two can be the same object:
+ /// Console.OpenStandardOutput returns Stream.Null when it cannot use the handle, and a host
+ /// that wants the output discarded passes Stream.Null itself.
+ /// </summary>
+ public bool IsOutputSetByHost => _outputSetByHost;
+
+ /// <summary>As <see cref="IsOutputSetByHost"/>, for <see cref="ErrorStream"/>.</summary>
+ public bool IsErrorOutputSetByHost => _errorSetByHost;
+
public Stream InputStream { get { InitializeInput(); return _inputStream!; } }
public Stream OutputStream { get { InitializeOutput(); return _outputStream!; } }
public Stream ErrorStream { get { InitializeErrorOutput(); return _errorStream!; } }
@@ -184,6 +199,7 @@ namespace Microsoft.Scripting.Runtime {
lock (_mutex) {
_outputStream = stream;
_outputWriter = writer;
+ _outputSetByHost = true;
}
}

@@ -192,6 +208,7 @@ namespace Microsoft.Scripting.Runtime {
lock (_mutex) {
_errorStream = stream;
_errorWriter = writer;
+ _errorSetByHost = true;
}
}

@@ -213,6 +230,8 @@ namespace Microsoft.Scripting.Runtime {
_inputReader = null;
_outputWriter = null;
_errorWriter = null;
+ _outputSetByHost = false;
+ _errorSetByHost = false;
}
}

1 change: 1 addition & 0 deletions Build/dlr-patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ the apply step instead of with a confusing CS1061 deep inside IronRuby.
| `0101-startup.patch` | Startup costs the DLR adds to every process: no COM type scan of every loaded assembly (nothing reads its cache); no culture-sensitive casing or comparison in option parsing and `OpenFileStream`, which made ICU open a collator; the namespace tree reads an assembly's public type names from its metadata instead of loading every exported type (a listed type that does not load, such as a forwarder to an assembly that is not there, is skipped when the namespace is enumerated, as it was before); the call-instruction shapes the interpreter builds with `MakeGenericType` are rooted so ReadyToRun precompiles them; user-defined conversion operator lookups are cached; `BackgroundCompilation.HoldBack()` lets a host hold back the interpreter's background compilations (IronRuby does while the prelude loads); `DeclaredMemberNames.MayDeclareMethod` answers from metadata whether a type declares a method name; no CoreLib collection over a DLR struct on the startup path (their code would be JIT-compiled). |
| `0102-jit-on-fresh-stack.patch` | The CLR's JIT also recurses over the trees of the IL it compiles, and needs far more stack per level than the light compiler: compiling a lambda nested ~1500 Ruby levels deep (an array literal, say) overflowed a 1 MB stack - a Windows main thread's, or the thread `DeepNesting1` runs on - in `DynamicMethod.CreateDelegate`, which no managed check can catch. The light compiler now records how deep the tree went, and `LightDelegateCreator.Compile`, through which both synchronous and background compiles go, compiles a lambda deeper than 400 levels on a fresh stack (`StackGuard`, from `0101`). Hardly anything is that deep: in IronRuby.Tests, 3 of 4609 compiled lambdas, all from `DeepNesting1`. |
| `0103-reflection-call-rethrow.patch` | The interpreter calls a delegate with more parameters than it has call instructions for - a Ruby method with ten or so parameters or more - through `MethodInfo.Invoke`, and rethrew what the call raised with `throw inner`, which starts the exception's stack trace over: the backtrace lost every frame between the `raise` and the caller's call site, and the frames below the rescuing one (`<main>`). It now rethrows through `ExceptionDispatchInfo`, which keeps them; IronRuby hides the reflection frames themselves. |
| `0104-SharedIO-output-set-by-host.patch` | `SharedIO.IsOutputSetByHost` / `IsErrorOutputSetByHost`: whether a host set the stream with `SetOutput`. On Windows .NET opens a standard output whose pipe reader has gone as `Stream.Null`, and IronRuby then writes to the handle itself so that the write reports EPIPE; a host that passes `Stream.Null` to discard the output is the same object, and has to stay a discard. |

`Util/check-dlr-patches.sh DLR_DIR` checks that a DLR checkout has all of these applied:
a local one is patched once and falls behind when a new patch lands here.
Expand Down
2 changes: 2 additions & 0 deletions Src/IronRuby.Tests/RubyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,8 @@ public Tests(Driver/*!*/ driver) {
Thread_HostMainThread1,
Thread_HostMainThread2,
Windows_NotImplementedAndLinks1,
Windows_PipeBlocking1,
Windows_StdoutPipeSysWrite1,
Windows_SymLinks1,
Range_IntegerFastPaths1,
Io_BuiltinReaders1,
Expand Down
158 changes: 158 additions & 0 deletions Src/IronRuby.Tests/Runtime/MiscTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4053,6 +4053,164 @@ public void Thread_StopAlone1() {
");
}

/// <summary>
/// IO.pipe on Windows is a CreatePipe pipe (WindowsPipeStream). A thread blocked reading it used to
/// be Running to the CLR, so Thread#stop? never said true and ruby/spec's `Thread.pass until
/// t.stop?` spun for ever; closing the read end from another thread did not reach the blocked read;
/// and #read_nonblock / #write_nonblock blocked on an empty / full pipe. Windows only.
/// </summary>
public void Windows_PipeBlocking1() {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
return;
}
TestOutput(@"
require 'ruby4'
# bounded, so that a thread that never looks blocked fails the test instead of hanging it
def wait_for(seconds = 10); deadline = Time.now + seconds; Thread.pass until yield || Time.now > deadline; end
r, w = IO.pipe
t = Thread.new { begin; r.read; rescue IOError => e; e.message; end }
wait_for { t.stop? }
puts ""blocked reader: #{t.status.inspect}""
r.close
puts ""closed underneath: #{(t.join(10) ? t.value : :still_blocked).inspect}""
w.close
r, w = IO.pipe
puts ""empty: #{begin; r.read_nonblock(5); rescue IO::WaitReadable; :wait_readable; end.inspect}""
puts ""empty, exception: false: #{r.read_nonblock(5, exception: false).inspect}""
w.write ""ab""
puts ""two bytes: #{r.read_nonblock(5).inspect}""
n = 0
full = begin; loop { n += w.write_nonblock('a' * 10_000) }; rescue IO::WaitWritable; :wait_writable; end
puts ""full: #{full.inspect} after #{n > 0 ? 'some' : 'no'} bytes""
puts ""full, exception: false: #{w.write_nonblock('a', exception: false).inspect}""
r.close
puts ""reader gone: #{begin; w.write_nonblock('a'); rescue SystemCallError => e; e.class; end}""
w.close
r, w = IO.pipe
writer = Thread.new { w.write('a' * 100_000) }
wait_for { writer.stop? }
started = Time.now
other = begin; w.write_nonblock('b'); rescue IO::WaitWritable; :wait_writable; end
puts ""behind a blocked writer: #{other.inspect}, at once: #{Time.now - started < 2}""
r.read(100_000)
writer.join
r.close; w.close
", @"
blocked reader: ""sleep""
closed underneath: ""stream closed in another thread""
empty: :wait_readable
empty, exception: false: :wait_readable
two bytes: ""ab""
full: :wait_writable after some bytes
full, exception: false: :wait_writable
reader gone: Errno::EPIPE
behind a blocked writer: :wait_writable, at once: true
");
}

[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern IntPtr GetStdHandle(int nStdHandle);

[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool SetStdHandle(int nStdHandle, IntPtr handle);

[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool PeekNamedPipe(Microsoft.Win32.SafeHandles.SafePipeHandle handle, IntPtr buffer, int size,
IntPtr bytesRead, out int bytesAvailable, IntPtr bytesLeft);

/// <summary>
/// STDOUT redirected to a pipe on Windows. The process's standard output handle is pointed at a
/// pipe for the length of the test, and SharedIO opens it as it opens standard output (A, B), or
/// is given a stream by the host (C, D).
/// A: put into non-blocking mode with io/nonblock, #syswrite on a full pipe takes what fits and
/// then raises IO::WaitWritable, as write(2) on an O_NONBLOCK descriptor 1 does. It used to
/// block, because STDOUT's stream is the console stream and #syswrite did not look behind it
/// for the pipe.
/// B: when the pipe's reader has already gone, .NET opens standard output as Stream.Null; the
/// write still has to reach the handle and report EPIPE, where it was thrown away silently.
/// C: a host that gives SharedIO Stream.Null itself wants the output discarded: nothing
/// reaches the pipe, in blocking or non-blocking mode. D: nor does it get EPIPE when the pipe's
/// reader has gone - the same Stream.Null object as B's, which is why SharedIO says which it is
/// (IsOutputSetByHost, Build/dlr-patches/0104). Windows only.
/// </summary>
public void Windows_StdoutPipeSysWrite1() {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
return;
}
const int STD_OUTPUT_HANDLE = -11;
IntPtr saved = GetStdHandle(STD_OUTPUT_HANDLE);
Action<IntPtr, System.IO.Stream> redirect = (handle, output) => {
SetStdHandle(STD_OUTPUT_HANDLE, handle);
ConsoleStream.ResetStandardDescriptors();
if (output == null) {
Runtime.IO.RedirectToConsole();
} else {
Runtime.IO.SetOutput(output, System.IO.TextWriter.Null);
}
};
// Nothing may be printed in here: standard output is the pipe under test.
try {
// A: a pipe nobody reads, filled
using (var pipe = new System.IO.Pipes.AnonymousPipeServerStream(System.IO.Pipes.PipeDirection.In)) {
redirect(pipe.ClientSafePipeHandle.DangerousGetHandle(), null);
var result = Engine.Execute<string>(@"
require 'io/nonblock'
STDOUT.nonblock = true
begin
first = STDOUT.syswrite('a' * 2_000_000)
second = begin; STDOUT.syswrite('b' * 10); rescue IO::WaitWritable; 'wait_writable'; end
""#{first > 0 && first < 2_000_000 ? 'short write' : first} then #{second}""
ensure
STDOUT.nonblock = false
end
").ToString();
Assert(result == "short write then wait_writable", result);
}

// B: the write end of a pipe whose reader has gone
using (var pipe = new System.IO.Pipes.AnonymousPipeServerStream(System.IO.Pipes.PipeDirection.Out)) {
pipe.DisposeLocalCopyOfClientHandle();
redirect(pipe.SafePipeHandle.DangerousGetHandle(), null);
Assert(Console.OpenStandardOutput() == System.IO.Stream.Null, "expected .NET to open a broken pipe as Stream.Null");
var result = Engine.Execute<string>(@"
begin; STDOUT.syswrite('x'); 'written'; rescue Errno::EPIPE; 'epipe'; end
").ToString();
Assert(result == "epipe", result);
}

// C: a pipe nobody reads, and a host that discards the output
using (var pipe = new System.IO.Pipes.AnonymousPipeServerStream(System.IO.Pipes.PipeDirection.In)) {
redirect(pipe.ClientSafePipeHandle.DangerousGetHandle(), System.IO.Stream.Null);
Engine.Execute(@"
require 'io/nonblock'
STDOUT.write('hidden'); STDOUT.flush
STDOUT.syswrite('x')
STDOUT.nonblock = true
STDOUT.syswrite('y')
STDOUT.nonblock = false
");
int available;
Assert(PeekNamedPipe(pipe.SafePipeHandle, IntPtr.Zero, 0, IntPtr.Zero, out available, IntPtr.Zero));
Assert(available == 0, available + " bytes reached the pipe");
}

// D: the write end of a pipe whose reader has gone, and a host that discards the output
using (var pipe = new System.IO.Pipes.AnonymousPipeServerStream(System.IO.Pipes.PipeDirection.Out)) {
pipe.DisposeLocalCopyOfClientHandle();
redirect(pipe.SafePipeHandle.DangerousGetHandle(), System.IO.Stream.Null);
var result = Engine.Execute<string>(@"
STDOUT.write('hidden'); STDOUT.flush
begin; STDOUT.syswrite('x').to_s; rescue Errno::EPIPE, SignalException => e; e.class.name; end
").ToString();
Assert(result == "1", result);
}
} finally {
SetStdHandle(STD_OUTPUT_HANDLE, saved);
ConsoleStream.ResetStandardDescriptors();
Runtime.IO.RedirectToConsole();
}
}

/// <summary>
/// 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
Expand Down
26 changes: 20 additions & 6 deletions Src/Libraries/Builtins/IoOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1762,14 +1762,28 @@ public static int SysWrite(BinaryOpStorage/*!*/ writeStorage, ConversionStorage<

// MRI's syswrite is one write(2), so a pipe that only had room for part of the string
// reports what it took rather than waiting for the rest to fit.
var pipe = DescriptorStream.Of(self.GetStream().BaseStream);
var baseStream = self.GetStream().BaseStream;
var pipe = (ISingleWriteStream)DescriptorStream.Of(baseStream) ?? baseStream as ISingleWriteStream;
// Windows has no O_NONBLOCK on the handle for the write to go by; io/nonblock's
// #nonblock= remembers the flag in the IO instead (see io/nonblock.rb). It is this IO's
// and this call's: another IO over the same pipe may be in the other mode. The pipe can
// also be the one behind STDOUT or STDERR, redirected; a blocking syswrite there keeps
// going through the standard stream as before.
object nonblock;
var windowsPipe = context.TryGetInstanceVariable(self, "@__nonblock__", out nonblock) && nonblock is bool && (bool)nonblock
? WindowsPipeStream.Of(baseStream) : null;
bool nonBlocking = windowsPipe != null;
int count = val.GetByteCount();
if (pipe != null && count > 0) {
// the write(2) blocks on a full pipe, so IO#timeout is waited out before it
self.WaitForStream(false);
if ((pipe != null || nonBlocking) && count > 0) {
if (!nonBlocking) {
// the write(2) blocks on a full pipe, so IO#timeout is waited out before it
self.WaitForStream(false);
}
int written;
try {
written = pipe.WriteOnce(val.ToByteArray(), 0, count);
written = nonBlocking
? windowsPipe.WriteNonBlocking(val.ToByteArray(), 0, count)
: pipe.WriteOnce(val.ToByteArray(), 0, count);
} catch (IOException e) {
throw TranslateStreamError(e);
}
Expand Down Expand Up @@ -2054,7 +2068,7 @@ private static MutableString ReadOnceWithoutWaiting(RubyIO/*!*/ io, int count, M
return (queued == 0 && buffered == 0) ? null : buffer;
}

var pipe = DescriptorStream.Of(stream.BaseStream);
var pipe = (INonBlockingReadStream)DescriptorStream.Of(stream.BaseStream) ?? stream.BaseStream as INonBlockingReadStream;
if (pipe == null) {
// A Windows pipe has no descriptor, and a ReadFile on an empty one waits for the
// writer however long that is, but PeekNamedPipe says whether anything is there.
Expand Down
Loading
Loading