Skip to content

Commit fcfa4bf

Browse files
committed
FIX: reuse native runner across fork
Run single-threaded V8 dispatches on a reusable mini_racer-owned native thread. All V8 work must be isolated from Ruby owned threads or we risk segfaults
1 parent 6cf79b4 commit fcfa4bf

6 files changed

Lines changed: 239 additions & 20 deletions

File tree

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
- 0.21.1 - 25-05-2026
2+
- Run `:single_threaded` V8 dispatches on a reusable mini_racer-owned native thread so V8 does not execute on Ruby-owned threads
3+
- Stop and join the reusable `:single_threaded` runner when contexts are disposed
4+
- Document `:single_threaded` fork-safety requirements for pre-fork contexts
5+
16
- 0.21.0 - 16-04-2026
27
- Add MiniRacer::Binary for returning Uint8Array to JavaScript from attached Ruby callbacks
38

DESIGN.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ they are almost universally:
5454
deliberate changes & known bugs
5555
===============================
5656

57-
- `MiniRacer::Platform.set_flags! :single_threaded` still runs everything on
58-
the same thread but is prone to crashes in Ruby < 3.4.0 due to a Ruby runtime
59-
bug that clobbers thread-local variables.
57+
- `MiniRacer::Platform.set_flags! :single_threaded` runs V8 dispatches on a
58+
reusable mini_racer-owned native thread. Pre-fork contexts may be used in the
59+
child only if the process forks while MiniRacer is quiescent; applications that
60+
fork from a multi-threaded process should guard all MiniRacer context
61+
operations and `fork` with the same lock.
6062

