Skip to content

Run example groups across forked workers with rspec --parallel - #1

Open
ilyazub wants to merge 58 commits into
mainfrom
ilyazub-rspec-native-parallel-runner
Open

ilyazub wants to merge 58 commits into
mainfrom
ilyazub-rspec-native-parallel-runner

Conversation

@ilyazub

@ilyazub ilyazub commented Oct 13, 2026 •

Copy link
Copy Markdown

Opus 5.5 xhigh wrote this and ran every check in it (who wrote and reviewed what: last section).

rspec --parallel runs a suite in worker processes and reports it as one ordinary RSpec run. You get the same formatters, one seed, one summary, one .rspec_status and the usual exit codes. It's opt-in and adds no gem. Workers are forked where Ruby can fork; on Windows and JRuby they are new rspec processes. This is for review here only; nothing has been proposed to rspec/rspec.

xychart-beta
  title "Synthetic 200-file suite, 8 workers (seconds, lower is better)"
  x-axis ["rspec", "parallel_tests", "turbo_tests", "flatware", "rspec --parallel", "--parallel, 2nd run"]
  y-axis "median of 5 runs (s)" 0 --> 40
  bar [36.27, 10.99, 10.94, 8.94, 7.06, 4.80]
Loading

Before / after

rspec rspec --parallel
rspec-core's own suite (2,527 examples, 8 workers) 55.1 s 10.3 s
the same suite on Windows (4 workers, GitHub runner) 61.6 s 28.5 s
SerpApi's app suite (151,725 examples, 8 workers) flatware -w 8: 199.5 s 140.1 s
an after(:suite) hook never returns hangs exit 1 within the timeout + 11 s
an example calls exit 3 exits 3 exits 3
a worker crashes mid-group the run dies that example fails; the rest of its group runs in another worker, and a new worker takes its place
SIGTERM while an example is stuck stops stops every worker, names the stuck example, prints the summary
the run gets SIGKILL — a watcher process stops its workers
an example prints to $stdout inline together with its group's report, in order
no fork (Windows, JRuby) runs runs in spawned rspec workers

Medians of 5 interleaved runs on 29b48c6, macOS, Ruby 3.4.8, load 9–15. Windows: one run each, Ruby 3.4. SerpApi: median of 3 cold rounds with the same examples and results in each runner (details and memory). Chart: 364655614; this round's A/B on those fixtures shows no change (below).

Formatters, --seed/--order, filters, path:line, --only-failures, --next-failure and --fail-fast give the same results as a serial run; --bisect bisects serially.

Since the last description (7c924a3 → 29b48c6), its "Not covered" list:

Last time Now
Windows: 9 of 2,465 examples failed 0 of 2,527. CI's Windows job now runs each library's specs (it found 0 examples before).
no fork: warned and ran serially spawned rspec workers; rspec-core's suite passes that way on Windows and JRuby
SIGKILL to the run left a stuck worker behind a watcher process sends it TERM, then KILL
metadata an example changed wasn't sent back sent back when it can be marshaled; a warning names a key that can't
a worker's $stdout wasn't ordered with the report shown with the group that printed it
a crashed worker wasn't replaced replaced while groups wait, up to the worker count per run
splitting a group is opt-in stays opt-in: a split group runs its before(:context) hooks once per part
Rails belongs in rspec-rails, in its own session

Not covered

  • Memory with --no-parallel-preload: every worker loads the whole suite. On SerpApi's suite that's 22 GB at peak against flatware's 8.5 GB (one sampled run each; 4 workers: 12 GB, 283 s). Loading only each worker's files changes when a spec file's global configuration takes effect, so this PR doesn't do it. Preloading shares one copy, but in that app on macOS the workers then crashed.
  • A stuck example hangs the run until it gets a signal, as serially; SIGTERM then names the example.
  • rspec-rails (per-worker databases): its session hasn't started; it is waiting for extension permissions.
  • Debuggers (binding.irb, debug) need a serial run, because a worker's $stdout isn't a terminal.
  • Spawned workers need the rspec command: a script calling RSpec::Core::Runner.run warns and runs serially on Windows and JRuby.
  • JRuby is slower with spawned workers on rspec-core's suite (4-vCPU runner, one run each: 188 → 200 s on 10.0, 240 → 292 s on 9.4), because each worker boots a JVM.
  • About 100 of rspec-core's parallel specs need fork, so they're skipped on Windows and JRuby (109–110 pending there, 7 on macOS).
  • Smaller gaps:
    • The watcher doesn't know about a worker forked in the instant before SIGKILL.
    • TruffleRuby hasn't been run.
    • A --bisect spec's DRb connection was reset in 1 of 13 --parallel runs of rspec-core's suite (0 of 40 reruns).
    • 20,000 trivial examples take 0.76 → 0.81 s (n=5).

Try it (Ruby 3.0+):

# Gemfile
git "https://github.com/serpapi/rspec", branch: "ilyazub-rspec-native-parallel-runner" do
  %w[rspec rspec-core rspec-expectations rspec-mocks rspec-support].each { |name| gem name }
end
bundle exec rspec --parallel     # one worker per processor
bundle exec rspec --parallel=4
How it works
flowchart LR
  R["Runner: loads and filters the suite"] --> C["Coordinator: queue of groups, longest first"]
  C -- "group ids (pipe)" --> W1["worker 1 (fork or spawn)"]
  C -- "group ids (pipe)" --> W2["worker N"]
  W1 -- "notifications (pipe)" --> C
  W2 -- "notifications (pipe)" --> C
  C --> F["Reporter + your formatters, unchanged"]
  X["watcher process"] -. "TERM, KILL if the coordinator dies" .-> W1
