Repository navigation
Upgrade to Rutie 0.10.2 and make the extension memory safe - #182
Conversation
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
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
|
CI status on This isn't caused by this PR. The new job is the first CI to report it. FasterPath's only path separator is It's also a real behaviour gap. Ruby on Windows treats both 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: Generated by Claude Code |
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe
|
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 Generated by Claude Code |
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
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::sendunsafe, so the old bindings no longer compile. They also weren't safe. On Ruby 2.7.8, currentmaster:FasterPath.join. It callsrb_scan_argsthrough atransmutedargv.FasterPath.add_trailing_separator(nil),FasterPath.join()with no arguments, and any path that isn't valid UTF-8 (for exampleFasterPath.basename("\xFF".b)).test/safety_test.rbcovers all of these. Every one of those tests kills Ruby onmasterand passes on this branch.Rust extension
rb_scan_argsortransmute. Wrong argument counts raiseArgumentError.RuntimeError.longjmpnever skips a destructor.to_path,to_sandrespond_to?are only called throughprotect_send. Strings they return are copied before any more Ruby code runs, so the GC can't free them while we use them.&str. Results keep the input's encoding.Encoding::CompatibilityError, and null bytes raiseArgumentError, as Ruby does.basenameonly removes an extension at a character boundary, so cp932 strings behave as in Ruby.to_path: path arguments may be objects responding toto_path, like Ruby'sFilemethods accept.Errno::*with Ruby's message. The old code namedErrno::NOENT, which doesn't exist.Pathnamecreation:Pathnames are built by setting@path, asPathname#initializedoes, so no Ruby code runs while listing a directory.unsafeword-at-a-timememrnchr. Its fast path could never trigger.lazy_static.cdyliband use edition 2021.The remaining
unsafecode is a few small, documented calls into Ruby's C API:argv;rb_enc_mbclen, for the character-boundary checks;rb_enc_get_indexandrb_enc_str_new, which replaceEncoding#==andEncoding#ascii_compatible?(both Ruby method calls, and slower than the path operation itself);rb_obj_alloc, used to buildPathnames.Windows support
On
master, FasterPath split paths only onstd::path::MAIN_SEPARATOR. On Windows that's\, so it ignored/(which is also Ruby'sFile::SEPARATORthere), and most of its own tests failed on Windows.The path functions are now ports of Ruby's own:
file.cforbasename,dirname,extnameandFile.join, andpathname.rbfor thePathnamehelpers. They follow both sets of rules Ruby is built with:/is the only separator./and\are both separators;C:) and UNC prefixes (//server/share) are kept whole;:streamnames at the end of a file name;\that is the second byte of a character isn't a separator;directory?("dir/...")is false, as with Ruby'sstat.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
Dirdoes.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:';cleanpath_conservativecase had its input and output swapped.Behaviour now matching 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_fromerrorsPathname's messagesEvery method was compared with Ruby's own
File/Pathnameon 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
test/refinementsandtest/ripple_effectshad 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 thedel_trailing_separatorandextnamebugs). They now have unique names.ENCODING=trueconfiguration, which Travis allowed to fail, now passes and is required.spec/ruby_specandspec/mspecmove 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.ymland.appveyor.ymlare replaced by.github/workflows/ci.yml:rake test: cargo tests, minitest, rubocop, pbench and mspec.rake testnow runs mspec through Ruby, so it also works on Windows.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 installneeded:sourceblock, Bundler couldn't find a minitest for Ruby 2.6.minitest-reportersis~> 1.1instead of exactly1.1, which crashes with current minitest.MiniTest, whichcolor_pound_spec_reporteruses 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
0.3.10→0.4.0(lib/faster_path/version.rb, and the README's install line).0.0.1→0.1.0(Cargo.tomlandCargo.lock).Thermite names the precompiled tarballs after the crate version and downloads them from the
v<gem version>release, so thev0.4.0tag's release job will buildfaster_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 betweenmasterand 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.absolute?add_trailing_separatorbasenamechildrenchildren_compatchop_basenamecleanpath_aggressivecleanpath_conservativedel_trailing_separatordirectory?dirnameentriesentries_compatextnamehas_trailing_separator?joinplusrelative?relative_path_fromjoin:master's pbench segfaults inFasterPath.join, somasterwas benchmarked with only that entry removed.add_trailing_separatorno longer builds a temporary string;_compatmethods buildPathnames without callingPathname.new.basenamefirst measured slower on Ruby 2.5 (34.6% → 28.9%). Callgrind showed the new code doing about 63 more instructions per call thanmaster: it went through the Windows-capable path scan on Unix, processed an empty extension, and usedmemchr's SIMD searches, whose setup costs more than they save on short paths.b1e5d1cgives 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:Verification
cargo test: 65 passed;rubocop(0.53): clean;gem buildand theext/Rakefilethermite build work;bundle installfollowed bybundle exec rake testpasses.FileandPathname.🤖 Generated with Claude Code
https://claude.ai/code/session_01TCvAywuoW7ZstauXvVmkKe