6163
- The `Isolate` class is gone. Maintaining a one-to-many relationship between
6264
isolates and contexts in a multi-threaded environment had a bad cost/benefit

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ Since 0.6.1 mini_racer does support V8 single threaded platform mode which shoul
139139
MiniRacer::Platform.set_flags!(:single_threaded)
140140
```
141141

142+
When using pre-fork `MiniRacer::Context` objects in `:single_threaded` mode,
143+
ensure the process only forks while MiniRacer is quiescent: no thread may be
144+
evaluating JavaScript, calling into a context, disposing/freeing a context,
145+
running a Ruby callback from JavaScript, or otherwise using MiniRacer at the
146+
instant of `fork`. In multi-threaded applications, guard all MiniRacer context
147+
operations and the `fork` itself with the same application-level lock. Forking
148+
while a MiniRacer operation is in progress can leave inherited pthread mutexes
149+
in an unusable state in the child process.
150+
142151
If you want to ensure your application does not leak memory after fork either:
143152

144153
1. Ensure no `MiniRacer::Context` objects are created in the master process; or

ext/mini_racer_extension/mini_racer_extension.c

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <stdlib.h>
55
#include <string.h>
66
#include <pthread.h>
7+
#include <unistd.h>
78
#include <math.h>
89

910
#if defined(__linux__) && !defined(__GLIBC__)
@@ -136,6 +137,9 @@ typedef struct Context
136137
VALUE exception; // pending exception or Qnil
137138
Buf req, res; // ruby->v8 request/response, mediated by |mtx| and |cv|
138139
Buf snapshot;
140+
pthread_t single_threaded_thr;
141+
pid_t single_threaded_pid;
142+
int single_threaded_thr_started;
139143
// |rr_mtx| stands for "recursive ruby mutex"; it's used to exclude
140144
// other ruby threads but allow reentrancy from the same ruby thread
141145
// (think ruby->js->ruby->js calls)
@@ -868,18 +872,10 @@ void v8_dispatch(Context *c)
868872
// only called when inside v8_call, v8_eval, or v8_pump_message_loop
869873
void v8_roundtrip(Context *c, const uint8_t **p, size_t *n)
870874
{
871-
struct rendezvous_nogvl *args;
872-
873875
buf_reset(&c->req);
874-
if (single_threaded) {
875-
assert(*c->res.buf == 'c'); // js -> ruby callback
876-
args = &(struct rendezvous_nogvl){c, &c->req, &c->res};
877-
rb_thread_call_with_gvl(rendezvous_callback, args);
878-
} else {
879-
pthread_cond_signal(&c->cv);
880-
while (!c->req.len)
881-
pthread_cond_wait(&c->cv, &c->mtx);
882-
}
876+
pthread_cond_signal(&c->cv);
877+
while (!c->req.len)
878+
pthread_cond_wait(&c->cv, &c->mtx);
883879
buf_reset(&c->res);
884880
*p = c->req.buf;
885881
*n = c->req.len;
@@ -991,10 +987,45 @@ static void *rendezvous_callback(void *arg)
991987
goto out;
992988
}
993989

990+
static void *single_threaded_runner(void *arg)
991+
{
992+
Context *c;
993+
994+
c = arg;
995+
pthread_mutex_lock(&c->mtx);
996+
for (;;) {
997+
while (!c->req.len && atomic_load(&c->quit) < 1)
998+
pthread_cond_wait(&c->cv, &c->mtx);
999+
if (atomic_load(&c->quit) >= 1)
1000+
break;
1001+
v8_single_threaded_enter(c->pst, c, dispatch);
1002+
pthread_cond_signal(&c->cv);
1003+
}
1004+
pthread_mutex_unlock(&c->mtx);
1005+
return NULL;
1006+
}
1007+
1008+
static int single_threaded_runner_start(Context *c)
1009+
{
1010+
pid_t pid;
1011+
int r;
1012+
1013+
pid = getpid();
1014+
if (c->single_threaded_thr_started && c->single_threaded_pid == pid)
1015+
return 0;
1016+
c->single_threaded_thr_started = 0;
1017+
c->single_threaded_pid = pid;
1018+
r = pthread_create(&c->single_threaded_thr, NULL, single_threaded_runner, c);
1019+
if (!r)
1020+
c->single_threaded_thr_started = 1;
1021+
return r;
1022+
}
1023+
9941024
static inline void *rendezvous_nogvl(void *arg)
9951025
{
9961026
struct rendezvous_nogvl *a;
9971027
Context *c;
1028+
int r;
9981029

9991030
a = arg;
10001031
c = a->context;
@@ -1010,7 +1041,16 @@ static inline void *rendezvous_nogvl(void *arg)
10101041
assert(c->res.len == 0);
10111042
buf_move(a->req, &c->req); // v8 thread takes ownership of req
10121043
if (single_threaded) {
1013-
v8_single_threaded_enter(c->pst, c, dispatch);
1044+
r = single_threaded_runner_start(c);
1045+
if (r) {
1046+
buf_move(&c->req, a->req);
1047+
pthread_mutex_unlock(&c->mtx);
1048+
c->depth--;
1049+
pthread_mutex_unlock(&c->rr_mtx);
1050+
return (void *)(intptr_t)r;
1051+
}
1052+
pthread_cond_signal(&c->cv);
1053+
do pthread_cond_wait(&c->cv, &c->mtx); while (!c->res.len);
10141054
} else {
10151055
pthread_cond_signal(&c->cv);
10161056
do pthread_cond_wait(&c->cv, &c->mtx); while (!c->res.len);
@@ -1019,6 +1059,7 @@ static inline void *rendezvous_nogvl(void *arg)
10191059
pthread_mutex_unlock(&c->mtx);
10201060
if (*a->res->buf == 'c') { // js -> ruby callback?
10211061
rb_thread_call_with_gvl(rendezvous_callback, a);
1062+
buf_reset(a->res);
10221063
goto next;
10231064
}
10241065
c->depth--;
@@ -1028,12 +1069,16 @@ static inline void *rendezvous_nogvl(void *arg)
10281069

10291070
static void rendezvous_no_des(Context *c, Buf *req, Buf *res)
10301071
{
1072+
void *r;
1073+
10311074
if (atomic_load(&c->quit)) {
10321075
buf_reset(req);
10331076
rb_raise(context_disposed_error, "disposed context");
10341077
}
1035-
rb_nogvl(rendezvous_nogvl, &(struct rendezvous_nogvl){c, req, res},
1036-
NULL, NULL, 0);
1078+
r = rb_nogvl(rendezvous_nogvl, &(struct rendezvous_nogvl){c, req, res},
1079+
NULL, NULL, 0);
1080+
if (r)
1081+
rb_raise(runtime_error, "pthread_create: %s", strerror((int)(intptr_t)r));
10371082
}
10381083

10391084
// send request to & receive reply from v8 thread; takes ownership of |req|
@@ -1190,7 +1235,16 @@ static void *context_free_thread_do(void *arg)
11901235
Context *c;
11911236

11921237
c = arg;
1193-
v8_single_threaded_dispose(c->pst);
1238+
if (single_threaded && c->single_threaded_thr_started && c->single_threaded_pid == getpid()) {
1239+
pthread_mutex_lock(&c->mtx);
1240+
atomic_store(&c->quit, 2);
1241+
pthread_cond_signal(&c->cv);
1242+
pthread_mutex_unlock(&c->mtx);
1243+
pthread_join(c->single_threaded_thr, NULL);
1244+
}
1245+
if (c->pst)
1246+
v8_single_threaded_dispose(c->pst);
1247+
pthread_mutex_lock(&c->mtx);
11941248
context_destroy(c);
11951249
return NULL;
11961250
}
@@ -1284,8 +1338,18 @@ static void *context_dispose_do(void *arg)
12841338

12851339
c = arg;
12861340
if (single_threaded) {
1341+
pthread_mutex_lock(&c->mtx);
1342+
while (c->req.len || c->res.len)
1343+
pthread_cond_wait(&c->cv, &c->mtx);
12871344
atomic_store(&c->quit, 1); // disposed
1288-
// intentionally a no-op for now
1345+
if (c->single_threaded_thr_started && c->single_threaded_pid == getpid()) {
1346+
pthread_cond_signal(&c->cv);
1347+
pthread_mutex_unlock(&c->mtx);
1348+
pthread_join(c->single_threaded_thr, NULL);
1349+
pthread_mutex_lock(&c->mtx);
1350+
c->single_threaded_thr_started = 0;
1351+
}
1352+
pthread_mutex_unlock(&c->mtx);
12891353
} else {
12901354
pthread_mutex_lock(&c->mtx);
12911355
while (c->req.len || c->res.len)

lib/mini_racer/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

33
module MiniRacer
4-
VERSION = "0.21.0"
4+
VERSION = "0.21.1"
55
LIBV8_NODE_VERSION = "~> 24.12.0.1"
66
end

test/single_threaded_test.rb

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "open3"
5+
require "rbconfig"
6+
require "tempfile"
7+
8+
class MiniRacerSingleThreadedTest < Minitest::Test
9+
def assert_single_threaded_script(script)
10+
skip "single-threaded V8 platform tests are only for CRuby" unless RUBY_ENGINE == "ruby"
11+
12+
file = Tempfile.new(["mini_racer_single_threaded", ".rb"])
13+
file.write(<<~RUBY)
14+
$LOAD_PATH.unshift #{File.expand_path("../lib", __dir__).inspect}
15+
require "mini_racer"
16+
17+
MiniRacer::Platform.set_flags!(:single_threaded)
18+
19+
#{script}
20+
RUBY
21+
file.close
22+
23+
stdout, stderr, status = Open3.capture3(RbConfig.ruby, file.path)
24+
assert status.success?, <<~MSG
25+
single-threaded script failed with status #{status.exitstatus}
26+
stdout:
27+
#{stdout}
28+
stderr:
29+
#{stderr}
30+
MSG
31+
ensure
32+
file&.unlink
33+
end
34+
35+
def test_basic_eval_and_call
36+
assert_single_threaded_script <<~'RUBY'
37+
context = MiniRacer::Context.new
38+
raise "bad eval" unless context.eval("1 + 1") == 2
39+
context.eval("function add(a, b) { return a + b }")
40+
raise "bad call" unless context.call("add", 20, 22) == 42
41+
RUBY
42+
end
43+
44+
def test_ruby_callback_from_javascript
45+
assert_single_threaded_script <<~'RUBY'
46+
context = MiniRacer::Context.new
47+
context.attach("ruby_add", proc { |a, b| a + b })
48+
raise "bad callback result" unless context.eval("ruby_add(20, 22)") == 42
49+
RUBY
50+
end
51+
52+
def test_nested_javascript_ruby_javascript_call
53+
assert_single_threaded_script <<~'RUBY'
54+
context = MiniRacer::Context.new
55+
context.eval("function js_add(a, b) { return a + b }")
56+
context.attach("ruby_calls_js", proc { context.call("js_add", 20, 22) })
57+
raise "bad nested callback result" unless context.eval("ruby_calls_js()") == 42
58+
RUBY
59+
end
60+
61+
def test_recursive_javascript_ruby_callback_ping_pong
62+
assert_single_threaded_script <<~'RUBY'
63+
context = MiniRacer::Context.new
64+
context.attach("ruby_recurse", proc { |n|
65+
n <= 0 ? "done" : context.call("js_recurse", n - 1)
66+
})
67+
context.eval(<<~JS)
68+
function js_recurse(n) {
69+
if (n <= 0) return "done";
70+
return ruby_recurse(n);
71+
}
72+
JS
73+
raise "bad recursive callback result" unless context.call("js_recurse", 10) == "done"
74+
RUBY
75+
end
76+
77+
def test_ruby_callback_exception_propagates
78+
assert_single_threaded_script <<~'RUBY'
79+
context = MiniRacer::Context.new
80+
context.attach("boom", proc { raise "ruby boom" })
81+
82+
begin
83+
context.eval("boom()")
84+
raise "expected callback exception"
85+
rescue RuntimeError => e
86+
raise "wrong exception: #{e.class}: #{e.message}" unless e.message.include?("ruby boom")
87+
end
88+
RUBY
89+
end
90+
91+
def test_dispose_after_runner_started
92+
assert_single_threaded_script <<~'RUBY'
93+
context = MiniRacer::Context.new
94+
raise "bad eval" unless context.eval("1 + 1") == 2
95+
context.dispose
96+
97+
begin
98+
context.eval("1 + 1")
99+
raise "expected disposed error"
100+
rescue MiniRacer::ContextDisposedError
101+
end
102+
103+
context = nil
104+
GC.start
105+
RUBY
106+
end
107+
108+
def test_multiple_contexts_and_dispose_one
109+
assert_single_threaded_script <<~'RUBY'
110+
a = MiniRacer::Context.new
111+
b = MiniRacer::Context.new
112+
113+
a.eval("var x = 1")
114+
b.eval("var x = 2")
115+
116+
raise "bad context a" unless a.eval("x") == 1
117+
raise "bad context b" unless b.eval("x") == 2
118+
119+
a.dispose
120+
raise "context b broke after disposing a" unless b.eval("x + 40") == 42
121+
RUBY
122+
end
123+
124+
def test_fork_after_runner_started_and_idle
125+
assert_single_threaded_script <<~'RUBY'
126+
exit 0 unless Process.respond_to?(:fork)
127+
128+
context = MiniRacer::Context.new
129+
context.eval("var answer = 41")
130+
context.eval("answer += 1") # starts the reusable runner and leaves it idle
131+
132+
pid = fork do
133+
exit(context.eval("answer") == 42 ? 0 : 1)
134+
end
135+
Process.wait(pid)
136+
raise "child failed" unless $?.success?
137+
RUBY
138+
end
139+
end

0 commit comments

Comments
 (0)