Skip to content

Commit 0d76e94

Browse files
committed
FIX: harden single-threaded fork, dispose, and interrupt handling
Recover inherited idle single-threaded runners after fork by reinitializing child-side condition variables and lazily starting a fresh runner thread in the child. Avoid single-threaded finalizer corruption by removing detached native cleanup threads and finalizing synchronously. If a forked child inherits a non-idle context, abandon the unsafe inherited native state instead of trying to tear it down. Make busy JavaScript execution interruptible from Ruby thread shutdown, Thread#kill/raise, and cross-thread Context#dispose by wiring an unblock function that terminates V8 execution. Make callback roundtrips dispose-aware so disposing while a Ruby callback is active does not hang or crash. Also make reentrant dispose from callbacks raise instead of deadlocking, add regression coverage for these fork/dispose/interruption cases, bump the gem version to 0.21.3, and fix a flaky datetime millisecond assertion that used floating point time conversion.
1 parent a9a874d commit 0d76e94

5 files changed

Lines changed: 440 additions & 33 deletions

File tree

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
- 0.21.3 - 18-06-2026
2+
- Fix `:single_threaded` contexts inherited across `fork` by recovering idle reusable native runners in the child process without falling back to per-dispatch native thread spawning
3+
- Avoid intermittent heap corruption during `:single_threaded` context finalization, especially when forked children exit normally after touching inherited contexts
4+
- Avoid finalizer hangs when a forked child garbage-collects a non-idle inherited `:single_threaded` context
5+
- Allow Ruby thread interrupts, process shutdown, and cross-thread `Context#dispose` to terminate busy `:single_threaded` JavaScript execution instead of hanging
6+
- Make `Context#dispose` while an attached Ruby callback is active either terminate safely or raise instead of deadlocking
7+
18
- 0.21.2 - 11-06-2026
29
- Add `Context#perform_microtask_checkpoint` to synchronously drain the V8 microtask queue, useful for spec-compliant `dispatchEvent` sequencing inside Ruby callbacks
310
- Fix native memory leaks in `Context#heap_snapshot`/`Context#write_heap_snapshot`; thanks to Pranjali Thakur from depthfirst.com

ext/mini_racer_extension/mini_racer_extension.c

Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <string.h>
66
#include <pthread.h>
77
#include <unistd.h>
8+
#include <errno.h>
89
#include <math.h>
910

1011
#if defined(__linux__) && !defined(__GLIBC__)
@@ -159,6 +160,7 @@ typedef struct Snapshot {
159160
} Snapshot;
160161

161162
static void context_destroy(Context *c);
163+
static void context_abandon(Context *c);
162164
static void context_free(void *arg);
163165
static void context_mark(void *arg);
164166
static size_t context_size(const void *arg);
@@ -227,6 +229,7 @@ struct rendezvous_nogvl
227229
{
228230
Context *context;
229231
Buf *req, *res;
232+
atomic_int active;
230233
};
231234

232235
struct rendezvous_des
@@ -870,8 +873,14 @@ void v8_roundtrip(Context *c, const uint8_t **p, size_t *n)
870873
{
871874
buf_reset(&c->req);
872875
pthread_cond_signal(&c->cv);
873-
while (!c->req.len)
876+
while (!c->req.len && !atomic_load(&c->quit))
874877
pthread_cond_wait(&c->cv, &c->mtx);
878+
if (!c->req.len && atomic_load(&c->quit)) {
879+
static const uint8_t disposed[] = "edisposed context";
880+
*p = disposed;
881+
*n = sizeof(disposed) - 1;
882+
return;
883+
}
875884
buf_reset(&c->res);
876885
*p = c->req.buf;
877886
*n = c->req.len;
@@ -1007,6 +1016,33 @@ static void *single_threaded_runner(void *arg)
10071016
return NULL;
10081017
}
10091018

