Skip to content

Upgrade to Rutie 0.10.2 and make the extension memory safe - #182

Merged
danielpclark merged 11 commits into
masterfrom
rutie-0.10.2
Oct 2, 2026
Merged

danielpclark merged 11 commits into
masterfrom
rutie-0.10.2

Conversation

@danielpclark

@danielpclark danielpclark commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Upgrades Rutie from 0.6.0 to 0.10.2. It also rewrites the Ruby bindings so that no input from Ruby can crash the interpreter, implements Ruby's Windows path rules, and moves CI to GitHub Actions.

Why

Rutie 0.10 makes Object::send unsafe, so the old bindings no longer compile. They also weren't safe. On Ruby 2.7.8, current master:

  • segfaults inside its own test suite, in FasterPath.join. It calls rb_scan_args through a transmuted argv.
  • aborts the process (a Rust panic crossing into C) for FasterPath.add_trailing_separator(nil), FasterPath.join() with no arguments, and any path that isn't valid UTF-8 (for example FasterPath.basename("\xFF".b)).

test/safety_test.rb covers all of these. Every one of those tests kills Ruby on master and passes on this branch.

Rust extension

  • Arguments: methods read their arguments as a slice; no more rb_scan_args or transmute. Wrong argument counts raise ArgumentError.
  • Panics: every method catches panics and raises them as RuntimeError.
  • Exceptions: Ruby exceptions are raised only after all Rust values are dropped, so the longjmp never skips a destructor.
  • Calling Ruby code: to_path, to_s and respond_to? are only called through protect_send. Strings they return are copied before any more Ruby code runs, so the GC can't free them while we use them.
  • Bytes and encodings:
    • Paths are handled as bytes in the string's own encoding, not as UTF-8 &str. Results keep the input's encoding.
    • Strings that aren't ASCII-compatible (such as UTF-16) raise Encoding::CompatibilityError, and null bytes raise ArgumentError, as Ruby does.
    • basename only removes an extension at a character boundary, so cp932 strings behave as in Ruby.
  • to_path: path arguments may be objects responding to to_path, like Ruby's File methods accept.
  • Directory errors:
    • Errors raise the correct Errno::* with Ruby's message. The old code named Errno::NOENT, which doesn't exist.
    • An unreadable directory entry raises instead of panicking.
  • Pathname creation: Pathnames are built by setting @path, as Pathname#initialize does, so no Ruby code runs while listing a directory.
  • Cleanups:
    • Removed the unsafe word-at-a-time memrnchr. Its fast path could never trigger.
    • Dropped lazy_static.
    • Build a cdylib and use edition 2021.

The remaining unsafe code is a few small, documented calls into Ruby's C API:

  • reading argv;
  • rb_enc_mbclen, for the character-boundary checks;
  • rb_enc_get_index and rb_enc_str_new, which replace Encoding#== and Encoding#ascii_compatible? (both Ruby method calls, and slower than the path operation itself);
  • rb_obj_alloc, used to build Pathnames.

Windows support

On master, FasterPath split paths only on std::path::MAIN_SEPARATOR. On Windows that's \, so it ignored / (which is also Ruby's File::SEPARATOR there), and most of its own tests failed on Windows.

