diff --git a/CHANGELOG.md b/CHANGELOG.md index a000245..7347f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,18 +28,14 @@ Please file a bug if you notice a violation of semantic versioning. ### Fixed -- Fix a teardown race that intermittently dropped a worker's results. The exit-watcher thread previously signaled process exit as soon as the child was reaped, before the stdout/stderr readers had drained the pipe. Under CI contention handle_messages could reach process_count exits and stop while that worker's rows were still queued behind the exit signal, so they were never reported. Symptom was a nested run reporting "1 example, 0 failures" instead of "4 examples, 0 failures, 3 pending", with which worker vanished varying run to run (spec/integration/multi_process_spec.rb failed at :46 on one run and :52-54 on the next). Readers are now drained to EOF before the watcher signals exit, and the exit/error messages are a deduped fallback for a reader force-stopped before EOF. Drain uses one shared monotonic deadline across both streams (READER_DRAIN_TIMEOUT) so a wedged pipe whose write end stays open cannot double teardown time or hang; force-stop still closes the pipe and kills the reader as before. - -- Fix spec discovery returning absolute paths on Windows, which crashed parallel_tests' File.stat during group sizing. rspec_configured_files_to_run made discovered paths relative to the cwd with a string prefix check. On Windows the cwd and the discovered files can name the same directory with two different spellings: Dir.pwd and ENV["TEMP"] report the 8.3 short name (C:/Users/RUNNER~1/AppData/Local/Temp) while Dir.glob, which RSpec uses to expand --pattern, reports the long name (C:/Users/runneradmin/AppData/Local/Temp). The prefix therefore never matched and the absolute temp path leaked through. File.realpath does not expand 8.3 short names, so canonicalizing first cannot reconcile the two spellings, and Pathname#relative_path_from returns nonsense such as "../../../../../runneradmin/...". The same class of mismatch arises from a symlinked ancestor on any platform, e.g. a Dir.pwd of /var/home/u/proj against a path spelled /home/u/proj/... Discovered paths are now made relative by walking up from the file and comparing each ancestor with File.identical?, which compares by filesystem identity (inode on Unix, file index on Windows) and so treats every spelling of one directory as equal. Extracted as TurboTests::Utils::Paths.relative_from/.within?. - ### Security -## [3.2.14] - 2026-10-05 +## [3.2.14] - 2026-10-07 - TAG: [v3.2.14][3.2.14t] -- COVERAGE: 96.81% -- 910/940 lines in 10 files -- BRANCH COVERAGE: 84.72% -- 244/288 branches in 10 files -- 36.73% documented +- COVERAGE: 96.93% -- 947/977 lines in 11 files +- BRANCH COVERAGE: 85.57% -- 255/298 branches in 11 files +- 39.42% documented ### Changed @@ -56,6 +52,10 @@ Please file a bug if you notice a violation of semantic versioning. - rspec_configured_files_to_run: normalize discovered spec paths against the root cwd instead of the nearest ancestor .rspec. In stacked monorepos (root .rspec aggregating gems/*/spec via --pattern, where each gem also has its own .rspec), the nearest-ancestor walk stripped aggregated specs to bare member-relative spec/... paths that do not exist at the root, crashing parallel_tests File.stat during group sizing and aborting the whole run. Regression introduced in 3.2.13. +- Fix a teardown race that intermittently dropped a worker's results. The exit-watcher thread previously signaled process exit as soon as the child was reaped, before the stdout/stderr readers had drained the pipe. Under CI contention handle_messages could reach process_count exits and stop while that worker's rows were still queued behind the exit signal, so they were never reported. Symptom was a nested run reporting "1 example, 0 failures" instead of "4 examples, 0 failures, 3 pending", with which worker vanished varying run to run (spec/integration/multi_process_spec.rb failed at :46 on one run and :52-54 on the next). Readers are now drained to EOF before the watcher signals exit, and the exit/error messages are a deduped fallback for a reader force-stopped before EOF. Drain uses one shared monotonic deadline across both streams (READER_DRAIN_TIMEOUT) so a wedged pipe whose write end stays open cannot double teardown time or hang; force-stop still closes the pipe and kills the reader as before. + +- Fix spec discovery returning absolute paths on Windows, which crashed parallel_tests' File.stat during group sizing. rspec_configured_files_to_run made discovered paths relative to the cwd with a string prefix check. On Windows the cwd and the discovered files can name the same directory with two different spellings: Dir.pwd and ENV["TEMP"] report the 8.3 short name (C:/Users/RUNNER~1/AppData/Local/Temp) while Dir.glob, which RSpec uses to expand --pattern, reports the long name (C:/Users/runneradmin/AppData/Local/Temp). The prefix therefore never matched and the absolute temp path leaked through. File.realpath does not expand 8.3 short names, so canonicalizing first cannot reconcile the two spellings, and Pathname#relative_path_from returns nonsense such as "../../../../../runneradmin/...". The same class of mismatch arises from a symlinked ancestor on any platform, e.g. a Dir.pwd of /var/home/u/proj against a path spelled /home/u/proj/... Discovered paths are now made relative by walking up from the file and comparing each ancestor with File.identical?, which compares by filesystem identity (inode on Unix, file index on Windows) and so treats every spelling of one directory as equal. Extracted as TurboTests::Utils::Paths.relative_from/.within?. + ## [3.2.13] - 2026-10-01 - TAG: [v3.2.13][3.2.13t] diff --git a/Gemfile.lock b/Gemfile.lock index e5cff06..d2238d9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,7 +138,7 @@ GEM geom2d (0.4.1) gitmoji-regex (2.0.15) version_gem (~> 1.1, >= 1.1.15) - hexapdf (1.11.0) + hexapdf (1.11.1) cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.4, >= 0.4.1) openssl (>= 2.2.1) @@ -149,7 +149,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.21.2) + json (3.0.2) json-merge (7.1.9) ast-merge (= 7.1.9) tree_haver (= 7.1.9) @@ -158,7 +158,7 @@ GEM ast-crispr-markdown-markly (~> 7.1, >= 7.1.1) kettle-dev (~> 3.1, >= 3.1.2) version_gem (~> 1.1, >= 1.1.15) - kettle-dev (3.1.7) + kettle-dev (3.1.8) emoji_regex (>= 15.0) kettle-gha-pins (~> 0.3, >= 0.3.16) kettle-ndjson (~> 0.1, >= 0.1.18) @@ -167,11 +167,11 @@ GEM ruby-progressbar (~> 1.13) tty-progressbar (~> 0.18) version_gem (~> 1.1, >= 1.1.15) - kettle-family (1.3.5) + kettle-family (1.3.6) command_kit (~> 0.6) command_kit-completion (~> 0.1) kettle-changelog (~> 1.0, >= 1.0.8) - kettle-dev (~> 3.1, >= 3.1.7) + kettle-dev (~> 3.1, >= 3.1.8) kettle-gha-pins (~> 0.3, >= 0.3.16) kettle-jem (~> 7.1, >= 7.1.28) kettle-test (~> 2.0, >= 2.0.23) @@ -358,8 +358,8 @@ GEM rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.88.2) - json (~> 2.3) + rubocop (1.91.0) + json (>= 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) parallel (>= 1.10) @@ -407,9 +407,9 @@ GEM rubocop-md (2.0.4) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-minitest (0.40.0) + rubocop-minitest (0.41.0) lint_roller (~> 1.1) - rubocop (>= 1.75.0, < 2.0) + rubocop (>= 1.89.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) rubocop-on-rbs (2.2.0) lint_roller (~> 1.1) @@ -429,9 +429,9 @@ GEM rubocop-rspec-extra (0.3.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) - rubocop-rspec_rails (2.32.0) + rubocop-rspec_rails (2.33.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) + rubocop (~> 1.89) rubocop-rspec (~> 3.5) rubocop-ruby2_4 (3.0.8) rubocop-gradual (~> 0.3, >= 0.3.6) @@ -440,9 +440,9 @@ GEM rubocop-thread_safety (~> 0.7, >= 0.7.3) standard-rubocop-lts (~> 2.0, >= 2.0.14) version_gem (~> 1.1, >= 1.1.15) - rubocop-thread_safety (0.7.3) + rubocop-thread_safety (0.8.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) + rubocop (~> 1.89) rubocop-ast (>= 1.44.0, < 2.0) ruby-merge (7.1.9) ast-merge (= 7.1.9) @@ -468,11 +468,11 @@ GEM simplecov (>= 0.4.1) simplecov_lcov_formatter (0.9.0) simplecov (>= 0.18) - sorbet-runtime (0.6.13528) - standard (1.56.0) + sorbet-runtime (0.6.13532) + standard (1.57.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.88.0) + rubocop (~> 1.91.0) standard-custom (~> 1.0.0) standard-performance (~> 1.8) standard-custom (1.0.2) @@ -645,7 +645,7 @@ CHECKSUMS bash-merge (7.1.9) sha256=fde66a0e5eaaa2df0eae456bd5a45be631e61806c356f345d2a43085e4138757 benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 - bundler (4.0.22) sha256=d8d5ec84c8555e0af71db63ed7aee4d1a8fb839ec46d84212d61979242a5d75a + bundler (4.0.21) sha256=7cbf5499b426076aa16c8d28a59e4c2c3aad5a438402abcd961f0119f29f1dd0 bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650 @@ -682,14 +682,14 @@ CHECKSUMS gem_bench (2.0.5) sha256=0dc0fb44a5a5eb7b2f5c1c68a5b0164d72007132822c012bac3abe976b199ead geom2d (0.4.1) sha256=ea0998ea90c4f2752e24fe13d85a4f89bee689d151316140ebcc6369bf634ed9 gitmoji-regex (2.0.15) sha256=c6571bffd757f95c06d84a7d2d0b3f180eca7fe576882c371cee1f1a081cd968 - hexapdf (1.11.0) sha256=72e0459dd2482ec1791f9a44a97144566a00188889cc8742dda97413ca679c62 + hexapdf (1.11.1) sha256=914578c3b2024f76b533144344379c7123d3eaa3ea586f4d98add59534b783a9 io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df json-merge (7.1.9) sha256=13c979c1398e6e3126ec6c1d4acccd08a138fb06a58152f51ad9079caf03b39e kettle-changelog (1.0.8) sha256=08ded13e84276e58cd6375c1750ee6dab45fd2f67c0f0fed623a7267ddb9e300 - kettle-dev (3.1.7) sha256=23e4b9d678d307a1ad314122b028087430811da0c3ee99131849ff6aa6795c87 - kettle-family (1.3.5) sha256=792328eae94386c84873cf04ba6e22a3b8285b2b2c30299fb45994a9bdfce472 + kettle-dev (3.1.8) sha256=0923d5e6908e7554d65ff6d064b587aef01cf1d328bb64c184ce29acbc190a9e + kettle-family (1.3.6) sha256=73aac0e22a44651773c4f6aba45d878c2e743292b63fb1473e985512c52d7623 kettle-gha-pins (0.3.16) sha256=4ee906232dae309e8dbd1fca8e993bbfdad20f24892fc73776c9960081bbbbdb kettle-jem (7.1.28) sha256=6bd38a850bc3a73786cd26f3c3489f37ab6040832143df8fbd5e41ad73085d14 kettle-ndjson (0.1.18) sha256=0608518194739eafee1d2e84871a3f9759350ced5ed7aaaa1a81c72e7d17468c @@ -749,7 +749,7 @@ CHECKSUMS rspec-stubbed_env (1.0.16) sha256=7ea81332e37f637c3b6289b1c7afc8e6ea0a1c3e3ab36ff2c9611b0416367d52 rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rspec_junit_formatter (0.6.0) sha256=40dde674e6ae4e6cc0ff560da25497677e34fefd2338cc467a8972f602b62b15 - rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf + rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-capybara (2.23.0) sha256=f9ea1ba3a7561ee8e88cf76fc378ce517ce5327155f305ee7b5c2500e5aee357 rubocop-env (0.1.0) sha256=5ab9a259e71822bf514c8e900a9b68cc2f74ccc87d5493efcd03213f3068e718 @@ -759,16 +759,16 @@ CHECKSUMS rubocop-lts-rspec (1.0.7) sha256=7bf96c2745b9d000f418ab739f27a11037ba2b1dc4980aff53bbc9fec2ac4272 rubocop-lts-ruby (0.1.5) sha256=09c47d2d666693ee759e1d5549e5534a5465f10e2580fbf9049c8a341a04999e rubocop-md (2.0.4) sha256=0d076b6b5e99dea2ddc928c4bd702497decb9a8400da7808091e02ebcafcfb93 - rubocop-minitest (0.40.0) sha256=353c698199115f12151144cf0b5a96f69bb9d77b660cf6536df2c4250c672a9d + rubocop-minitest (0.41.0) sha256=c15bfe00134a30c8a00ce670b07ecc80440455ba277b8682cf0d515c98ee7426 rubocop-on-rbs (2.2.0) sha256=a78c1fa10fd9d78e26a9de67e9428a1ef6b5dc3b61e3db69e201222ccd588cf5 rubocop-packaging (0.6.0) rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 rubocop-rspec-extra (0.3.0) sha256=ee4b6484d0332a0d23c4a465eeb607e41888eb3d4ed479f850b9f5212514f787 - rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 + rubocop-rspec_rails (2.33.0) sha256=669e0a7a9a5fca6d6ccaf853ac65b5b8bdfde3519f604616f2c5278cbf565689 rubocop-ruby2_4 (3.0.8) sha256=d2d047d30d89330745af7101218931defcdc8b99363a36dc159b0fbc5465d72e - rubocop-thread_safety (0.7.3) sha256=067cdd52fbf5deffc18995437e45b5194236eaff4f71de3375a1f6052e48f431 + rubocop-thread_safety (0.8.0) sha256=71fe5e7894fb9acfbd4c2dcd394feabc35bd690f75b0bc846e1504d9e31f9034 ruby-merge (7.1.9) sha256=935774793dcec28614019a7c0802f14f93f805664684e7a29184ad1b4db241fa ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby_engine (2.0.3) sha256=ca2ae7def8cfcdc2bfb6a186d36d5d59c8b403245dc0256c3802b767fa278aaa @@ -780,8 +780,8 @@ CHECKSUMS simplecov-console (0.9.5) sha256=b1108bcfff5f210143e2b8301698c367b01586f20d25a73e95475a5df6fc6ff6 simplecov-rcov (0.3.7) sha256=372f50bf6df6b6350b7d0c840f2f8bdabe021861a43c26877b747c9ac96139fc simplecov_lcov_formatter (0.9.0) sha256=4c1839bb31dcf7a8f7a6474c94ed228aa03b994e9dd5e1965e58b4121b049bed - sorbet-runtime (0.6.13528) sha256=c89d1c6936db4d2107732be5cc6d7649ccf3b5a095ae211a312e21fba82fd53a - standard (1.56.0) sha256=ae2af4d9669589162ac69ed5ef59dcf9f346d4afc81f7e62b84339310dfcb787 + sorbet-runtime (0.6.13532) sha256=b75bf990d7d4b9e07a4bf1c470d6ff8df1f3c4a1b0137edebbc4f90d25781150 + standard (1.57.0) sha256=4a65ea92421fbaf77fb54da91e16093cf79b335b8f62c35aaa3dd714c8592858 standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 standard-rspec (0.5.1) sha256=94c6a0c94f6d303867df5cc403a0d4994cf0d72973e86c0a0c82e3b89843989a @@ -822,4 +822,4 @@ CHECKSUMS zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 BUNDLED WITH - 4.0.22 + 4.0.21 diff --git a/README.md b/README.md index daf205e..6d30ffa 100644 --- a/README.md +++ b/README.md @@ -811,7 +811,7 @@ Thanks for RTFM. ☺️ [📌gitmoji]: https://gitmoji.dev [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ -[🧮kloc-img]: https://img.shields.io/badge/KLOC-0.940-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue +[🧮kloc-img]: https://img.shields.io/badge/KLOC-0.977-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue [🔐security]: https://github.com/galtzo-floss/turbo_tests2/blob/main/SECURITY.md [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year diff --git a/docs/TurboTests.html b/docs/TurboTests.html index 79e0fb5..a2f0691 100644 --- a/docs/TurboTests.html +++ b/docs/TurboTests.html @@ -81,7 +81,7 @@
Defined in:
lib/turbo_tests.rb,
- lib/turbo_tests/cli.rb,
lib/turbo_tests/shim.rb,
lib/turbo_tests/runner.rb,
lib/turbo_tests/version.rb,
lib/turbo_tests/reporter.rb,
lib/turbo_tests/json_rows_formatter.rb
+ lib/turbo_tests/cli.rb,
lib/turbo_tests/shim.rb,
lib/turbo_tests/runner.rb,
lib/turbo_tests/version.rb,
lib/turbo_tests/reporter.rb,
lib/turbo_tests/utils/paths.rb,
lib/turbo_tests/json_rows_formatter.rb
@@ -92,7 +92,7 @@