1019+
static int single_threaded_recover_after_fork(Context *c)
1020+
{
1021+
pthread_condattr_t cattr;
1022+
pid_t pid;
1023+
int r;
1024+
1025+
pid = getpid();
1026+
if (!c->single_threaded_thr_started || c->single_threaded_pid == pid)
1027+
return 0;
1028+
if (c->depth || c->req.len || c->res.len)
1029+
return EBUSY;
1030+
1031+
if ((r = pthread_condattr_init(&cattr)))
1032+
return r;
1033+
#ifndef __APPLE__
1034+
pthread_condattr_setclock(&cattr, CLOCK_MONOTONIC);
1035+
#endif
1036+
r = pthread_cond_init(&c->cv, &cattr);
1037+
pthread_condattr_destroy(&cattr);
1038+
if (r)
1039+
return r;
1040+
1041+
c->single_threaded_thr_started = 0;
1042+
c->single_threaded_pid = pid;
1043+
return 0;
1044+
}
1045+
10101046
static int single_threaded_runner_start(Context *c)
10111047
{
10121048
pid_t pid;
@@ -1031,14 +1067,25 @@ static inline void *rendezvous_nogvl(void *arg)
10311067

10321068
a = arg;
10331069
c = a->context;
1070+
if (single_threaded && (r = single_threaded_recover_after_fork(c)))
1071+
return (void *)(intptr_t)r;
10341072
pthread_mutex_lock(&c->rr_mtx);
1073+
atomic_store(&a->active, 1);
10351074
if (c->depth > 0 && c->depth%50 == 0) { // TODO stop steep recursion
10361075
fprintf(stderr, "mini_racer: deep js->ruby->js recursion, depth=%d\n", c->depth);
10371076
fflush(stderr);
10381077
}
10391078
c->depth++;
10401079
next:
10411080
pthread_mutex_lock(&c->mtx);
1081+
if (atomic_load(&c->quit)) {
1082+
buf_reset(a->req);
1083+
pthread_mutex_unlock(&c->mtx);
1084+
c->depth--;
1085+
atomic_store(&a->active, 0);
1086+
pthread_mutex_unlock(&c->rr_mtx);
1087+
return (void *)(intptr_t)ECANCELED;
1088+
}
10421089
assert(c->req.len == 0);
10431090
assert(c->res.len == 0);
10441091
buf_move(a->req, &c->req); // v8 thread takes ownership of req
@@ -1048,6 +1095,7 @@ static inline void *rendezvous_nogvl(void *arg)
10481095
buf_move(&c->req, a->req);
10491096
pthread_mutex_unlock(&c->mtx);
10501097
c->depth--;
1098+
atomic_store(&a->active, 0);
10511099
pthread_mutex_unlock(&c->rr_mtx);
10521100
return (void *)(intptr_t)r;
10531101
}
@@ -1058,29 +1106,66 @@ static inline void *rendezvous_nogvl(void *arg)
10581106
do pthread_cond_wait(&c->cv, &c->mtx); while (!c->res.len);
10591107
}
10601108
buf_move(&c->res, a->res);
1109+
pthread_cond_broadcast(&c->cv);
10611110
pthread_mutex_unlock(&c->mtx);
10621111
if (*a->res->buf == 'c') { // js -> ruby callback?
10631112
rb_thread_call_with_gvl(rendezvous_callback, a);
10641113
buf_reset(a->res);
1114+
if (atomic_load(&c->quit)) {
1115+
buf_reset(a->req);
1116+
c->depth--;
1117+
atomic_store(&a->active, 0);
1118+
pthread_mutex_unlock(&c->rr_mtx);
1119+
return (void *)(intptr_t)ECANCELED;
1120+
}
10651121
goto next;
10661122
}
10671123
c->depth--;
1124+
atomic_store(&a->active, 0);
10681125
pthread_mutex_unlock(&c->rr_mtx);
10691126
return NULL;
10701127
}
10711128

1129+
static void rendezvous_ubf(void *arg)
1130+
{
1131+
struct rendezvous_nogvl *a;
1132+
Context *c;
1133+
1134+
a = arg;
1135+
if (!atomic_load(&a->active))
1136+
return;
1137+
c = a->context;
1138+
if (c->pst)
1139+
v8_terminate_execution(c->pst);
1140+
pthread_cond_broadcast(&c->cv);
1141+
}
1142+
1143+
static void terminate_ubf(void *arg)
1144+
{
1145+
Context *c;
1146+
1147+
c = arg;
1148+
if (c->pst)
1149+
v8_terminate_execution(c->pst);
1150+
pthread_cond_broadcast(&c->cv);
1151+
}
1152+
10721153
static void rendezvous_no_des(Context *c, Buf *req, Buf *res)
10731154
{
10741155
void *r;
1156+
struct rendezvous_nogvl a;
10751157

10761158
if (atomic_load(&c->quit)) {
10771159
buf_reset(req);
10781160
rb_raise(context_disposed_error, "disposed context");
10791161
}
1080-
r = rb_nogvl(rendezvous_nogvl, &(struct rendezvous_nogvl){c, req, res},
1081-
NULL, NULL, 0);
1162+
a.context = c;
1163+
a.req = req;
1164+
a.res = res;
1165+
atomic_init(&a.active, 0);
1166+
r = rb_nogvl(rendezvous_nogvl, &a, rendezvous_ubf, &a, 0);
10821167
if (r)
1083-
rb_raise(runtime_error, "pthread_create: %s", strerror((int)(intptr_t)r));
1168+
rb_raise(runtime_error, "single-threaded runner: %s", strerror((int)(intptr_t)r));
10841169
}
10851170

10861171
// send request to & receive reply from v8 thread; takes ownership of |req|
@@ -1096,7 +1181,8 @@ static VALUE rendezvous1(Context *c, Buf *req, DesCtx *d)
10961181
c->exception = Qnil;
10971182
// if js land didn't handle exception from ruby callback, re-raise it now
10981183
if (res.len == 1 && *res.buf == 'e') {
1099-
assert(!NIL_P(r));
1184+
if (NIL_P(r))
1185+
rb_raise(context_disposed_error, "disposed context");
11001186
rb_exc_raise(r);
11011187
}
11021188
r = rb_protect(deserialize, (VALUE)&(struct rendezvous_des){d, &res}, &exc);
@@ -1251,11 +1337,19 @@ static VALUE context_alloc(VALUE klass)
12511337
return Qnil; // pacify compiler
12521338
}
12531339