The path functions are now ports of Ruby's own: file.c for basename, dirname, extname and File.join, and pathname.rb for the Pathname helpers. They follow both sets of rules Ruby is built with:

  • Unix: / is the only separator.
  • Windows:
    • / and \ are both separators;
    • drive letters (C:) and UNC prefixes (//server/share) are kept whole;
    • NTFS ignores trailing dots, spaces and :stream names at the end of a file name;
    • names compare case-insensitively;
    • in cp932 and similar encodings, a \ that is the second byte of a character isn't a separator;
    • directory?("dir/...") is false, as with Ruby's stat.

Both sets of rules are always compiled and unit-tested, so the Windows rules are tested on every platform. On Windows, paths are converted to UTF-8 for the OS, and file names are converted to the filesystem encoding, as Ruby's Dir does.

Windows CI ran FasterPath's Windows-only tests for the first time. Two of them had the wrong expectations, and now match the same cases in Ruby's test_pathname.rb:

  • add_trailing_separator('A:') returns 'A:';
  • the cleanpath_conservative case had its input and output swapped.

Behaviour now matching Ruby

Call Before Now (as Ruby)
extname("..foo"), extname("a/..b") ".foo", ".b" ""
extname("foo.") on Ruby 2.7 (Unix) "" "." (detected from the running Ruby)
cleanpath_conservative("a/b/") "b/" "a/b/"
del_trailing_separator("") "/" ""
relative_path_from errors own wording Pathname's messages

Every method was compared with Ruby's own File/Pathname on random paths: 200,000 on Ruby 2.7 and 100,000 each on 2.5 and 2.6 (Linux). The paths mixed /, \, :, spaces, dots and non-ASCII characters, and every result matched.

Tests

  • Hidden tests: many classes in test/refinements and test/ripple_effects had the same names as the main test classes. Their same-named methods replaced each other, so several tests never ran (including the ones that exposed the del_trailing_separator and extname bugs). They now have unique names.
  • Encoding tests: the ENCODING=true configuration, which Travis allowed to fail, now passes and is required.
  • mspec submodules: spec/ruby_spec and spec/mspec move to the last ruby/spec commit that supports Ruby 2.5. The old pin predates Ruby 2.7 and fails on it even without FasterPath loaded.

CI: GitHub Actions replaces Travis and AppVeyor

travis-ci.org no longer runs, and AppVeyor wasn't reporting on PRs, so .travis.yml and .appveyor.yml are replaced by .github/workflows/ci.yml:

  • Test matrix: Ruby 2.5, 2.6 and 2.7 on Linux and Windows, each with the three settings Travis used (regression, monkeypatches, encoding), plus Ruby 2.7 on macOS.
  • Each job runs the full rake test: cargo tests, minitest, rubocop, pbench and mspec. rake test now runs mspec through Ruby, so it also works on Windows.
  • Releases: on a v* tag, CI builds the precompiled extension tarballs and attaches them to the GitHub release, as Travis's deploy step did.

All 19 jobs pass on b1e5d1c, the latest code change.

Dependency fixes that a fresh bundle install needed:

  • The Gemfile declares rubygems.org as its global source. With only a source block, Bundler couldn't find a minitest for Ruby 2.6.
  • minitest-reporters is ~> 1.1 instead of exactly 1.1, which crashes with current minitest.
  • The test helper defines MiniTest, which color_pound_spec_reporter uses to print failures and minitest 5.19+ no longer defines.

Supported Rubies

Rutie 0.10 supports Ruby 2.5–2.7 (64-bit only on Windows), so the gemspec now sets required_ruby_version = ['>= 2.5', '< 3.0'].

Versions

  • Gem: 0.3.10 → 0.4.0 (lib/faster_path/version.rb, and the README's install line).
  • Crate: 0.0.1 → 0.1.0 (Cargo.toml and Cargo.lock).

Thermite names the precompiled tarballs after the crate version and downloads them from the v<gem version> release, so the v0.4.0 tag's release job will build faster_path-0.1.0-*.tar.gz, which is the name the 0.4.0 gem looks for.

Benchmarks

Measured with the project's Pinch-bench, LONG_RUN=10 rake pbench (500,000 calls of each method, 5 times). Each figure is the median of 3 runs, alternating between master and this branch, on Linux x86_64 with Rust 1.97. The percentages are the time shaved off compared with Ruby's own implementation; higher is better. The "change" columns are in percentage points. Runs vary by a few points. The README has the same tables.

Method Ruby 2.7 before Ruby 2.7 after Change Ruby 2.5 before Ruby 2.5 after Change
absolute? 93.3% 95.7% +2.4 96.1% 96.7% +0.6
add_trailing_separator 66.6% 85.4% +18.8 64.3% 83.0% +18.7
basename 42.4% 42.4% 0.0 32.1% 39.1% +7.0
children 52.8% 54.7% +1.9 46.5% 47.8% +1.3
children_compat −1.2% 32.1% +33.3 1.1% 26.6% +25.5
chop_basename 71.2% 70.4% −0.8 74.7% 75.1% +0.4
cleanpath_aggressive 90.3% 90.3% 0.0 92.7% 90.6% −2.1
cleanpath_conservative 89.8% 89.6% −0.2 91.7% 91.6% −0.1
del_trailing_separator 86.5% 85.3% −1.2 87.0% 87.6% +0.6
directory? 33.2% 34.3% +1.1 37.2% 37.5% +0.3
dirname 49.8% 56.8% +7.0 51.2% 54.7% +3.5
entries 40.4% 38.0% −2.4 33.6% 33.9% +0.3
entries_compat −29.8% 16.8% +46.6 −23.3% 9.2% +32.5
extname 70.3% 74.0% +3.7 74.0% 73.1% −0.9
has_trailing_separator? 87.5% 88.5% +1.0 87.1% 89.1% +2.0
join crashes 88.1% crashes 90.3%
plus 92.3% 91.7% −0.6 93.0% 93.4% +0.4
relative? 90.7% 92.8% +2.1 93.6% 95.2% +1.6
relative_path_from 92.7% 92.5% −0.2 94.0% 93.6% −0.4
  • join: master's pbench segfaults in FasterPath.join, so master was benchmarked with only that entry removed.
  • Largest gains:
    • add_trailing_separator no longer builds a temporary string;
    • no method checks its arguments for valid UTF-8 any more;
    • the _compat methods build Pathnames without calling Pathname.new.
  • basename first measured slower on Ruby 2.5 (34.6% → 28.9%). Callgrind showed the new code doing about 63 more instructions per call than master: it went through the Windows-capable path scan on Unix, processed an empty extension, and used memchr's SIMD searches, whose setup costs more than they save on short paths. b1e5d1c gives Unix a plain scan and skips empty extensions. Its row was then re-measured (Ruby 2.5 runs before: 40.0, 32.1, 28.7%; after: 29.6, 39.1, 39.2%). Instructions per call (callgrind), master → branch:
    • Ruby 2.5: 2439 → 2331;
    • Ruby 2.7: 2689 → 2561.
  • No regression from the Windows code on Unix: these numbers include it.

Verification

  • Locally, on Ruby 2.5.7, 2.6.6 and 2.7.8 (Linux):
    • cargo test: 65 passed;
    • minitest in all four settings (plain, regression, monkeypatches, encoding): 0 failures;
    • mspec in its three modes: 0 failures;
    • rubocop (0.53): clean;
    • gem build and the ext/Rakefile thermite build work;
    • fresh bundle install followed by bundle exec rake test passes.
  • CI: all 19 jobs pass on Linux, Windows and macOS. On Windows, mspec runs 102 examples, including the Windows-only ones, with FasterPath monkeypatched into File and Pathname.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe

claude added 2 commits October 1, 2026 23:14
Rutie 0.10 makes `Object::send` unsafe, so the bindings are rewritten
around what the new version offers, and so that no input from Ruby can
crash the interpreter. Before this, the test suite itself segfaulted
Ruby 2.7 in `FasterPath.join`, and invalid UTF-8, `nil` or a missing
argument aborted the process from a Rust panic.

Rust extension:
- `join` used `rb_scan_args` through a `transmute`d `argv`; it now reads
  the arguments as a slice and raises `ArgumentError` when given none.
- Every method catches panics and raises them as `RuntimeError`, raises
  Ruby exceptions only after Rust values are dropped, and calls Ruby
  code (`to_path`, `to_s`, `respond_to?`) only through `protect_send`.
- Paths are handled as bytes in the string's own encoding instead of
  `&str`, so non-UTF-8 paths work; results keep the input's encoding.
  Strings that aren't ASCII-compatible raise
  `Encoding::CompatibilityError` and null bytes raise `ArgumentError`,
  as in Ruby.
- Path arguments may respond to `to_path`, as with Ruby's `File`
  methods. Strings returned by Ruby code are copied before more Ruby
  code runs, so the GC can't free them while in use.
- Directory errors raise the right `Errno::*` (the code named a
  nonexistent `Errno::NOENT`); unreadable entries no longer panic.
- `Pathname`s are built by setting `@path`, like `Pathname#initialize`,
  so no Ruby code runs while reading a directory.
- Wrong argument counts raise `ArgumentError`; `add_trailing_separator`
  raises `TypeError` for a non-path instead of aborting.
- Remove the unsafe word-at-a-time `memrnchr` (its fast path could never
  be taken) and `lazy_static`; build a `cdylib`; use edition 2021.

Behaviour fixes found on the way, now matching Ruby:
- `extname` ignores leading dots ("..foo", "a/..b") and follows the
  Ruby it runs in for a trailing dot (Ruby 2.7 returns ".").
- `cleanpath_conservative("a/b/")` returned "b/" instead of "a/b/".
- `del_trailing_separator("")` returned "/" instead of "".
- `basename` only removes an extension at a character boundary (cp932).
- `relative_path_from` errors use the same messages as `Pathname`.

Tests:
- Many test classes under test/refinements and test/ripple_effects had
  the same names as the main ones, so their methods replaced each other
  and several tests never ran; they now have unique names.
- Add test/safety_test.rb for the inputs that used to crash Ruby.
- The `ENCODING=true` tests now pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
Rutie 0.10 supports Ruby 2.5, 2.6 and 2.7 (64-bit only on Windows), so
the gemspec now requires those versions, and Travis and AppVeyor test
them. The encoding job passes now and is no longer allowed to fail.

The ruby/spec and mspec submodules move to the last ruby/spec commit
that supports Ruby 2.5; the old ones predate Ruby 2.7 and fail on it
without FasterPath loaded.

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

semanticdiff-com Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  src/cleanpath_aggressive.rs  42% smaller
  src/lib.rs  30% smaller
  src/relative_path_from.rs  29% smaller
  src/cleanpath_conservative.rs  22% smaller
  src/plus.rs  21% smaller
  src/pathname.rs  9% smaller
  src/path_parsing.rs  5% smaller
  src/dirname.rs  1% smaller
  .appveyor.yml  0% smaller
  .github/workflows/ci.yml  0% smaller
  .travis.yml  0% smaller
  Cargo.lock Unsupported file format
  Cargo.toml Unsupported file format
  Gemfile Unsupported file format
  README.md Unsupported file format
  Rakefile Unsupported file format
  faster_path.gemspec Unsupported file format
  lib/faster_path.rb Unsupported file format
  lib/faster_path/version.rb Unsupported file format
  spec/mspec  0% smaller
  spec/ruby_spec  0% smaller
  src/basename.rs  0% smaller
  src/chop_basename.rs  0% smaller
  src/debug.rs  0% smaller
  src/extname.rs  0% smaller
  src/helpers.rs  0% smaller
  src/memrnchr.rs  0% smaller
  src/pathname_sys.rs  0% smaller
  src/prepend_prefix.rs  0% smaller
  src/ruby.rs  0% smaller
  test/add_trailing_separator_test.rb Unsupported file format
  test/cleanpath_conservative_test.rb Unsupported file format
  test/extname_test.rb Unsupported file format
  test/refinements/absoulte_test.rb Unsupported file format
  test/refinements/add_trailing_separator_test.rb Unsupported file format
  test/refinements/basename_test.rb Unsupported file format
  test/refinements/chop_basename_test.rb Unsupported file format
  test/refinements/directory_test.rb Unsupported file format
  test/refinements/dirname_test.rb Unsupported file format
  test/refinements/extname_test.rb Unsupported file format
  test/refinements/has_trailing_separator_test.rb Unsupported file format
  test/refinements/plus_test.rb Unsupported file format
  test/refinements/relative_test.rb Unsupported file format
  test/ripple_effects/cleanpath_aggressive_test.rb Unsupported file format
  test/ripple_effects/cleanpath_conservative_test.rb Unsupported file format
  test/ripple_effects/del_trailing_separator_test.rb Unsupported file format
  test/ripple_effects/has_trailing_separator_test.rb Unsupported file format
  test/ripple_effects/join_test.rb Unsupported file format
  test/ripple_effects/plus_test.rb Unsupported file format
  test/safety_test.rb Unsupported file format
  test/test_helper.rb Unsupported file format

Travis CI (travis-ci.org) no longer runs and AppVeyor isn't reporting
on pull requests, so this replaces both with a GitHub Actions workflow:

- Linux: Ruby 2.5, 2.6 and 2.7, each with the regression, monkeypatch
  and encoding settings Travis used, running `rake test` (cargo tests,
  minitest, rubocop, pbench and mspec).
- macOS: Ruby 2.7, and Windows: Ruby 2.7 (cargo tests and minitest).
- On a version tag, build the precompiled extension tarballs and attach
  them to the GitHub release, as the Travis deploy step did.

A fresh `bundle install` no longer worked, so:
- The Gemfile declares rubygems.org as its global source. With only a
  `source` block Bundler couldn't find a minitest for Ruby 2.6.
- minitest-reporters is `~> 1.1` instead of exactly 1.1, which crashes
  with the minitest versions Bundler now picks.

The README badge points at the new workflow.

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

Copy link
Copy Markdown
Owner Author

CI status on fcbf696: all 10 Linux jobs (Ruby 2.5, 2.6, 2.7 × regression/monkeypatches/encoding) and macOS (Ruby 2.7) pass the full rake test. Ruby 2.7 on windows-latest fails: 31 of the 43 Rust unit tests fail.

This isn't caused by this PR. The new job is the first CI to report it. FasterPath's only path separator is std::path::MAIN_SEPARATOR, which is \ on Windows. On master that's pub const SEP: u8 = MAIN_SEPARATOR as u8 in src/path_parsing.rs, and this PR keeps it. The unit tests (e.g. basename("/a/b///c", "") == "c") use /. The old AppVeyor config ran these same tests (rake test), so master fails the same way on Windows; with AppVeyor no longer reporting, nobody saw it.

It's also a real behaviour gap. Ruby on Windows treats both / (File::SEPARATOR) and \ (File::ALT_SEPARATOR) as separators, and also handles drive letters and UNC paths. FasterPath only splits on \. This is the "Windows support is underway" item in the README.

No fix exists yet. A re-run won't change anything because the failure is deterministic. Making the Windows job pass means implementing Windows path rules: / and \ as separators, drive letters, UNC paths, and the DOSISH branches already in the Ruby tests. That's a feature-sized change, so I've asked for direction on whether to do it in this PR, in a follow-up, or to narrow the Windows job until then.


Generated by Claude Code

claude added 5 commits October 2, 2026 02:42
FasterPath split paths only on `std::path::MAIN_SEPARATOR`, so on
Windows it ignored `/` (Ruby's `File::SEPARATOR` there too) and the
library failed most of its own tests. The path functions are now ports
of Ruby's own (`file.c` for `basename`, `dirname`, `extname` and
`File.join`; `pathname.rb` for the `Pathname` helpers), for both sets
of rules Ruby is built with:

- Unix: `/` is the only separator.
- Windows: `/` and `\` are separators, drive letters ("C:") and UNC
  prefixes ("//server/share") are kept whole, NTFS ignores trailing
  dots, spaces and `:stream` names at the end of a file name, and names
  compare case-insensitively.

Both sets of rules are always compiled and unit tested, so the Windows
rules are tested on every platform; the extension uses the ones of the
platform it is built for. Paths are joined with `/` everywhere, as
`File.join` does.

In encodings such as cp932, `\` can be the second byte of a character;
Windows Ruby doesn't take that for a separator, and neither does
FasterPath now.

On Windows, paths are converted to UTF-8 for the OS and file names to
the filesystem encoding, as Ruby's `Dir` does. `children` joins names
with `File.join` semantics instead of the OS separator.

Every method was compared with Ruby's own on 200,000 to 100,000 random
paths on Ruby 2.5, 2.6 and 2.7 (Linux): all results match.

CI runs the full `rake test` on Windows for Ruby 2.5 to 2.7 too, with
`rake test` running mspec through Ruby so it works there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
Windows CI ran FasterPath's Windows-only tests for the first time. Two
had their expectations wrong, and now match the same cases in Ruby's
test_pathname.rb:

- `add_trailing_separator('A:')` is "A:" (`File.basename("A:a")` is
  "a"), not "A:/".
- The `cleanpath_conservative` case had its input and output swapped:
  'c:\foo\bar' cleans to 'c:/foo/bar'.

`directory?` now returns false for a path whose first "..." is a whole
name of dots, such as "dir/...", as Ruby's Windows `stat` does
(`check_valid_dir` in win32.c); Windows itself takes it for "dir".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
Ruby 2.7 returns an extension of only a dot (`File.extname("a./")` is
"."), where earlier versions return "". FasterPath checked this with
`File.extname("a.")`, which is "" on Windows whatever the version since
NTFS drops trailing dots, so on Windows with Ruby 2.7 it returned "" for
"a./". It now checks "a./", which tells on every platform.

color_pound_spec_reporter prints failures using `MiniTest`, which
minitest 5.19 and later don't define, so a failing test crashed the
reporter; the test helper defines it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
The README's "Time Shaved Off" tables now hold current pbench results
for Ruby 2.7 and 2.5, including the `_compat` methods, and `join` is
listed against `Pathname#join` under its own name. A new Benchmarks
section says how they're measured (median of 3 runs of
`LONG_RUN=10 rake pbench`) and compares every method before and after
the upgrade. The Status list no longer calls Windows and encoding
support "underway".

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

Copy link
Copy Markdown
Owner Author

Follow-up to my comment about the Windows job: Windows support is now implemented in this PR (see "Windows support" in the description). All 19 CI jobs pass on e469093, including Ruby 2.5–2.7 on Windows with the full rake test (cargo tests, minitest, rubocop, pbench, mspec). The description now also has before/after benchmarks for every method.


Generated by Claude Code

claude added 3 commits October 2, 2026 03:38
On Unix, find the last name with a plain scan instead of the general
Windows-capable path, skip extension handling when no extension is given,
and only merge encodings when they differ. Callgrind now counts 2331
instructions per call on Ruby 2.5 (was 2502; the old implementation 2439)
and 2561 on Ruby 2.7 (old implementation 2689).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
@danielpclark
danielpclark merged commit b4becc7 into master Oct 2, 2026
20 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.

2 participants