Defined Under Namespace

- Modules: Version + Modules: Utils, Version diff --git a/docs/TurboTests/Runner.html b/docs/TurboTests/Runner.html index bf08336..837751f 100644 --- a/docs/TurboTests/Runner.html +++ b/docs/TurboTests/Runner.html @@ -126,6 +126,42 @@

%i[warnings stream buffered quiet].freeze
+
READER_DRAIN_TIMEOUT = +
+
+

Shared wall-clock budget, in seconds, for draining a worker’s stdout and
+stderr readers after the child has exited. Normally the pipe EOFs in
+milliseconds, so this is not on the hot path; the bound exists only for a
+wedged pipe whose write end is still held open (e.g. by a grandchild),
+which can never EOF. Shared across both streams so teardown cannot take
+twice the budget.

+ + +
+
+
+ + +
+
+
1
+ +
FORCE_STOP_JOIN = +
+
+

Short join used only after the pipe is force-closed, to give the reader a
+moment to notice EOF before it is killed.

+ + +
+
+
+ + +
+
+
0.1
+ @@ -486,24 +522,6 @@

 
 
-214
-215
-216
-217
-218
-219
-220
-221
-222
-223
-224
-225
-226
-227
-228
-229
-230
-231
 232
 233
 234
@@ -525,10 +543,28 @@ 