Loading
  • Only a run configured to be parallel loads the new code; a serial run loads none of it.
  • The coordinator hands the next unit (a top-level group, or a part of one tagged :parallel_split) to whichever worker is idle. Each worker has its own pair of pipes carrying length-prefixed Marshal frames, and every waitpid is WNOHANG.
  • Workers forward every reporter notification; the coordinator replays it on the real Example and ExampleGroup objects. Exceptions are rebuilt, never marshaled. Metadata an example changed travels key by key.
  • A worker's $stdout and $stderr go to a file of its own. The coordinator, the only reader, shows each piece with the group that printed it.
  • Formatters that react to group boundaries see one group at a time. A finished group is shown once the live one has been silent for 2 s or has waited 10 s, or once 5,000 notifications or 8 MB are queued.
  • Without fork, workers are ruby -I<load path> <the rspec command> processes started in the run's directory. They load the spec files themselves and adopt the coordinator's seed.
  • Every wait is bounded by config.parallel_shutdown_timeout (30 s). A worker that doesn't report back, or doesn't exit after its last group, gets TERM, then KILL 5 s later. A worker leaving its job loop arms a watchdog that dumps its threads and aborts. SIGTERM or SIGHUP to the run stops every worker and prints what each was running. If the coordinator itself is killed, a small watcher process (ruby --disable-gems) stops the workers it registered.
  • A worker lost while groups wait is replaced, up to the worker count per run. Its in-flight example fails, and the examples it hadn't started go back to the queue.
  • Workers get config.parallel_worker_number (1, 2, …) and parallel_tests' TEST_ENV_NUMBER ("", "2", …).
  • Size: library +3,572 lines (16 new files), specs +6,039, features +225, all in rspec-core; plus the Windows CI job in .github/workflows/rspec.yml.
Benchmarks

rspec-core's own suite on 29b48c6, serial and --parallel=8 alternating, n=5, macOS, Ruby 3.4.8, load 9–15. Every run exited 0 with 2,527 examples, 0 failures, 7 pending.

median range
rspec 55.08 s 49.94–57.61
rspec --parallel=8 10.34 s 8.26–11.08

Runner overhead this round (bench fixtures, n=5 interleaved, every run checked for its count and 0 failures, 30/30):

fixture 7c924a3 42a596e (the final library code)
200 even files 2.90 (2.89–2.91) 2.92 (2.91–3.00)
heavy-tailed 6.60 (5.24–6.79) 6.27 (5.27–7.10)
20,000 trivial examples 0.76 (0.75–1.05) 0.81 (0.80–0.84); measured again later: 0.81 (0.78–0.83)

Runner comparison (the chart), measured on 364655614 (456bb51 merged into main), Ruby 3.4.8, Apple M4 Max, load 23–57. Seconds, median (min–max) of 5 runs, runners interleaved, 8 workers, 120/120 runs with the right count and 0 failures:

runner heavy-tailed (1,093 ex.) even (1,000) one big group (600) trivial (20,000)
rspec (serial) 36.27 (36.19–36.44) 24.53 (23.84–24.90) 8.00 (7.78–8.46) 1.36 (1.32–1.44)
rspec --parallel=8 7.06 (5.40–7.54) 3.25 (3.17–3.55) 5.37 (5.00–6.26) 1.23 (1.15–1.28)
rspec --parallel=8, 2nd run 4.80 (4.72–4.98) 3.42 (3.23–3.53) 5.44 (5.04–6.65) 1.42 (1.37–1.56)
parallel_tests 5.8.0 10.99 (10.75–11.77) 4.45 (4.08–4.65) 6.87 (6.03–8.00) 1.20 (1.18–1.21)
turbo_tests 2.2.5 10.94 (10.85–11.00) 4.46 (4.11–4.91) 6.86 (6.16–7.18) 1.41 (1.29–1.48)
flatware 2.4.0 8.94 (8.87–9.11) 3.92 (3.40–4.10) 6.00 (5.58–6.56) 6.36 (6.07–7.27)

Windows and JRuby, rspec-core's suite, GitHub runners, one run each (probe run 38839752160, rspec-core identical to d3eb83a):

serial --parallel=4
Windows, Ruby 3.0 67.5 s 33.5 s
Windows, Ruby 3.4 61.6 s 28.5 s
JRuby 10.0 187.7 s 199.8 s
JRuby 9.4 240.0 s 292.5 s
Checks

GitHub Actions on this fork, run 38841282309 on 29b48c6: 44 of 44 jobs passed.

MRI 3.0 3.1 3.2 3.3 3.4 4.0   run_rspec 2,527/0 (line coverage 100% on 3.2-4.0), run_rspec_one_by_one, cucumber 302/302
JRuby 9.4 10.0 10.1           run_rspec 2,527/0
Windows, Ruby 3.4             core 2,527/0, expectations 2,365/0, mocks 1,752/0, support 595/0

Windows and JRuby probe, run 38839752160 (rspec-core identical to d3eb83a):

Windows 3.0, 3.4; JRuby 9.4, 10.0   bundle exec rspec               2,527 examples, 0 failures
                                    bundle exec rspec --parallel=4  2,527 examples, 0 failures (spawned workers, no fallback warning)

Local, CI's environment variables, macOS, Ruby 3.4.8, on 3b14b6d38 (d3eb83a merged into rspec/rspec main 4af3571, which contains this PR's base 14c8804; 29b48c6 changes one spec, run by the CI above):

script/run_rspec                      2,527 examples, 0 failures, 7 pending; line coverage 100.00% (6,384/6,384)
script/run_rspec_one_by_one           0 failing
script/run_cucumber                   302 scenarios, 302 passed
bin/rspec --parallel=8, 3 runs        2,527 examples, 0 failures, 7 pending, each
script/check_documentation_coverage   100.00% documented
bin/rubocop                           587 files, no offenses

Edge cases in real processes, on 29b48c6, Ruby 3.4.8: 107/107 preloaded, 107/107 --no-parallel-preload, 105/105 with fork removed (spawned workers). Earlier spawned runs failed a worker-count check twice, once in each of two scenarios: the harness expected every worker to run a group, but the groups were so short that one worker ran them all, which is valid scheduling. Those checks now hold each worker's examples until the expected workers have started, for at most 10 s (5 more runs: 3/3 each).

An example that never returns (6-file fixture, 2 workers, 3 runs per cell), then SIGTERM to the main process, SIGTERM to the process group, or GNU timeout 12 around the run: rspec --parallel names the stuck example, prints the summary and leaves no process in every case. flatware 2.4.0 prints nothing, and leaves the stuck worker running when only the main process is signalled (details).

