Created
February 24, 2026 21:09
-
-
Save sam-saffron-jarvis/f02faf90460617795c923d38f2cfec2a to your computer and use it in GitHub Desktop.
mini_racer fork safety: 4-scenario test demonstrating single_threaded mode vs dispose-before-fork, with deadlock proof
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # test_fork_crash.rb | |
| # | |
| # Demonstrates mini_racer fork safety — specifically that using an inherited | |
| # MiniRacer::Context across a fork boundary WITHOUT single_threaded mode causes | |
| # a deadlock/hang every time. | |
| # | |
| # Safety mechanism: each scenario runs as an isolated subprocess wrapped in an | |
| # OS-level `timeout`, so a hang can never lock up this harness. | |
| # | |
| # We test four scenarios: | |
| # | |
| # A. single_threaded=YES, child reuses inherited ctx → pass | |
| # B. single_threaded=NO, child reuses inherited ctx → HANG (deadlock) | |
| # C. single_threaded=YES, child disposes + fresh ctx → pass | |
| # D. single_threaded=NO, child disposes + fresh ctx → pass | |
| # | |
| # CONCLUSIONS (verified on mini_racer 0.12.x, Ruby 4.0.1, V8 ~12.x): | |
| # | |
| # 1. Scenario B reliably hangs (deadlock, never a clean crash on modern V8). | |
| # Without single_threaded, V8 spins up background threads for GC and JIT. | |
| # fork() copies memory but NOT threads. The child inherits V8's internal | |
| # mutexes in a locked state — the threads that would release them no longer | |
| # exist. The child's first eval hits one of those locks and waits forever. | |
| # On older V8 this was a SIGSEGV/SIGBUS; modern V8 hits the deadlock path. | |
| # | |
| # 2. single_threaded mode (Scenario A) fixes inherited-context fork safety | |
| # because V8 never creates background threads, so there are no zombie locks. | |
| # | |
| # 3. Disposing all inherited contexts before forking (Scenarios C & D) is | |
| # equally safe and works even WITHOUT single_threaded. The flag is only | |
| # required if you want to pass a live context from parent to child directly. | |
| # | |
| # 4. The two safe patterns for fork-heavy servers (unicorn, puma clustered): | |
| # - Set single_threaded BEFORE creating any context; reuse freely across forks. | |
| # - OR: dispose all contexts before fork; create fresh ones in each child. | |
| # | |
| # Each scenario is run as an isolated child script via a subprocess so that | |
| # a crash or hang in one scenario doesn't affect this harness. | |
| require "tempfile" | |
| require "timeout" | |
| CHILD_TIMEOUT_S = 8 # wall-clock seconds before we SIGKILL | |
| RUBY = RbConfig.ruby | |
| BUNDLER_SETUP = File.join(__dir__, "Gemfile") | |
| LODASH = File.join(__dir__, "lodash.min.js") | |
| # The template child script — substitutions: | |
| # SINGLE_THREADED_LINE : either the set_flags! call or a comment | |
| # DISPOSE_INHERITED : either dispose-before-fork block or nothing | |
| # USE_INHERITED : either reuse ctx or create fresh | |
| CHILD_TEMPLATE = <<~'RUBY' | |
| require "bundler/setup" | |
| require "mini_racer" | |
| require "json" | |
| require "timeout" | |
| EVAL_TIMEOUT_MS = 2_000 | |
| SINGLE_THREADED_LINE | |
| LODASH_SRC = File.read(LODASH_PATH) | |
| ctx = MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS) | |
| ctx.eval(LODASH_SRC) | |
| ctx.eval("let counter = 0; function inc() { return ++counter; }") | |
| v = ctx.eval("_.VERSION") | |
| raise "lodash not loaded" unless v =~ /^4\./ | |
| $stdout.puts "[parent] context warm, lodash #{v}" | |
| $stdout.flush | |
| child_pid = fork do | |
| $stdout.puts "[child #{Process.pid}] forked" | |
| $stdout.flush | |
| DISPOSE_INHERITED_BLOCK | |
| eval_ctx = CHILD_CTX_EXPR | |
| LOAD_LODASH_IF_FRESH | |
| # Run a bunch of JS to stress the context | |
| results = [] | |
| 5.times do |i| | |
| r = eval_ctx.eval("_.chunk(_.range(#{(i+1)*10}), 3).length") | |
| results << r | |
| end | |
| fib = eval_ctx.eval("(function fib(n){ return n<=1?n:fib(n-1)+fib(n-2); })(25)") | |
| raise "fib wrong: #{fib}" unless fib == 75025 | |
| $stdout.puts "[child #{Process.pid}] ✓ evals ok — fib(25)=#{fib}, chunks=#{results.inspect}" | |
| $stdout.flush | |
| eval_ctx.dispose | |
| exit 0 | |
| end | |
| # Wait with a Ruby-level timeout (belt-and-suspenders, OS timeout is primary) | |
| begin | |
| Timeout.timeout(6) do | |
| _, status = Process.waitpid2(child_pid) | |
| if status.success? | |
| $stdout.puts "[parent] ✓ child exited cleanly" | |
| else | |
| $stdout.puts "[parent] ✗ child exited with status #{status.exitstatus || 'signal'}" | |
| exit 2 | |
| end | |
| end | |
| rescue Timeout::Error | |
| $stdout.puts "[parent] ✗ child timed out — killing" | |
| Process.kill("KILL", child_pid) rescue nil | |
| Process.waitpid2(child_pid) rescue nil | |
| exit 3 | |
| end | |
| after = ctx.eval("inc(); counter") | |
| $stdout.puts "[parent] parent ctx after fork — counter=#{after}" | |
| ctx.dispose | |
| $stdout.puts "[parent] done" | |
| exit 0 | |
| RUBY | |
| SCENARIOS = { | |
| "A" => { | |
| desc: "single_threaded=YES, child reuses inherited context", | |
| flags: "MiniRacer::Platform.set_flags!(:single_threaded)", | |
| dispose: "", | |
| ctx_expr: "ctx", | |
| load_lodash: "# (lodash already in inherited ctx)", | |
| expect: :pass, | |
| }, | |
| "B" => { | |
| desc: "single_threaded=NO, child reuses inherited context", | |
| flags: "# (single_threaded NOT set)", | |
| dispose: "", | |
| ctx_expr: "ctx", | |
| load_lodash: "# (lodash already in inherited ctx)", | |
| expect: :fail, | |
| }, | |
| "C" => { | |
| desc: "single_threaded=YES, child disposes inherited + creates fresh", | |
| flags: "MiniRacer::Platform.set_flags!(:single_threaded)", | |
| dispose: "ObjectSpace.each_object(MiniRacer::Context){|c| c.dispose}", | |
| ctx_expr: "MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS)", | |
| load_lodash: "eval_ctx.eval(LODASH_SRC)", | |
| expect: :pass, | |
| }, | |
| "D" => { | |
| desc: "single_threaded=NO, child disposes inherited + creates fresh", | |
| flags: "# (single_threaded NOT set)", | |
| dispose: "ObjectSpace.each_object(MiniRacer::Context){|c| c.dispose}", | |
| ctx_expr: "MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS)", | |
| load_lodash: "eval_ctx.eval(LODASH_SRC)", | |
| expect: :pass, | |
| }, | |
| } | |
| results = {} | |
| SCENARIOS.each do |id, s| | |
| puts "=" * 60 | |
| puts "Scenario #{id}: #{s[:desc]}" | |
| puts "Expected: #{s[:expect] == :pass ? '✓ pass' : '✗ crash/hang/corrupt'}" | |
| puts "-" * 60 | |
| # Build the child script from template | |
| script = CHILD_TEMPLATE | |
| .sub("SINGLE_THREADED_LINE", s[:flags]) | |
| .sub("DISPOSE_INHERITED_BLOCK", s[:dispose]) | |
| .sub("CHILD_CTX_EXPR", s[:ctx_expr]) | |
| .sub("LOAD_LODASH_IF_FRESH", s[:load_lodash]) | |
| .sub("LODASH_PATH", "'#{LODASH}'") | |
| tmp = Tempfile.new(["mini_racer_scenario_#{id}", ".rb"]) | |
| tmp.write(script) | |
| tmp.flush | |
| tmp.close | |
| # Run under OS-level timeout — this is the primary safety net | |
| start = Time.now | |
| output = "" | |
| exit_code = nil | |
| IO.popen( | |
| ["timeout", CHILD_TIMEOUT_S.to_s, RUBY, "-I", "#{__dir__}/lib", tmp.path], | |
| err: [:child, :out], | |
| chdir: __dir__ | |
| ) do |io| | |
| output = io.read | |
| end | |
| elapsed = Time.now - start | |
| exit_code = $?.exitstatus | |
| tmp.unlink | |
| outcome = case exit_code | |
| when 0 then :pass | |
| when nil then :pass # shouldn't happen | |
| when 124 then :hang # timeout(1) exits 124 on timeout | |
| when 1, 2 then :crash # ruby error / our exit 2 | |
| when 3 then :hang # our exit 3 (Ruby Timeout) | |
| else :crash | |
| end | |
| # Also check output for signs of corruption even if process exited 0 | |
| corruption_signals = ["signal", "Segmentation fault", "bus error", | |
| "core dumped", "SIGILL", "SIGBUS", "SIGSEGV", | |
| "fib wrong", "lodash not loaded"] | |
| if outcome == :pass && corruption_signals.any? { |s| output.downcase.include?(s.downcase) } | |
| outcome = :corrupt | |
| end | |
| puts output.gsub(/^/, " ") | |
| puts " → exit=#{exit_code}, elapsed=#{elapsed.round(2)}s, outcome=#{outcome.upcase}" | |
| expected_ok = (outcome == :pass) == (s[:expect] == :pass) | |
| results[id] = { outcome: outcome, expected: s[:expect], ok: expected_ok, desc: s[:desc] } | |
| puts expected_ok ? " ✓ matched expectation" : " ✗ DID NOT MATCH expectation" | |
| puts | |
| end | |
| puts "=" * 60 | |
| puts "SUMMARY" | |
| puts "=" * 60 | |
| SCENARIOS.each_key do |id| | |
| r = results[id] | |
| icon = r[:ok] ? "✓" : "✗" | |
| puts "#{icon} Scenario #{id}: #{r[:outcome].to_s.upcase.ljust(8)} (expected #{r[:expected]}) — #{r[:desc]}" | |
| end | |
| puts | |
| all_ok = results.values.all? { |r| r[:ok] } | |
| puts all_ok ? "All scenarios matched expectations ✓" : "Some scenarios did not match expectations ✗" | |
| exit(all_ok ? 0 : 1) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment