Skip to content

Windows and CLR interop fixes: boxed struct fields, zlib, File::NULL, Process.kill, JSON control characters - #25

Merged
Largo merged 9 commits into
masterfrom
fix/clr-interop-and-windows
Oct 26, 2026
Merged

Largo merged 9 commits into
masterfrom
fix/clr-interop-and-windows

Conversation

@Largo

@Largo Largo commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Five fixes for problems that show up when IronRuby code runs as a long-lived plugin inside a Windows .NET service, plus a diagnosis of net/http over TLS. Each fix has a test in IronRuby.Tests that fails without it.

Fixes

  • Field stores into a boxed .NET struct went to a copy (RubyFieldInfo). s = SomeStruct.new; s.Field = x was built as Assign(Field(Convert(target))), and Convert unboxes into a temporary. Once a call site was compiled, the store was lost silently. OpenSSL::PKey fills RSAParameters this way, so loading the same PEM failed after about 35 loads ("The specified RSA parameters are not valid"); with the current upstream DLR it fails on the first. Stores now go through Unbox. ClrValueTypes1, commented out since 2011 as "fails with /noadaptive", runs again; ClrValueTypes3 fills a struct and RSAParameters in a method called 100 times.
  • zlib on Windows. require "zlib" was a LoadError, because there is no libz. ClrZlib runs the same LibZ entry points on the zlib that .NET itself ships (System.IO.Compression.Native), so ZStream, Inflate, Deflate and the gzip classes work unchanged. Linux and macOS keep calling libz exactly as before. What .NET does not export raises NotImplementedError: set_dictionary, Deflate#params once data has gone in, and Inflate#sync_point?. The README says so.
  • IO::NULL is "NUL" on Windows, as in CRuby; it was "/dev/null", so spawn(..., out: File::NULL) failed. A redirection that cannot be opened now names the file, not the program (ENOENT, EACCES or EISDIR, using CRuby's map_errno choices).
  • Process.kill on Windows P/Invoked libc's kill() and raised DllNotFoundException. It now follows CRuby's win32 kill():
    • KILL is TerminateProcess (exit code 0, as in CRuby), and 0 asks whether the process is still running.
    • INT is a console Ctrl+C, or Ctrl+Break for a process group.
    • A signal the process sends itself from another thread runs its trap handler on the main thread.
    • Anything else is EINVAL.
    • A child's handle is only used under the children table's lock, so a thread reaping the child cannot close it mid-kill.
  • JSON.parse rejects raw control characters (U+0000–U+001F) inside strings, as the json gem does, and supports the gem's allow_control_characters: true (2.18.0).

Tests only

Diagnosis only

  • Docs/net-http-over-tls.md: on Windows, net/http over plain sockets is fine (IO.select on a socket is Socket.Select, not poll(2)). Over TLS, read, write and open timeouts never fire, and ca_file/cert_store are ignored. The doc proposes a two-stage fix; Util/net-http-tls-server.rb (CRuby) and Util/net-http-tls-check.rb reproduce the problem. The fix itself is a separate branch stacked on this one.

Testing

Run on Windows 11, .NET 10, Debug only; Linux, macOS, net8.0 and Release are left to CI.

  • IronRuby.Tests: full suite, 0 failures.
  • ruby/spec, against the 4.0.1 release, in core/time, core/file, core/process, core/kernel, core/marshal, core/signal, library/zlib, library/openssl and library/stringio:
    • No regressions.
    • library/zlib now loads: 148 examples, 2 failures and 3 errors. The failures compare classic zlib's exact output bytes; the errors are the three NotImplementedErrors.
    • core/process/kill: 2 errors → 0.

Notes

Largo added 6 commits October 26, 2026 12:48
RubyFieldInfo built a field store as Assign(Field(Convert(target, S), f), v).
Convert unboxes into a temporary, so once the rule is compiled the store
changes that copy and the box Ruby holds keeps its old fields - silently.
OpenSSL::PKey fills RSAParameters / DSAParameters / ECParameters this way, so
a PEM loaded about 35 times and then failed for the rest of the process
("The specified RSA parameters are not valid"): the interpreter updated the
box, compiled code did not. With today's upstream DLR the interpreter copies
too (IronLanguages/dlr#368), and it fails on the first load.

The store now goes through Unbox, which addresses the value inside the box,
in compiled and interpreted code alike. Calls to mutating methods on a boxed
struct already reached the box.

ClrValueTypes1, commented out since 2011 as "fails with /noadaptive", is the
same bug and runs again; ClrValueTypes3 fills S2 and RSAParameters in a method
called 100 times and checks every result.
Time_MachineZone1 compares Time.at, Time.local and Time.now with what
TimeZoneInfo.Local says for a winter and a summer instant, so it holds in any
machine zone, and checks that a local time's zone survives Marshal.
Time_OlsonZone1 sets TZ=Europe/Zurich, which Windows resolves through
TimeZoneInfo since it has no zoneinfo files. Both fail on 4.0.1 on Windows,
where the zone was TimeZoneInfo.Local's base offset alone.
require "zlib" was a LoadError on Windows: there is no libz there, and the
binding's z_stream is laid out for LP64. Every .NET runtime carries zlib in
System.IO.Compression.Native, though (zlib-ng since .NET 9, Intel's zlib in
.NET 8), behind CompressionNative_* exports over its own PAL_ZStream.

ClrZlib runs LibZ's entry points on those exports, so ZStream, Inflate,
Deflate and the gzip classes in zlib.rb work unchanged: the z_stream record
is only managed code's on Windows; next_in/avail_in/next_out/avail_out go to
the PAL stream and back, and total_in, total_out and adler (the running
adler32/crc32, or the dictionary id on Z_NEED_DICT) are kept as libz keeps
them. adler32, crc32, their _combine and crc_table are computed as zlib does.
A reset begins the stream again with its parameters (.NET 8 exports no reset).
What the exports leave out raises NotImplementedError: set_dictionary,
Deflate#params once data has gone in, Inflate#sync_point?. Linux and macOS
keep calling libz exactly as before.

ruby/spec library/zlib on Windows: 148 examples, 2 failures (they compare
classic zlib's exact compressed bytes), 3 errors (the NotImplementedErrors);
before, all 42 files were the LoadError. Zlib_Streams1 inflates input CRuby
compressed and checks round trips, totals, adler and the checksums, so it
holds on libz and on .NET's zlib alike.
Process.spawn(cmd, out: File::NULL) failed on Windows with ENOENT, and
Process.kill raised System::DllNotFoundException.

IO::NULL (and File::NULL) came from the prelude as "/dev/null" everywhere;
Windows has no such file, so the redirect could not be opened. It is "NUL"
there, as in CRuby. The error itself named the program, not the file:
WindowsSpawn handed back only -errno and the prelude filled in the command.
A redirection that cannot be opened now raises with its own path - ENOENT,
EACCES, or EISDIR for a directory - as CRuby does, which opens it in the
parent.

Process.kill P/Invoked libc's kill(), which Windows does not have.
WindowsSpawn.Kill does what CRuby's win32 kill() does: KILL is
TerminateProcess (exit code 0, as there), 0 reports whether the process still
runs (ESRCH once it has exited), INT is a console Ctrl+C - Ctrl+Break aimed at
a process group - and every other signal is EINVAL.

ruby/spec on Windows: core/process/kill 2 errors -> 0; core/process/spawn,
core/kernel/spawn and core/kernel/system unchanged otherwise.
Process_SpawnKill1 spawns a child that blocks on a pipe, signals 0 and KILL,
waits, and checks a missing redirection is named.
RFC 8259 has U+0000-U+001F escaped inside a string, and the json gem raises
"invalid ASCII control character in string" for an unescaped one. IronRuby's
parser took them as they were, so a document with a literal line break in a
value parsed here and failed on CRuby - code that falls back on a
ParserError for exactly that case never ran its fallback.

JSON.parse(source, allow_control_characters: true), the gem's option since
2.18.0 (the API version this JSON reports), lets them through. DEL and
escaped control characters stay fine.
net/http is said to be undependable on Windows because IO.select on a socket
is poll(2). Measured: plain sockets are fine (Socket.Select on Windows, and
read_timeout fires); over TLS nothing times out - SSLSocket's read_nonblock,
write_nonblock and connect_nonblock block in SslStream and never return
:wait_readable, which is the only path net/protocol applies read_timeout on -
and ca_file/cert_store/verify_callback are not consulted.

Docs/net-http-over-tls.md has the diagnosis and a two-stage proposal:
timeouts on the blocking SslStream calls plus the context's CAs first (small),
real non-blocking reads through IoReadiness later. Util/net-http-tls-server.rb
(CRuby) and Util/net-http-tls-check.rb reproduce both findings.
@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8807a180-6907-4f50-9bbe-cf35eb1e0606
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Largo added 3 commits October 26, 2026 13:49
…-windows

# Conflicts:
#	Src/IronRuby.Tests/RubyTests.cs
Raw inflate on Windows / .NET 8 stopped after the first 16 KB: the run loop
ends an inflate run once avail_in is 0, which with libz means it has nothing
more to give. .NET 8's zlib is Intel's fork, which reads all of the input
ahead into its bit buffer, so with the output full and no input left it can
still have output to hand out. A zlib or gzip stream hid this, because its
trailer was still unread.

On .NET's zlib the loop asks again; when inflate really is done it leaves the
fresh buffer untouched and the avail_out check ends the run. libz keeps the
old condition. Zlib_Streams1 caught it on CI (windows-latest net8.0); with
this its output matches CRuby's on .NET 8, and ruby/spec library/zlib there
has 0 failures and the 3 NotImplementedErrors.
@Largo
Largo merged commit 3189a75 into master Oct 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant