Skip to content

Bundle detected DLLs into bin when their packed location is unsearchable - #56

Merged
Largo merged 1 commit into
masterfrom
fix/windows-dll-bundle-to-bin
Aug 10, 2026
Merged

Bundle detected DLLs into bin when their packed location is unsearchable#56
Largo merged 1 commit into
masterfrom
fix/windows-dll-bundle-to-bin

Conversation

@Largo

@Largo Largo commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Problem

The Windows loader resolves a native extension's imports from the extension's own directory, ruby.exe's application directory (bin) plus its ruby_builtin_dlls SxS assembly, and the system directories. PATH is not consulted on hardened systems, and the AddDllDirectory route gems take through ruby_installer/runtime does not exist in a packed app.

A detected DLL loaded from anywhere else is packed only at its original location, which the loader never searches when resolving imports:

  • a gem's own tree — e.g. FreeTDS (libsybdb-5.dll), which tiny_tds ships under ports/x64-mingw-ucrt/bin/
  • a devkit's msys64 tree inside the Ruby prefix (where e.g. libssl-3-x64.dll gets loaded from when a native gem was compiled on the build machine)
  • any directory outside the prefix — this case was skipped entirely: the next unless dll.subpath?(exec_prefix) guard made the existing copy_to_bin else-branch unreachable dead code

Packaged applications then die at require time with a misleading LoadError (cannot load such file -- tiny_tds/tiny_tds — the message comes from the gem's fat-binary fallback require, masking the real dependent-DLL failure) on machines where a rich PATH does not mask the gap. CI runners mask it: GitHub's windows runners resolve dependent DLLs via PATH and carry OpenSSL/MSYS2/Perl toolchains on it, so a broken build tests green and only fails in the field (observed with a tiny_tds app running as a Windows service on a production host — the identical exe ran fine on the runner).

Fix

Bundle a copy of every detected DLL into bin, next to ruby.exe, whenever it is not already resolvable from where it gets packed — mirroring what the Linux branch already does with LD_LIBRARY_PATH. Details:

  • DLLs under the Windows directory always come from the target system and are never bundled
  • in-prefix DLLs keep their prefix-relative copy (gems glob their ports/ dirs at runtime), and additionally land in bin unless they already live under bindir
  • companion DLLs found next to native extensions (e.g. libssl-3-x64.dll beside openssl.so in archdir) also go into bin: an archdir copy only helps extensions in archdir itself, while the same extension packed at a gem path (openssl and psych are gems since Ruby 3.x) resolves its imports from bin
  • deduplicated by basename so detected + companion passes do not pack a DLL twice

Test

test_nonsearched_dll_bundled_to_bin (Windows-only): the fixture dlopens a DLL from a ports/bin/ directory the way fat binary gems do; the packed exe asserts the DLL was bundled next to ruby.exe.

🤖 Generated with Claude Code

The Windows loader resolves a native extension's imports from the
extension's own directory, ruby.exe's application directory (bin) plus
its ruby_builtin_dlls SxS assembly, and the system directories. PATH is
not consulted on hardened systems, and the AddDllDirectory route gems
take through ruby_installer/runtime does not exist in a packed app.

A detected DLL loaded from a gem's own tree (e.g. FreeTDS, which
tiny_tds ships under ports/), from a devkit's msys64 tree inside the
Ruby prefix, or from outside the prefix entirely was packed only at its
original location, which the loader never searches for imports - the
out-of-prefix case was skipped entirely by a guard that made its
copy_to_bin branch unreachable. Packaged applications then died at
require time with a misleading LoadError ("cannot load such file --
tiny_tds/tiny_tds") on machines where a rich PATH did not mask the gap.

Bundle a copy of every such DLL into bin, next to ruby.exe, mirroring
what the Linux branch achieves with LD_LIBRARY_PATH. DLLs from the
Windows directory always come from the target system and are never
bundled. Companion DLLs found next to native extensions go into bin as
well: an archdir copy only helps extensions in archdir itself, while
the same extension packed at a gem path (openssl and psych are gems
since Ruby 3.x) resolves its imports from bin.
@Largo
Largo force-pushed the fix/windows-dll-bundle-to-bin branch from 95935f7 to e915887 Compare August 10, 2026 12:50
@Largo
Largo merged commit d6df092 into master Aug 10, 2026
16 of 17 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