Bugs found and fixed this round:

  • Review (5 findings, a6e1354):
    • metadata the coordinator couldn't load made the whole frame undecodable;
    • a spawned worker's add_formatter truncated the coordinator's report file;
    • worker output could pile up past the 8 MB bound;
    • non-ASCII worker output raised encoding errors;
    • a spawned worker lost while loading the spec files was replaced, though lost loading workers aren't meant to be.
  • Re-review (42a596e): workers that load the spec files themselves could still truncate a formatter's file.
  • Windows: --bisect joined $LOAD_PATH with : (b39bb9a), and two specs failed only there (d3eb83a).
  • Spec timing: two of 44 CI jobs (JRuby 10.0 and 10.1) failed a replacement spec on d3eb83a, where the surviving fake worker finished every group before the crash was noticed (fixed in 29b48c6).
Who wrote and reviewed what

Opus 5.5 wrote this description and ran every check in it: xhigh effort this round, high and max in earlier ones. Of the 58 commits, Fable 5.1 Max wrote the first 3 and Opus 5.5 the rest; this round's 16 at xhigh. An Opus 5.5 xhigh code-review agent reviewed 7c924a3..f92bc3e and then the fixes through 42a596e; d3eb83a and 29b48c6 (spec and Windows fixes) were not reviewed. The reviewer is the writer's model family, so treat it as a self-review. GPT-6 Astra (high and xhigh) advised on the plan and on how this round was measured. Nobody has reviewed it by hand yet.

Machine-readable summary
head: 29b48c65f
base: {branch: main, sha: 14c880461c, merge_base: df877b73ab}
flag: --parallel[=COUNT]    # default COUNT: Etc.nprocessors; --no-parallel or 0 runs serially
config: [parallel_workers, parallel_preload, parallel_shutdown_timeout, parallel_worker_number]
metadata: [parallel_split]
runtime_dependencies_added: 0
workers: {fork: "MRI on Linux/macOS", spawn: "Windows, JRuby (needs the rspec command)"}
bounds:                       # T = parallel_shutdown_timeout (30 s)
  worker_not_reporting: "T, then TERM, KILL +5 s"
  worker_not_exiting_after_last_group: "watchdog at T; coordinator TERM at T+1 s, KILL +5 s"
  coordinator_sigkilled: "watcher: 1.5 s, then TERM, KILL +5 s"
  example_never_returns: "unbounded, as serially; SIGTERM names it"
checks:
  fork_ci: {run: 38841282309, sha: 29b48c65f, result: "44/44", windows: {core: "2527/0", expectations: "2365/0", mocks: "1752/0", support: "595/0"}}
  probe: {run: 38839752160, windows_3_0_3_4_jruby_9_4_10_0: "2527/0 serial and --parallel=4"}
  local_merge: {sha: 3b14b6d38, examples: 2527, failures: 0, line_coverage: "6384/6384", cucumber: "302/302", parallel8_runs: "3/3"}
  edge_cases: {sha: 29b48c65f, preloaded: "107/107", no_preload: "107/107", spawned: "105/105"}
  stuck_example_sigterm: {fixture: "6 files, 2 workers", runs_per_cell: 3, parallel: "names it, summary, 0 left (TERM to pid or group, or gtimeout 12)", flatware_2_4_0: "prints nothing; stuck worker left when only the main pid is signalled"}
benchmarks:
  own_suite_s: {sha: 29b48c65f, n: 5, serial: 55.08, parallel8: 10.34}
  windows_own_suite_s: {n: 1, ruby_3_4: {serial: 61.6, parallel4: 28.5}, ruby_3_0: {serial: 67.5, parallel4: 33.5}}
  jruby_own_suite_s: {n: 1, jruby_10_0: {serial: 187.7, parallel4: 199.8}, jruby_9_4: {serial: 240.0, parallel4: 292.5}}
  serpapi_app_suite: {examples: 151725, workers: 8, rounds: 3, flatware_median_s: 199.5, parallel_median_s: 140.1, id_and_status_parity: "3/3 pairs", peak_memory_gb_one_sampled_run_each: {flatware: 8.5, parallel8: 22, parallel4: 12}}
  synthetic_runner_comparison: {sha: 364655614, see: "Benchmarks"}
not_covered: [memory_no_preload_loads_whole_suite_per_worker, stuck_example_unbounded, rspec_rails_not_started, debuggers_need_serial, spawn_needs_rspec_command, jruby_spawn_slower, fork_only_specs_skipped_on_windows_jruby, watcher_registration_window, truffleruby_not_run, bisect_drb_reset_1_of_13, overhead_0_05s_per_20k_trivial]

ilyazub and others added 30 commits October 4, 2026 22:45
`rspec --parallel[=COUNT]` (or `config.parallel_workers`) forks COUNT workers
from the process that loaded the suite and hands them top-level example
groups from a shared queue, longest first when `.rspec_status` recorded
their timings. Workers forward every notification -- including custom ones
published through the reporter -- as id-based, Marshal-safe frames over a
pipe; the coordinator maps them back to the real `Example` and
`ExampleGroup` objects and replays them onto the ordinary `Reporter`, so
existing formatters work unchanged and the run keeps one seed, one summary,
one profile, one `.rspec_status` and the usual exit codes. Formatters that
follow group boundaries see one group at a time, with bounded latency;
the others get every notification as it arrives.

Exceptions are never marshaled: workers ship class name, message, detailed
message, backtrace, cause chain and aggregate-failure details, and the
coordinator rebuilds a subclass of the real class under the original name.

A worker that finishes but never exits cannot hang the run: it arms a
watchdog before its `after(:suite)` hooks that dumps every thread's
backtrace and aborts after `config.parallel_shutdown_timeout`, and the
coordinator independently escalates TERM -> KILL -> give up. Cancelled
workers (fail-fast, interrupt) and crashed workers are bounded and reported
the same way. Suite hooks run once per worker, which sees
`config.parallel_worker_number` and a parallel_tests-style
`TEST_ENV_NUMBER`. Without `fork`, or inside another tool's parallel
worker, the run is serial with a warning.

This is by Claude Fable 5.1

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`setup_aruba` wipes the working directory, which pulled it out from under
the `cd` of other workers when the suite ran under `--parallel`. The
directory is now namespaced by `TEST_ENV_NUMBER`, chosen in a
`before(:suite)` hook because workers are forked after the suite loads, and
the two specs that spelled out `tmp/aruba` use the helper instead.