1254-
static void *context_free_thread_do(void *arg)
1340+
static void *context_free_do(void *arg)
12551341
{
12561342
Context *c;
12571343

12581344
c = arg;
1345+
if (single_threaded && single_threaded_recover_after_fork(c)) {
1346+
// The child forked while this inherited context was not idle. There is
1347+
// no live runner thread to join and the inherited V8/pthread state is
1348+
// not safe to tear down. A finalizer must not hang here; let the OS
1349+
// reclaim the abandoned V8 state when the child exits.
1350+
context_abandon(c);
1351+
return NULL;
1352+
}
12591353
if (single_threaded && c->single_threaded_thr_started && c->single_threaded_pid == getpid()) {
12601354
pthread_mutex_lock(&c->mtx);
12611355
atomic_store(&c->quit, 2);
@@ -1270,31 +1364,16 @@ static void *context_free_thread_do(void *arg)
12701364
return NULL;
12711365
}
12721366

1273-
static void context_free_thread(Context *c)
1274-
{
1275-
pthread_t thr;
1276-
int r;
1277-
1278-
// dispose on another thread so we don't block when trying to
1279-
// enter an isolate that's in a stuck state; that *should* be
1280-
// impossible but apparently it happened regularly before the
1281-
// rewrite and I'm carrying it over out of an abundance of caution
1282-
if ((r = pthread_create(&thr, NULL, context_free_thread_do, c))) {
1283-
fprintf(stderr, "mini_racer: pthread_create: %s", strerror(r));
1284-
fflush(stderr);
1285-
context_free_thread_do(c);
1286-
} else {
1287-
pthread_detach(thr);
1288-
}
1289-
}
1290-
12911367
static void context_free(void *arg)
12921368
{
12931369
Context *c;
12941370

12951371
c = arg;
12961372
if (single_threaded) {
1297-
context_free_thread(c);
1373+
// Free synchronously. A detached cleanup thread can race normal Ruby
1374+
// process shutdown and trip glibc malloc corruption checks while V8 is
1375+
// tearing down single-threaded contexts.
1376+
context_free_do(c);
12981377
} else {
12991378
pthread_mutex_lock(&c->mtx);
13001379
c->quit = 2; // 2 = v8 thread frees
@@ -1303,6 +1382,14 @@ static void context_free(void *arg)
13031382
}
13041383
}
13051384

1385+
static void context_abandon(Context *c)
1386+
{
1387+
buf_reset(&c->snapshot);
1388+
buf_reset(&c->req);
1389+
buf_reset(&c->res);
1390+
ruby_xfree(c);
1391+
}
1392+
13061393
static void context_destroy(Context *c)
13071394
{
13081395
pthread_mutex_unlock(&c->mtx);
@@ -1360,8 +1447,25 @@ static VALUE context_attach(VALUE self, VALUE name, VALUE proc)
13601447
static void *context_dispose_do(void *arg)
13611448
{
13621449
Context *c;
1450+
int r;
13631451

13641452
c = arg;
1453+
if (single_threaded) {
1454+
if ((r = single_threaded_recover_after_fork(c)))
1455+
return (void *)(intptr_t)r;
1456+
}
1457+
if (c->depth > 0) {
1458+
r = pthread_mutex_trylock(&c->rr_mtx);
1459+
if (!r) {
1460+
pthread_mutex_unlock(&c->rr_mtx);
1461+
return (void *)(intptr_t)EBUSY;
1462+
}
1463+
if (r != EBUSY)
1464+
return (void *)(intptr_t)r;
1465+
if (c->pst)
1466+
v8_terminate_execution(c->pst);
1467+
pthread_cond_broadcast(&c->cv);
1468+
}
13651469
if (single_threaded) {
13661470
pthread_mutex_lock(&c->mtx);
13671471
while (c->req.len || c->res.len)
@@ -1389,9 +1493,12 @@ static void *context_dispose_do(void *arg)
13891493
static VALUE context_dispose(VALUE self)
13901494
{
13911495
Context *c;
1496+
void *r;
13921497

13931498
TypedData_Get_Struct(self, Context, &context_type, c);
1394-
rb_thread_call_without_gvl(context_dispose_do, c, NULL, NULL);
1499+
r = rb_thread_call_without_gvl(context_dispose_do, c, terminate_ubf, c);
1500+
if (r)
1501+
rb_raise(runtime_error, "context dispose: %s", strerror((int)(intptr_t)r));
13951502
return Qnil;
13961503
}
13971504

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.2"
4+
VERSION = "0.21.3"
55
LIBV8_NODE_VERSION = "~> 24.12.0.1"
66
end

0 commit comments

Comments
 (0)