250 251 252 -253

+253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 -
# File 'lib/turbo_tests/runner.rb', line 214
+      
# File 'lib/turbo_tests/runner.rb', line 232
 
 def initialize(**opts)
   @formatters = opts[:formatters]
@@ -597,19 +633,19 @@ 

 
 
-20
-21
-22
-23
-24
-25
-26
-27
-28
-29
+31 +32 +33 +34 +35 +36 +37 +38 +39 +40

-
# File 'lib/turbo_tests/runner.rb', line 20
+      
# File 'lib/turbo_tests/runner.rb', line 31
 
 def create(count)
   # We are unable to load parallel tests' tasks in the normal way (top of file)
@@ -641,12 +677,12 @@ 

 
 
-156
-157
-158
+167 +168 +169

-
# File 'lib/turbo_tests/runner.rb', line 156
+      
# File 'lib/turbo_tests/runner.rb', line 167
 
 def generate_seed
   (Random.new_seed % 65_535).to_s
@@ -694,16 +730,16 @@ 

 
 
-148
-149
-150
-151
-152
-153
-154
+159 +160 +161 +162 +163 +164 +165

-
# File 'lib/turbo_tests/runner.rb', line 148
+      
# File 'lib/turbo_tests/runner.rb', line 159
 
 def normalize_order(order)
   order = order.to_s.strip.downcase