This is by Claude Fable 5.1

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ly interrupts

The "spec files" shared context wrote `_passing_spec.rb` and
`_failing_spec.rb` into `spec/support` and deleted them in `after(:all)`;
under `--parallel`, nine spec files raced over those two paths and the
library-wide checks globbed them while they existed. They now live under
`tmp/`, named per worker.

Integration specs cover a worker that receives an interrupt on its own:
it retires after the group it was running while the others finish, and a
run where every worker retires reports the groups nobody was left to run.
The changelog spells out what reaches formatters instead of promising that
they work unchanged.

This is by Claude Fable 5.1

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…crash, wedge or lose their coordinator

Found by a real-subprocess edge-case matrix and by running SerpApi's suite
(Rails, Mongoid, Ruby 2.7, 151k examples) on a 3.12 backport of the runner:

- The example a worker was running when it crashed or had to be
  terminated now fails with a Parallel::WorkerError naming the cause, so it
  shows under "Failed examples", in .rspec_status and in --only-failures;
  worker problems outside examples count as errors outside of examples.
- Workers exit within half a second when the coordinating process goes
  away, and SIGTERM/SIGHUP to the coordinator -- or to its process group,
  as timeout(1) sends -- stop every worker, including ones that cannot
  tear down, before the coordinator exits the way a serial run would.
  A second SIGTERM used to cut the cleanup short and leave such workers
  running.
- Listeners registered inside a worker (before(:suite) register_listener)
  are notified, and an example's result is forwarded after its outcome
  notification so metadata they add (extra_failure_lines) is kept.
- Rebuilt exceptions use Exception's own backtrace accessors; a rebuilt
  Minitest::UnexpectedError used to crash dump_failures.
- --[no-]parallel[=COUNT]: a malformed COUNT is a usage error instead of
  a backtrace, 0 runs serially, --no-parallel overrides .rspec, and the
  reproduction command --bisect prints runs serially.
- Queued presentation is also flushed past 8 MB, and the parallel specs
  pass on MRI 3.0-4.0 and JRuby.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… under --parallel

Fixes from an independent review of the previous commit, each reproduced first
with real processes:

- A run started under nohup no longer dies on SIGHUP. The coordinator only
  takes over TERM/HUP when they would end the process; ignored signals and
  handlers the suite installed stay in force, and workers get back exactly
  the handlers the coordinator replaced instead of the defaults.
- Once a worker is given up on (it did not stop in time, crashed or sent
  garbage), nothing more is read from it. A worker that survived SIGTERM
  could otherwise report its example a second time, counting it twice or
  crashing the failure summary.
- Under --fail-fast, a worker that leaves cleanly right after its group, before
  the coordinator asked it to stop, is no longer reported as an error outside
  of examples.
- --bisect also takes --parallel out of SPEC_OPTS, which win over the
  command line, for its runs and the reproduction command it prints.
- Two coordinator specs no longer race under load.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… are enabled, and finish parallel runs without an idle tick

- The reproduction command --bisect prints now always ends with
  --no-parallel. Bisect cannot see config.parallel_workers set in a helper
  that the spec files require, and in a parallel run the groups of an
  order-dependent failure no longer share a process, so the command did not
  reproduce it. --parallel options are also always taken out of SPEC_OPTS
  while bisecting.
- A signal the suite ignores with trap(signal, nil) stays ignored under
  --parallel, as with "IGNORE".
- The coordinator no longer sleeps a full 0.25 s tick when every remaining
  worker is already exiting: a 24-example suite goes from 0.35 s to 0.09 s
  with --parallel=4 (serial: 0.07 s), and on 34 CI shards of a large Rails app the
  runs went from 0.13 s slower to 0.10 s faster than flatware (medians).

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… and JRuby

Found by running rspec-core's own suite under --parallel in CPU-starved
containers (MRI 3.0-4.0, JRuby 9.4-10.1):

- The coordinator specs' fake workers run their examples on threads of
  the spec's process, side by side; those examples now skip the rspec-mocks
  lifecycle, whose scopes the spec's own stubs live in (on JRuby the
  threads raced on them and stubs vanished).
- Deadline specs either use a single worker or a timeout long enough for
  the well-behaved worker, and the fake grace periods are long unless a
  spec is about them, so a slow thread death is not escalated.
- The integration specs wait for the worker pid files to be written, not
  just created; the --fail-fast spec waits for the failure instead of
  racing worker start-up; the requeue spec no longer depends on a 0.2 s
  timeout; the watchdog spec waits for its blocked thread to block.
- The SyntaxError spec no longer depends on syntax_suggest finishing its
  search twice within its 1 s timeout, and the signal spec accepts how
  JRuby reports a signal ignored with nil.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…l run is stopped

A run stopped from outside -- a CI job's timeout sending SIGTERM to the process
group, say -- was most often stuck in an example, but the output only said
"Received SIGTERM; stopping the workers.". Found running a large Rails app's
suite on Ruby 3.3 on macOS, where a worker hung inside an example (in
getaddrinfo, after fork) and finding the example took a native stack sample.

The coordinator now reads what the workers already sent and adds a line per
busy worker: "Parallel worker 1 (pid 10559) was running
./spec/lib/x_spec.rb:42 (X does y) for 14 minutes 47.3 seconds." Results are
unchanged: interrupted examples are not turned into failures.

Signalling a worker (escalation step, deadline, kill) moves to WorkerHandle.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… --no-parallel-preload

Workers forked after the spec files load inherit whatever loading did. On
macOS, after a large Rails suite's spec files have loaded, forked workers
crash (SIGABRT, Ruby 2.7) or hang at 100% CPU (Ruby 3.3) in getaddrinfo ->
Network.framework -> os_log; a probe without RSpec reproduces it, and workers
forked before loading were fine in every run. Load-time database clients are
inherited too.

With `--no-parallel-preload` (`config.parallel_preload = false`) the workers
are forked once the `--require`d files are loaded, before the spec files, and
each loads them itself with its TEST_ENV_NUMBER already set; the coordinating
process loads them too, for its reporting. Default behaviour is unchanged.

- A worker gets work only after reporting a fingerprint of the examples it
  loaded (ids, locations, descriptions, after filtering) that matches the
  coordinator's, and no load error the coordinator did not get. Otherwise it
  is not used and its groups run elsewhere; a load error every process gets
  goes on like a serial run.
