Skip to content

Instantly share code, notes, and snippets.

@sam-saffron-jarvis
Created February 24, 2026 21:09
Show Gist options
  • Select an option

  • Save sam-saffron-jarvis/f02faf90460617795c923d38f2cfec2a to your computer and use it in GitHub Desktop.

Select an option

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
#!/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