@@ -732,23 +768,23 @@ 

 
 
-108
-109
-110
-111
-112
-113
-114
-115
-116
-117
-118
 119
 120
-121
+121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132

-
# File 'lib/turbo_tests/runner.rb', line 108
+      
# File 'lib/turbo_tests/runner.rb', line 119
 
 def normalize_rspec_file_selection(files)
   selectors = {}
@@ -807,19 +843,19 @@ 

 
 
-97
-98
-99
-100
-101
-102
-103
-104
-105
-106
+108 +109 +110 +111 +112 +113 +114 +115 +116 +117

-
# File 'lib/turbo_tests/runner.rb', line 97
+      
# File 'lib/turbo_tests/runner.rb', line 108
 
 def normalize_worker_output_mode(mode)
   value = mode.to_s.strip.downcase.tr("-", "_")
@@ -851,17 +887,17 @@ 

 
 
-204
-205
-206
-207
-208
-209
-210
-211
+222 +223 +224 +225 +226 +227 +228 +229

-
# File 'lib/turbo_tests/runner.rb', line 204
+      
# File 'lib/turbo_tests/runner.rb', line 222
 
 def project_rspec_options(root = Dir.pwd)
   %w[.rspec .rspec-local].flat_map do |path|
@@ -891,27 +927,34 @@ 

 
 
-160
-161
-162
-163
-164
-165
-166
-167
-168
-169
-170
 171
 172
 173
 174
 175
 176
-177
+177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195

-
# File 'lib/turbo_tests/runner.rb', line 160
+      
# File 'lib/turbo_tests/runner.rb', line 171
 
 def rspec_configured_files_to_run
   configuration = RSpec::Core::Configuration.new