- A worker still loading well after the coordinator finished (its load time
  plus parallel_shutdown_timeout) is terminated; workers there are more of
  than groups, still loading when the queue is empty, or not needed because
  the run ended early or is serial, are dismissed with SIGKILL -- they ran
  nothing. A worker given no work exits without suite hooks or at_exit.
- Groups are dispatched by id as well as index. Forking, dismissing and
  terminating workers move to a WorkerLifecycle module.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…and warn when preload settings come too late

With `--no-parallel-preload`, a worker is used if it loaded the same
example ids and locations as the coordinating process; descriptions
computed at load time (from the time or a temporary file's path) no
longer get every worker rejected. A worker that loaded other examples
is reported with the first spec file that differs.

`parallel_workers` or `parallel_preload = false` set while the spec
files load (in a file they require) comes too late to fork before
loading: the workers are forked after loading, with a warning saying
where to set it instead. `--only-failures` without a status file forks
nothing early, since that run aborts with `exit!`. The worker count is
no longer capped by the number of spec files.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ption their matcher generated

Examples without a docstring (`it { is_expected.to eq(5) }`) get their
description from the matcher once they have run, in the worker. The
coordinating process reported them as "example at ./spec/x_spec.rb:3"
in the documentation, failures, JSON and summary output; workers now
send each example's description and full description with its result,
and each group's with its start, and the coordinating process reports
them as the worker had them. With --no-parallel-preload that also
makes a description computed while a worker loaded the spec files the
worker's own, rather than the coordinating process's.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rnel.rand seed, and never fork after loading

Ruby reseeds `Kernel.rand` in a forked process, so workers that load
the spec files themselves drew other values while loading than the
coordinating process, even in a suite that seeds `Kernel.rand` from
`--seed`, and ran other examples than it reported. All of them now
start loading from the coordinating process's seed, so the values are
those of a serial run with that seed.

`parallel_workers` or `parallel_preload = false` set while the spec
files load now gives a serial run with a warning, instead of forking
after loading, which is what `parallel_preload = false` is there to
avoid. No more workers than spec files (two for a single file) are
forked before loading, so a small run does not load the suite once per
processor, and workers forked before a `fork` that fails are dismissed
rather than left behind.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Spec never calls srand

Seeding every process from the coordinating process's seed, so that
values drawn while loading matched, rewound that process's sequence
(values drawn before the workers were forked were drawn again), gave
every worker the same sequence while running examples (random ports
and keys collided between workers), and broke RSpec's rule that it
never calls `srand`.

RSpec no longer touches `Kernel.rand`: each worker keeps the seed of
its own that Ruby gives a forked process, while loading and running
alike. Examples are reported as the worker that ran them described
them, so a description drawn at random while loading is reported as
it ran, and a worker whose examples differ in ids or locations is
still not used.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… lookup per example

`File.expand_path` of a relative path asks the OS for the working
directory: about 17 microseconds on macOS. Filtering called it for every
example even with no location filter (3.3 s for 151,132 examples in one
process, 12.5 s while eight parallel workers did the same at once), and
loading called it for every example and group to re-expand the relative
form of a path RSpec had just loaded by its absolute path.

Filtering now skips the lookup when no location or id filter is set, and
loading expands the path it was given, which is also right when the
working directory changed after `Metadata.relative_path_regex` was
memoized.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rge suites

Measured on a 151,132-example suite with 8 workers:

- Workers spent ~95 microseconds per result frame, almost all of it
  marshaling `started_at`/`finished_at`: a local `Time` asks libc for its
  UTC offset, and in a process forked on macOS every such call reloads
  the time zone file. They now send whole seconds and nanoseconds and the
  coordinator rebuilds the `Time`s (~8 microseconds per frame).
- The coordinator copied the rest of its read buffer (up to a megabyte)
  for every frame it took out of it; it now keeps an offset and compacts
  once per read.
- It called `waitpid` on every worker at every wake-up of its loop; it now
  does so every 50 ms, and at once when a worker's results pipe closes.

The coordinator's CPU time fell from ~78 s to ~45 s, with workers
blocked less on its full pipes.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…'t count a stopped run against their deadlines

- A worker's pipes are now opened just before it is forked, so the
  coordinator holds 2 file descriptors per worker rather than 4: 24
  workers fit in a 64-descriptor limit. When the system refuses a worker
  (EMFILE, ENFILE, EAGAIN, ENOMEM) the run goes on with the workers it
  got, and serially if it got none, with a warning; it used to end with
  the error and 0 examples run.
- Deadlines exclude time the coordinator's loop was not running beyond
  its own waits, and a worker's exit watchdog counts only time the worker
  ran: a run stopped with Ctrl-Z (or a machine asleep) while workers ran
  their after(:suite) hooks reported each one as stuck and failed.
- The watchdog aborts the worker even when an example closed the stream
  it dumps the threads to.
- Warnings no longer end in two periods.
- The README says how to get coverage from the workers with SimpleCov
  (`merge_subprocesses`): rspec-core's suite measures 100% under
  `--parallel=8` with it, 61% without.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ter it was set

Two gaps in the previous commit, found in review and reproduced with
SIGSTOP/SIGCONT on the process group:

- A pause in the first turn of the coordinator's loop was not counted,
  so a Ctrl-Z just after it loaded the spec files still killed every
  worker that was loading them itself.
- A deadline set after a pause, later in the same turn (a --fail-fast
  stop, a worker's results pipe closing), was postponed by the whole
  pause, so a worker stuck in an example could outlive its timeout by as
  long as the run had been stopped.

Each deadline now records when it was set and is postponed only by the
part of a pause that came after it, counted from the start of the loop.

This is by Claude Opus 5.5

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Deciding whether formatters follow group boundaries compared their
methods with BaseFormatter's, which only a built-in formatter or a
subclass of it loads. With a single custom formatter that does not
inherit it, `rspec --parallel` raised NameError before running anything;
rspec-core's own spec for custom formatters passed only when an earlier
spec in the same process had loaded BaseFormatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker that did not exit in time printed its timeout followed by
"seconds" whatever the value ("within 1 seconds"); the coordinator's
messages already pluralize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by running CI's steps in Linux containers (MRI 3.0-4.0, JRuby
9.4-10.1) on this branch merged into main:

- A worker still loading once every group has been handed out is
  dismissed, so on these few-example suites one worker could take every
  group first. The specs that expect both workers to load, or the second
  to be turned away, failed on Ruby 3.0 and 3.4. They now keep groups
  queued until both workers have loaded, or let the second report first;
  the --no-parallel-preload scenario checks only what its example proves.
- coordinator_spec's fake workers are threads, and Ruby 3.0 warns when a
  thread requires digest/sha2 while another is loading it.
- A loading allowance of exactly one second reads "1 second"; two specs
  expected "seconds".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hreads

Their workers run on threads of the spec's process, and each one told to
stop arms its exit watchdog, which sleeps for up to the shutdown timeout:
60 such threads were still alive after these two files (Ruby 3.4.8). A
later example stubbing Parallel.sleep counted their calls too, so the
sleep_running specs failed on JRuby 10.0 when worker_spec ran first (1
run in 3). Each example now kills and joins the threads it started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its example passes only in a worker that loaded the spec file itself, but
a worker still loading once every group is handed out is dismissed, so
the scenario cannot show that every worker loaded the files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS, reading a worker's pipe while that worker copies a large frame
into it fails with EINTR if a signal arrives, and Ruby takes SIGCHLD
without SA_RESTART, so another worker exiting was enough. The run then
crashed, printed a summary without the failure it was receiving and
exited 1. The read moved no bytes, so it is now retried. A 5 MB failure
message under a SIGCHLD storm: 10 of 10 runs crashed before, 0 of 10 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rspec-core loaded all 12 files of the parallel runner, which serial runs
never use. Now only parallel.rb and plan.rb load with it, and the
coordinator with the rest once a run is going to fork workers, as bisect
does; its specs require it. Serial `rspec` with one example, n=100
interleaved medians: main 93.3 ms, all loaded 96.1 ms, now 93.1 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves `parallel_workers_from` above `add_tag_filter`, so that changes to
`configure_only_failures` merge without touching it, and lets the
--fail-fast spec accept further segments of the summary line (counts that
other changes add) while it still checks for the one failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker whose code under test called `exit` skipped arming its watchdog
and got no deadline from the coordinator, so a stuck `after(:suite)` hook
hung the run (a repro still ran after 75 s; now it ends in 2.9 s). On every
way out of its job loop a worker now arms the watchdog and tells the
coordinator before any `after(:suite)` hook runs.

`exit` in a worker ends the run with its status, as it does serially,
instead of failing the example. A crashed worker's example still fails and
is not run again; the examples of its group it had not started run in
another worker, and a crash in a context hook fails that group's unstarted
examples as a raising hook would. Workers that run out of work wait while
others are busy, so that one is there to take them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run is never shorter than its longest top-level group: rspec-core's own
suite under --parallel=8 spent 10.62 of its 10.64 s in one. A group tagged
with a part count now runs as that many slices of the order its examples
run in, balanced by recorded run times, each a filtered run of the group
in whichever worker is free. It is opt-in because context hooks run once
per part and parts can run at the same time; groups with a custom
ordering are not split. rspec-core tags its two longest groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a worker's `exit` has ended the run, its status stands only if every
worker then leaves on its own: another worker that is lost, fails or has to
be terminated now makes the run end with the exit code its results call
for, instead of the `exit` status. And an `after(:suite)` hook that calls
`exit` with another status decides the status, as it does serially,
instead of being reported as a worker that did not exit in time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator's report of a worker lost between example groups was
reached only when the ECHILD spec happened to reap an idle worker: on
Linux MRI 3.1 (aarch64 container) that line was missed in 3 of 6 runs of
CI's run_rspec, failing its 100% coverage minimum. Since workers now
wait while another one is busy, a worker killed in that wait is a real
case: a spec now covers it deterministically (30/30 runs reached it).

The `exit` and SIGTERM specs matched the summary line up to its end, so
upstream's open rspec#302 (examples not run) and rspec#305 (expectation counts)
broke them by appending to it; they now allow a suffix, as the fail-fast
spec already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JRuby's Example#run rescues SystemExit (example.rb), so there `exit` in
an example fails it, serially too, and never leaves the worker's job
loop. The two worker specs that call it from an example sent no stop
message after the job, so on JRuby 9.4 the worker waited for one forever
and CI's run_rspec step hung (JVM thread dump: Channel#receive in the
job loop, inside the specs' raise_error). They now skip on JRuby, where
--parallel runs serially anyway, and queue a stop so that an `exit` that
does not propagate fails them instead of hanging the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ilyazub and others added 28 commits October 13, 2026 18:28
A worker past its shutdown timeout after its last group is sent SIGTERM,
but its frames were still read: if an `after(:suite)` hook took the
signal by calling `exit 0`, that became the run's status, and a run with
an error (or failures) exited 0. Like every other path that terminates a
worker, that one now stops reading it. A coordinator spec reproduces it:
it got `exit(0)` in 3 of 3 runs before the fix and passes 5 of 5 after
(2 of 2 fail again with only the library change taken out).

When the same worker's later `exit` replaces the status, the run now
says so instead of only naming the first status. The SIGTERM spec, whose
summary match allows a suffix, also checks that no error was reported.

Found by an independent review of the last three commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every `require "rspec/core"` loaded parallel.rb and parallel/plan.rb
(about 400 lines) and every run built a plan, though only a run that
asks for workers -- `--parallel`, `config.parallel_workers` or
`config.parallel_preload = false`, set before the spec files load or by
them -- needs either. The runner now loads them for such a run only.

A one-example `rspec` process, each tree under /tmp, n=100 per arm
interleaved in one session, macOS Ruby 3.4.8 at load ~10, median (IQR):
branch base df877b7 69.6 ms (67.7-71.4), before this commit 70.4
(68.7-72.3), with it 70.1 (68.4-71.1), and 69.7 (68.3-71.2) for a
second arm of the same code. The same code run from the worktree's path
under /Users took 71.0 ms, so only runs from one location compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pec#305

The spec that checks a --no-parallel-preload run leaves Kernel.rand alone
drew its value after its own `expect`. With rspec#305 merged, every
`expect` calls `RSpec.configuration`, which in this process may create a
configuration and so draw from Kernel.rand: on a trial merge with rspec#305
the check failed after an `expect` for a serial run too, and passed when
drawn first, both serially and with --parallel=2 (4/4 pass without rspec#305).
It now draws first, and still fails when the coordinator draws (checked
by making `Coordinator#start` call Kernel.rand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A still-loading worker's deadline is twice the coordinating process's own
load time plus the shutdown timeout. At load 313 that stretched past the
2.1 s of work the other worker had, so worker 2 was dismissed unreported
and the spec failed (1 of 5 serial suite runs). The busy worker now waits
until worker 2 is gone, capped at 15 s. With the coordinating process's
loading slowed by 0.5 s per file: before 3/3 failed, after 3/3 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The busy worker could read loading.pid after it was created and before
its contents were written, and fail on Integer(""). Worker 2 now writes
a temporary file and renames it. Slowed-coordinator reproduction: 3/3
passed; the file passes 45/0 serially and with --parallel=4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…t count them

rspec#305 has rspec-expectations call the configured reporter's
increment_expectation_count for every expectation. In a worker that is
WorkerReporter, so the counts never reached the summary. Workers now send
[:expectations, n] before each unit_finished and after their after(:suite)
hooks, and the coordinator replays them if its reporter has that method.
Without rspec#305 nothing calls it and nothing is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…xit`

From an independent review: counts were sent only before unit_finished
and after the suite hooks, so an `exit` in an example or after(:suite)
hook dropped the unit's and the hooks' counts, which a serial run keeps.
Workers now also send them on leaving the job loop and from an ensure,
ignoring a channel nobody reads, as send_exiting does. The slow-loading
spec's 15 s cap is now one deadline per worker, not one per example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…e run

Found by a trial merge at load ~300: a worker deadlocked inside fork
(rb_thread_atfork waiting on a mutex, before any Ruby code ran) got a
group, looked like one running a long example, and the run waited until
`timeout` killed it after 600 s; with its coordinator-watch thread never
started, it was then orphaned. Workers now send [:started] first; until
then they get no work, are killed once nothing is left, and are sent
TERM, then KILL, if they miss the start deadline (the shutdown timeout).
Before: the new integration spec hung until killed at 90 s. After: 3/3
passed, the stuck worker killed and both examples run by the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
0780bdd held work back until a worker's first frame, so on a busy
machine the first worker to start took every small group: two of
rspec-core's parallel specs that expect each worker to run one failed
(1 of 3 dogfood runs). Workers again get a group at fork; one that
misses its start deadline has that group requeued as is. A reworked
start deadline then hung the suite under --parallel=8 (2 of 2 runs):
a late [:started] cleared the deadline fail-fast had set. Only the
start deadline itself is cleared now; any later one replaces it. New
spec: with the old clearing, the run did not end within 10 s.
Dogfood --parallel=8: 3/3 passed, 8-12 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…lsewhere

From an independent review: when the run was stopping early, the report
still said the other workers would run the stuck worker's share, but its
group was not handed on. It now says the group runs in another worker,
or that it did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… they start

Fork CI failed one coordinator spec on 4 of 44 jobs: a fake worker whose
thread started after `dismiss` had closed its pipe raised sending
[:started] and died, so only 1 of 2 workers "survived SIGKILL". The
fakes now ignore a pipe nobody reads, as the processes they stand for
would go on. With their start delayed 0.05 s: before 5/5 failed, after
5/5 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…der rspec#305

On a trial merge with rspec#305, the spec that checks a loading
worker sends nothing else got [:expectations, 1]: its own `expect`,
made while the worker ran, counted on the worker's reporter. It now
asserts once the worker has finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bisect started its runs with `-I` paths joined by `:`, which splits
`C:/...` on Windows: 8 bisect specs failed there. The rake task spec
for a clean environment now passes whether or not ANSICON is active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
From the repository's root `rspec` found no examples, so the job passed
without running any. In each library's directory, as the Linux jobs run:
rspec-core 2,465 / 0 failures, rspec-expectations 2,365 / 0, rspec-mocks
1,740 / 0, rspec-support 595 / 0 (Ruby 3.4, windows-latest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… changed

Keys an example, its hooks or a worker listener set, changed in place or
deleted reach the coordinator's copy before formatters see the example
(screenshot paths, retry counts). RSpec's own keys stay the coordinator's;
a value that cannot be marshaled is named in a warning once per key and
the formatters see the value from before the example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rinted it

A worker's standard output and error go to files in a private temporary
directory; before each frame it says how far they reach, and the
coordinator shows the new bytes in the worker's sequenced stream, so
`puts`, warnings and subprocess output land under their group as in a
serial run instead of between progress dots. What is left once a worker
has gone (a crash report, `at_exit` output) is read then. Workers
loading the spec files themselves skip their loading output, which the
coordinator already printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Where Ruby has no `fork`, each worker is a new process running the
`rspec` command the run was started with: same arguments, `.rspec`
files and `SPEC_OPTS`, no formatters, the run's seed (adopted without
choosing an ordering). It loads the spec files itself, as with
`--no-parallel-preload`, and takes jobs and sends results over its
standard input and output in binary mode. Workers notice a gone
coordinator by its pid where their parent pid never changes (Windows),
get SIGKILL where SIGTERM cannot be sent, and a run that was not
started by the `rspec` command stays serial with a warning.

Probed first on windows-latest (Ruby 3.0, 3.4), JRuby 9.4/10.0/10.1
and TruffleRuby: pipes, binary frames, capture files, exit statuses,
signals, inheritance and parent death, 18/18 checks each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…it to run

A worker lost while it ran groups (crashed, killed, terminated for being
stuck, or stuck in `fork`) is replaced by one with its number and
`TEST_ENV_NUMBER`, which runs what is left; its failed example stays
failed. Not once the run stops early or ends by `exit` or a signal, not
for a worker lost while loading, and no more often than the run has
workers. Under `--no-parallel-preload` with `fork` the replacement is a
new `rspec` process: forking the loaded coordinator is what that mode
avoids. Output files are per worker generation, so a replacement never
truncates a predecessor's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Windows CI showed: `$stdout` replaced by a StringIO broke showing worker
output (no `binmode?`); a spawned worker's start was reported only after
its `--require`d files loaded, so a slow one was called "stuck in a fork
handler"; and a failure while stopping the workers could leave the run's
SIGTERM handler installed. Workers now size their output files by path
and keep no handles to them, the coordinator restores its signal
handlers whatever stopping the workers does, and a spawned worker that
never reports back is described as not having started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
JRuby keeps `Dir.chdir` inside the JVM, so a process it spawns starts in
the JVM's directory: spawned workers now get `:chdir => Dir.pwd`, as the
spec helper that runs `rspec` in a process of its own does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…illed

A worker that cannot notice its coordinator is gone -- stopped, or stuck
before running any of RSpec's code, as a deadlock in a fork handler
leaves it -- kept CI steps open when the coordinator was killed with
SIGKILL. A small process, spawned before the first worker is forked,
watches the coordinator; when it goes it gives registered workers 1.5 s
to leave, sends SIGTERM, then SIGKILL 5 s later, and removes their
output files. It signals only registered pids still in the run's
process group, ignores INT/TERM/HUP/QUIT and exits when told the run is
done. Not covered: a worker forked moments before the coordinator was
killed and not yet registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On Windows the surviving fake worker finished every queued group before
the crash was noticed, so nothing waited to run and no replacement was
due. The remaining groups now take 0.5 s each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…speed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Metadata a worker could marshal but the coordinator could not load
  (a class defined only in the worker) made the frame undecodable, so
  the worker was terminated and a passing example failed. Each changed
  key now travels as its own dump; one that fails to load is warned
  about and left out.
- A spawned worker's `--require`d files ran `add_formatter` with a
  path, truncating the file the coordinator writes. Its formatter
  loader now opens no files.
- Output read between two frames could pile up past the sequencer's
  8 MB bound until the next loop turn; queued events are now flushed
  as soon as a bound is crossed.
- Worker output with non-ASCII bytes raised Encoding errors when
  `$stdout` had an external encoding; it is written untranscoded.
- A spawned worker that never reported back while loading was
  replaced; workers lost while loading are not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Workers that load the spec files themselves reset their reporter before
loading, which put back a formatter loader that opens files: a helper
the spec files require that adds a formatter writing to a file
truncated the coordinator's report (NUL bytes where its first lines
were, deterministically for a replacement worker). They now keep a
loader that opens nothing. Found in the re-review of the five fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Windows writes CRLF to a text-mode file and can still find a pid for a
moment after it was reaped, which two new specs assumed it would not.
The docs no longer name TruffleRuby among the platforms that spawn
workers: only the process mechanics were probed there, not RSpec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On JRuby the surviving fake worker finished every queued group before
the crash was noticed, so no replacement was due. The groups now take
0.5 s each, as in the other replacement spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ilyazub

ilyazub commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

Opus 5.5 xhigh ran these and wrote this comment.

SerpApi's app suite runs in 140 s with rspec --parallel=8 against 200 s with flatware -w 8: the same 151,725 examples with the same results. The cost is memory: 22 GB at peak against flatware's 8.5 GB.

flatware -w 8 rspec --parallel=8
wall time, 3 cold rounds 199.5, 180.6, 238.1 s 150.5, 139.8, 140.1 s
median 199.5 s 140.1 s
examples run 151,725 151,725, the same ids
results 190 failed, 3 pending the same status for every example
largest single process 2.0–2.1 GB 3.1–3.6 GB
memory at peak, all processes (one run each) 8.5 GB 22 GB

Apple M4 Max, the app's CI Ruby, --no-parallel-preload. rspec-core 3.12.1 (the version the app pins) with this branch's library backported. Cold: no recorded timings. Arm order alternated per round, with other work on the machine (load 10–33). The network was blocked: the same 190 examples fail under both runners in every pair (not classified), and one spec file that needs live services was excluded from both. Both runners also report the same error outside examples, from one of the app's after(:context) hooks.

Memory comes from separate runs, one per arm, sampled every ~5 s with macOS footprint (copy-on-write pages counted once; it reports whole GB above 10 GB). --parallel=4 peaked at 12 GB and took 283 s in its run; those runs' timings aren't used above. In the three of them where the machine load reached 75, one example's status differed between the runners. No status differed in the 3 timing pairs or in 2 more pairs at normal load (4 distinct flatware runs).

Why the memory: every worker loads the whole suite. Booting the app takes 0.6 GB, and loading all 151,725 examples brings a process to 2.0 GB. flatware's workers load only the files of their current batch. Doing the same here would change when a spec file's global configuration and shared examples take effect, so this PR doesn't.

When an example never returns. This happened in one extra run of each runner: a worker's threads stayed blocked on a lock for over 8 minutes. I then reproduced it with a 6-file fixture (one example joins a thread waiting on a mutex nobody releases, 2 workers, 3 runs per cell):

once the stuck example has started flatware -w 2 rspec --parallel=2
prints nothing Received SIGTERM; stopping the workers., the stuck example and how long it ran, the usual summary
SIGTERM to the main process only exits within 0.5 s; the stuck worker keeps running (3/3) exits within 0.5 s; nothing left (3/3)
SIGTERM to the process group exits within 0.5 s; nothing left (3/3) exits within 0.5 s; nothing left (3/3)
run wrapped in GNU timeout 12 exit 124; nothing left (3/3) exit 124; nothing left (3/3)

The SerpApi runs agreed: after SIGTERM to the main process, flatware printed nothing and left its stuck worker running. rspec --parallel named the example its stuck worker had run for 514 s, printed the summary, and left nothing behind. Neither runner notices a stuck example by itself: the run waits for a signal, as a serial run does.

Preloading doesn't work for this app on macOS. Preloading is the default: workers fork after the spec files load. In that mode 12 workers crashed (SIGABRT after a segfault in a thread), and 8 replacements were started (the cap). Every example a crashed worker hadn't started still ran in another worker. I checked the recorded ids against a full run: 18 are missing. Matched file by file against the run's failure list, they are the 12 examples that were running when their worker crashed (they fail), plus the stuck example and the 5 queued behind it that the SIGTERM above cut off. That's why the app uses --no-parallel-preload. The cause of the crashes isn't established.

Not shown here: spec names, failure messages, the environment and its services.

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