@@ -924,11 +967,18 @@ 

# walking to the nearest .rspec would strip those to bare # member-relative "spec/..." paths that do not exist at the root, # crashing File.stat during group sizing. - root = File.expand_path(Dir.pwd).tr("\\", "/") - root_prefix = "#{root}/" + # + # Compare paths by filesystem identity (File.identical?) rather than by + # string. On the Windows CI runner Dir.pwd reports the 8.3 short name + # (C:/Users/RUNNER~1/...) while Dir.glob, which RSpec uses to expand + # --pattern, reports the long name (C:/Users/runneradmin/...). Both + # spellings name the same directory, but File.realpath does NOT expand + # 8.3 names, so neither a string prefix nor Pathname#relative_path_from + # can reconcile them. The same class of mismatch arises from a symlinked + # ancestor. See TurboTests::Utils::Paths. + root = File.expand_path(Dir.pwd) configuration.files_to_run.map do |path| - expanded_path = File.expand_path(path.to_s, root).tr("\\", "/") - expanded_path.start_with?(root_prefix) ? expanded_path[root_prefix.length..-1] : expanded_path + TurboTests::Utils::Paths.relative_from(File.expand_path(path.to_s, root), root) end end

@@ -951,17 +1001,6 @@

 
 
-31
-32
-33
-34
-35
-36
-37
-38
-39
-40
-41
 42
 43
 44
@@ -1015,10 +1054,21 @@ 

92 93 94 -95

+95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106

-
# File 'lib/turbo_tests/runner.rb', line 31
+      
# File 'lib/turbo_tests/runner.rb', line 42
 
 def run(opts = {})
   default_file_discovery = !opts.key?(:files) || opts[:files].nil?
@@ -1105,23 +1155,23 @@ 

 
 
-133
-134
-135
-136
-137
-138
-139
-140
-141
-142
-143
 144
 145
-146
+146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157

-
# File 'lib/turbo_tests/runner.rb', line 133
+      
# File 'lib/turbo_tests/runner.rb', line 144
 
 def runtime_log_from_example_status(example_status_log)
   statuses = RSpec::Core::ExampleStatusPersister.load_from(example_status_log)
@@ -1157,18 +1207,18 @@ 

 
 
-123
-124
-125
-126
-127
-128
-129
-130
-131
+134 +135 +136 +137 +138 +139 +140 +141 +142

-
# File 'lib/turbo_tests/runner.rb', line 123
+      
# File 'lib/turbo_tests/runner.rb', line 134
 
 def split_rspec_location(entry)
   value = entry.to_s
@@ -1199,33 +1249,33 @@ 

 
 
-179
-180
-181
-182
-183
-184
-185
-186
-187
-188
-189
-190
-191
-192
-193
-194
-195
-196
 197
 198
 199
 200
 201
-202
+202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220

-
# File 'lib/turbo_tests/runner.rb', line 179
+      
# File 'lib/turbo_tests/runner.rb', line 197
 
 def worker_spec_opts(spec_opts)
   args = project_rspec_options + Array(spec_opts)
@@ -1277,24 +1327,6 @@ 

 
 
-255
-256
-257
-258
-259
-260
-261
-262
-263
-264
-265
-266
-267
-268
-269
-270
-271
-272
 273
 274
 275
@@ -1342,10 +1374,28 @@ 

317 318 319 -320

+320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338

-
# File 'lib/turbo_tests/runner.rb', line 255
+      
# File 'lib/turbo_tests/runner.rb', line 273
 
 def run
   parallel_tests_options = @parallel_options.reject { |key, _value| key == :only_group }
diff --git a/docs/TurboTests/Utils.html b/docs/TurboTests/Utils.html
new file mode 100644
index 0000000..ef8843d
--- /dev/null
+++ b/docs/TurboTests/Utils.html
@@ -0,0 +1,118 @@
+
+
+  
+    
+
+
+  Module: TurboTests::Utils
+  
+    — Documentation by YARD 0.9.45
+  
+
+
+  
+
+  
+
+
+
+
+  
+
+  
+
+
+  
+  
+    
+
+    
+
+    
+ + +

Module: TurboTests::Utils + + + +

+
+ + + + + + + + + + + +
+
Defined in:
+
lib/turbo_tests/utils/paths.rb +
+
+ +
+ +

Defined Under Namespace

+

+ + + Modules: Paths + + + + +

+ + + + + + + + + +
+ + + +
+ + diff --git a/docs/TurboTests/Utils/Paths.html b/docs/TurboTests/Utils/Paths.html new file mode 100644 index 0000000..e78067c --- /dev/null +++ b/docs/TurboTests/Utils/Paths.html @@ -0,0 +1,508 @@ + + + + + + + Module: TurboTests::Utils::Paths + + — Documentation by YARD 0.9.45 + + + + + + + + + + + + + + + + + + + + + +
+ + +

Module: TurboTests::Utils::Paths + + + +

+
+ + + + + + + + + + + +
+
Defined in:
+
lib/turbo_tests/utils/paths.rb +
+
+ +
+ +

Overview

+
+

Makes discovered spec paths relative to the cwd that owns the RSpec
+configuration, in a way that survives the same directory being spelled
+two different ways.

+ +

== Why string comparison of paths is not safe here

+ +

A path string is a name, not an identity. One directory can have
+several valid names, and the two names can reach this module from
+different APIs within the same process.

+ +

On the Windows CI runner (windows-latest, github/actions) this is not
+hypothetical. Measured from the runner itself:

+ +

Dir.pwd => “C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-…-ujisy2”
+ ENV[“TEMP”] => “C:\Users\RUNNER~1\AppData\Local\Temp”
+ Dir.tmpdir => “C:/Users/RUNNER~1/AppData/Local/Temp”
+ Dir.glob result => “C:/Users/runneradmin/AppData/Local/Temp/turbo-tests2-…-ujisy2/gems/example/spec/example_spec.rb”

+ +

RUNNER~1 is an 8.3 short name; runneradmin is the long name of the
+same directory. Dir.pwd and the environment report the short form while
+Dir.glob — which RSpec uses to expand --pattern — reports the long
+form. Both are correct, and they are never equal as strings.

+ +

In this codebase the mismatch has a second, platform-independent face:
+a symlinked ancestor. On Fedora, /home/pboling is a symlink to
+/var/home/pboling, so Dir.pwd can report one form while a path built
+from an ENV var or a stored fixture reports the other. That is what makes
+this bug reproducible in specs without a Windows runner.

+ +

== Approaches that do NOT work, and why

+ +

All four were tried on this branch and all failed. Recorded so nobody
+repeats the sequence.

+ +
    +
  1. +

    String prefix, the original code:

    + +

    root_prefix = “#{root}/”
    + expanded.start_with?(root_prefix)

    + +

    Never matches when the two spellings differ, so the absolute temp path
    +leaks through. parallel_tests then calls File.stat on it during group
    +sizing. It also wrongly reports “project-other/spec” as inside
    +“project”, which is why spec/utils/paths_spec.rb covers siblings.

    +
  2. +
  3. +

    Canonicalize with File.realpath, then compare:

    + +

    File.realpath(path) == File.realpath(root) # conceptually

    + +

    WRONG PREMISE: File.realpath does NOT expand 8.3 short names. Measured
    +on the runner, realpath of the short path returns the short path
    +unchanged:

    + +

    root_realpath => “C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-…”

    + +

    Canonicalization therefore cannot reconcile the two spellings at all.
    +(kettle-dev’s Kettle::Dev::Paths.canonical documents realpath as
    +expanding 8.3 names; that comment is inaccurate. Worth correcting if
    +this module is ever extracted to a shared gem.)

    + +

    realpath also resolves symlinks, which introduces a second failure
    +mode: root and file can legitimately traverse different symlink paths
    +to one directory, and canonicalizing only one side makes them diverge.

    +
  4. +
  5. +

    Component-wise comparison of the canonicalized parts (split on
    +File::SEPARATOR, compare element by element, case-insensitively on
    +Windows). Built on the same false premise as #2, so it fails
    +identically — RUNNER~1 never equals runneradmin in any case
    +folding. It additionally depended on File::ALT_SEPARATOR, which
    +measured as “” (empty string, not nil) on that runner, making the
    +separator-normalizing tr a silent no-op. Do not trust ALT_SEPARATOR
    +to be present.

    +
  6. +
  7. +

    Pathname#relative_path_from, Ruby’s own stdlib helper:

    + +

    Pathname.new(file).relative_path_from(Pathname.new(root)).to_s

    + +

    It is purely lexical. With differing spellings it walks all the way up
    +and back down, producing:

    + +

    ”../../../../../runneradmin/AppData/Local/Temp/…/example_spec.rb”

    + +

    Note it does not raise, so a rescue ArgumentError guard cannot catch
    +it. The result is a plausible-looking but wrong relative path, which is
    +worse than the leak in #1 because it silently selects the wrong files.
    +The same nonsense appears on the symlink case:
    +“../../../../../../var/home/pboling/src/my/…”.

    +
  8. +
+ +

== The approach that works

+ +

File.identical? asks the filesystem whether two names denote one object,
+by inode on Unix and by file index on Windows. It is spelling-agnostic,
+which is exactly the property needed. Measured on the runner:

+ +

File.identical?(root, root) => true
+ File.identical?(file, file) => true

+ +

and a walk-up from the long-name file to the short-name root produced the
+correct “gems/example/spec/example_spec.rb”, while the string-prefix and
+Pathname baselines both failed in the same run.

+ +

So: ascend from the file, asking at each level “is this the root?”, and
+re-join the basenames on the way back down. The relative path is built
+from the file’s spelling, which is what the caller needs, since
+parallel_tests stats these paths and spawns workers from this cwd.

+ +

== How this was finally diagnosed

+ +

The failure only reproduced on the Windows CI runner, and its output was
+swallowed: these discovery specs run a subprocess via Open3.capture3 whose
+stderr is only surfaced when the status check fails, and the assertion
+that failed was the JSON comparison. Four blind fixes failed in a row.

+ +

What resolved it was a temporary, Windows-guarded spec
+(if: Gem.win_platform?) that deliberately failed with the diagnostic
+JSON embedded in the expectation message, so the runner’s real path forms
+printed into the CI log. Guarding it kept the other 22 checks green while
+one job reported the data. That spec is deleted; the findings it produced
+are this comment plus spec/utils/paths_spec.rb.

+ +

Lesson: when a bug is environment-specific and the environment is not
+available locally, spend the iteration on instrumenting the real
+environment rather than on another hypothesized fix.

+ + +
+
+
+ + +
+ + + + + + + +

+ Class Method Summary + collapse +

+ +
    + +
  • + + + .relative_from(file, root) ⇒ String + + + + + + + + + + + + + +

    Returns the path of +file+ relative to +root+ when +file+ is +root+ itself or lives inside it, compared by filesystem identity rather than by string.

    +
    + +
  • + + +
  • + + + .within?(file, root) ⇒ Boolean + + + + + + + + + + + + + +

    Returns true when +file+ is +root+ itself or lives inside it, compared by filesystem identity.

    +
    + +
  • + + +
+ + + + + +
+

Class Method Details

+ + +
+

+ + .relative_from(file, root) ⇒ String + + + + + +

+
+

Returns the path of +file+ relative to +root+ when +file+ is +root+
+itself or lives inside it, compared by filesystem identity rather than
+by string.

+ +

Returns the expanded +file+ unchanged when it is not inside +root+
+(there is nothing to strip) or when identity cannot be established,
+e.g. because one side does not exist. That matches the long-standing
+behavior of passing through paths outside the root untouched.

+ + +
+
+
+

Parameters:

+
    + +
  • + + file + + + (String, Pathname) + + + + — +

    path to make relative; expanded against
    +the current directory when relative

    +
    + +
  • + +
  • + + root + + + (String, Pathname) + + + + — +

    directory to make +file+ relative to

    +
    + +
  • + +
+ +

Returns:

+
    + +
  • + + + (String) + + + + — +

    relative path using File::SEPARATOR, “.” when the two
    +name the same directory, or the expanded +file+ when it is not inside
    ++root+

    +
    + +
  • + +
+ +
+ + + + +
+
+
+
+145
+146
+147
+148
+149
+150
+
+
# File 'lib/turbo_tests/utils/paths.rb', line 145
+
+def relative_from(file, root)
+  expanded_file = File.expand_path(file.to_s)
+  relative = relative_to_root(expanded_file, root)
+
+  relative || expanded_file
+end
+
+
+ +
+

+ + .within?(file, root) ⇒ Boolean + + + + + +

+
+

Returns true when +file+ is +root+ itself or lives inside it, compared
+by filesystem identity.

+ +

Unlike a string prefix test this does not treat a sibling directory
+that merely shares a prefix (“project-other”) as being inside “project”.

+ + +
+
+
+

Parameters:

+
    + +
  • + + file + + + (String, Pathname, nil) + + + +
  • + +
  • + + root + + + (String, Pathname, nil) + + + +
  • + +
+ +

Returns:

+
    + +
  • + + + (Boolean) + + + + — +

    false when either argument is nil or empty

    +
    + +
  • + +
+ +
+ + + + +
+
+
+
+161
+162
+163
+164
+165
+
+
# File 'lib/turbo_tests/utils/paths.rb', line 161
+
+def within?(file, root)
+  return false if blank?(file) || blank?(root)
+
+  relative_to_root(File.expand_path(file.to_s), root).is_a?(String)
+end
+
+
+ +
+ +
+ + + +
+ + diff --git a/docs/_index.html b/docs/_index.html index 7c5faae..a57248d 100644 --- a/docs/_index.html +++ b/docs/_index.html @@ -172,6 +172,21 @@

Namespace Listing A-Z

+
    +
  • P
  • +
      + +
    • + Paths + + (TurboTests::Utils) + +
    • + +
    +
+ +
  • R
    • @@ -234,6 +249,24 @@

      Namespace Listing A-Z

    + + + +
      +
    • U
    • +
        + +
      • + Utils + + (TurboTests) + +
      • + +
      +
    + +
    • V
      • diff --git a/docs/class_list.html b/docs/class_list.html index cc43873..9b2ae54 100644 --- a/docs/class_list.html +++ b/docs/class_list.html @@ -46,7 +46,7 @@

        Class List

        diff --git a/docs/file.CHANGELOG.html b/docs/file.CHANGELOG.html index 26580af..df175d4 100644 --- a/docs/file.CHANGELOG.html +++ b/docs/file.CHANGELOG.html @@ -84,15 +84,15 @@

        Fixed

        Security

        -

        -3.2.14 - 2026-10-05

        +

        +3.2.14 - 2026-10-07

        • TAG: v3.2.14
        • -
        • COVERAGE: 96.81% – 910/940 lines in 10 files
        • -
        • BRANCH COVERAGE: 84.72% – 244/288 branches in 10 files
        • -
        • 36.73% documented
        • +
        • COVERAGE: 96.93% – 947/977 lines in 11 files
        • +
        • BRANCH COVERAGE: 85.57% – 255/298 branches in 11 files
        • +
        • 39.42% documented

        Changed

        @@ -116,7 +116,15 @@

        Changed

        Fixed

          -
        • rspec_configured_files_to_run: normalize discovered spec paths against the root cwd instead of the nearest ancestor .rspec. In stacked monorepos (root .rspec aggregating gems/*/spec via –pattern, where each gem also has its own .rspec), the nearest-ancestor walk stripped aggregated specs to bare member-relative spec/… paths that do not exist at the root, crashing parallel_tests File.stat during group sizing and aborting the whole run. Regression introduced in 3.2.13.
        • +
        • +

          rspec_configured_files_to_run: normalize discovered spec paths against the root cwd instead of the nearest ancestor .rspec. In stacked monorepos (root .rspec aggregating gems/*/spec via –pattern, where each gem also has its own .rspec), the nearest-ancestor walk stripped aggregated specs to bare member-relative spec/… paths that do not exist at the root, crashing parallel_tests File.stat during group sizing and aborting the whole run. Regression introduced in 3.2.13.

          +
        • +
        • +

          Fix a teardown race that intermittently dropped a worker’s results. The exit-watcher thread previously signaled process exit as soon as the child was reaped, before the stdout/stderr readers had drained the pipe. Under CI contention handle_messages could reach process_count exits and stop while that worker’s rows were still queued behind the exit signal, so they were never reported. Symptom was a nested run reporting “1 example, 0 failures” instead of “4 examples, 0 failures, 3 pending”, with which worker vanished varying run to run (spec/integration/multi_process_spec.rb failed at :46 on one run and :52-54 on the next). Readers are now drained to EOF before the watcher signals exit, and the exit/error messages are a deduped fallback for a reader force-stopped before EOF. Drain uses one shared monotonic deadline across both streams (READER_DRAIN_TIMEOUT) so a wedged pipe whose write end stays open cannot double teardown time or hang; force-stop still closes the pipe and kills the reader as before.

          +
        • +
        • +

          Fix spec discovery returning absolute paths on Windows, which crashed parallel_tests’ File.stat during group sizing. rspec_configured_files_to_run made discovered paths relative to the cwd with a string prefix check. On Windows the cwd and the discovered files can name the same directory with two different spellings: Dir.pwd and ENV[“TEMP”] report the 8.3 short name (C:/Users/RUNNER~1/AppData/Local/Temp) while Dir.glob, which RSpec uses to expand –pattern, reports the long name (C:/Users/runneradmin/AppData/Local/Temp). The prefix therefore never matched and the absolute temp path leaked through. File.realpath does not expand 8.3 short names, so canonicalizing first cannot reconcile the two spellings, and Pathname#relative_path_from returns nonsense such as “../../../../../runneradmin/…”. The same class of mismatch arises from a symlinked ancestor on any platform, e.g. a Dir.pwd of /var/home/u/proj against a path spelled /home/u/proj/… Discovered paths are now made relative by walking up from the file and comparing each ancestor with File.identical?, which compares by filesystem identity (inode on Unix, file index on Windows) and so treats every spelling of one directory as equal. Extracted as TurboTests::Utils::Paths.relative_from/.within?.

          +

        diff --git a/docs/file.README.html b/docs/file.README.html index 5779c4f..7c3cb3e 100644 --- a/docs/file.README.html +++ b/docs/file.README.html @@ -290,7 +290,7 @@

        💡 Info you can shake a stick at

        Source -Source on GitLab.com Source on CodeBerg.org Source on GitHub.com The best SHA: dQw4w9WgXcQ! +Source on GitLab.com Source on CodeBerg.org Source on GitHub.com The best SHA: dQw4w9WgXcQ! diff --git a/docs/index.html b/docs/index.html index abe2a00..eed9c5a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -290,7 +290,7 @@

        💡 Info you can shake a stick at

        Source -Source on GitLab.com Source on CodeBerg.org Source on GitHub.com The best SHA: dQw4w9WgXcQ! +Source on GitLab.com Source on CodeBerg.org Source on GitHub.com The best SHA: dQw4w9WgXcQ! diff --git a/docs/method_list.html b/docs/method_list.html index 68d3659..123d6aa 100644 --- a/docs/method_list.html +++ b/docs/method_list.html @@ -544,6 +544,14 @@

        Method List

      • +
        + relative_from + TurboTests::Utils::Paths +
        +
      • + + +
      • remove TurboTests::Shim @@ -551,7 +559,7 @@

        Method List

      • -
      • +
      • #remove TurboTests::Shim @@ -559,7 +567,7 @@

        Method List

      • -
      • +
      • #report TurboTests::Reporter @@ -567,7 +575,7 @@

        Method List

      • -
      • +
      • #report_number_of_tests TurboTests::Reporter @@ -575,7 +583,7 @@

        Method List

      • -
      • +
      • rspec_configured_files_to_run TurboTests::Runner @@ -583,7 +591,7 @@

        Method List

      • -
      • +
      • #run TurboTests::CLI @@ -591,7 +599,7 @@

        Method List

      • -
      • +
      • run TurboTests::Runner @@ -599,7 +607,7 @@

        Method List

      • -
      • +
      • #run TurboTests::Runner @@ -607,7 +615,7 @@

        Method List

      • -
      • +
      • #run_time TurboTests::FakeExecutionResult @@ -615,7 +623,7 @@

        Method List

      • -
      • +
      • runtime_log_from_example_status TurboTests::Runner @@ -623,7 +631,7 @@

        Method List

      • -
      • +
      • #seed TurboTests::JsonRowsFormatter @@ -631,7 +639,7 @@

        Method List

      • -
      • +
      • split_rspec_location TurboTests::Runner @@ -639,7 +647,7 @@

        Method List

      • -
      • +
      • #start TurboTests::Reporter @@ -647,7 +655,7 @@

        Method List

      • -
      • +
      • #start TurboTests::JsonRowsFormatter @@ -655,7 +663,7 @@

        Method List

      • -
      • +
      • #status TurboTests::FakeExecutionResult @@ -663,7 +671,7 @@

        Method List

      • -
      • +
      • #status TurboTests::Shim::Result @@ -671,6 +679,14 @@

        Method List

      • +
      • +
        + within? + TurboTests::Utils::Paths +
        +
      • + +