Skip to content

Commit 60d5abd

Browse files
authored
Root janet_vm.top_dyns to stop the plugin-worker SIGSEGV (#830)
mirror_capture_buffers_into_top_dyns created the top-level dyn table via janet_setdyn but rooted only the buffers inside it. Janet's collector never marks janet_vm.top_dyns — the field appears in the dyn read, the setdyn lazy create, and janet_init/janet_deinit, and in no mark path — so the first full collection freed the table. The next no-fiber janet_dyn, the :err-color lookup in janet_stacktrace_ext, then read freed memory and faulted in janet_dict_find. That matches all three crash reports, including why each one hashed the same key. Root the table itself, the way Janet roots abstract_registry. Both call sites run once at thread init and top_dyns is never reassigned mid-life, so it stays one root per VM. The offset-8 read of a layout private to janet.c is pinned by top_dyns_offset_is_stable, which runs in the normal suite. janetrs depends on evil-janet "1", so without it an upstream field insertion would silently hand janet_gcroot whatever moved into the slot instead of failing the build. plugin_worker_uaf_stress is the ignored repro driver: it faulted in 3 turns before the fix and now survives 30. Refs: dirge-eona
1 parent fd097e9 commit 60d5abd

2 files changed

Lines changed: 240 additions & 0 deletions

File tree

src/plugin/mod_tests.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3420,3 +3420,187 @@ fn call_tool_defaults_missing_args_to_empty_object() {
34203420
Ok("nil".to_string())
34213421
);
34223422
}
3423+
3424+
// --- dirge-eona stress harness --------------------------------------
3425+
//
3426+
// Not a correctness test — a repro driver for the Janet-heap
3427+
// use-after-free that SIGSEGVs the plugin worker (dirge-eona). It
3428+
// replays the workload that precedes the crash: hook dispatch across a
3429+
// session with a response-capturing plugin loaded, then a slash-command
3430+
// invocation, under explicit GC pressure. Ignored so the normal suite
3431+
// never runs it; drive it explicitly:
3432+
//
3433+
// cargo test --bin dirge plugin_worker_uaf_stress -- --ignored --nocapture
3434+
//
3435+
// and under lldb for a symbolicated backtrace at the fault.
3436+
//
3437+
// Knobs (env): DIRGE_STRESS_TURNS (default 200),
3438+
// DIRGE_STRESS_UPDATES (25 per turn), DIRGE_STRESS_RESPONSE_BYTES
3439+
// (20000), DIRGE_STRESS_USER_PLUGINS (1 = load ~/.config/dirge/plugins,
3440+
// exactly the crashing session's env).
3441+
#[cfg(feature = "plugin")]
3442+
#[test]
3443+
#[ignore]
3444+
fn plugin_worker_uaf_stress() {
3445+
fn env_usize(name: &str, default: usize) -> usize {
3446+
std::env::var(name)
3447+
.ok()
3448+
.and_then(|v| v.parse().ok())
3449+
.unwrap_or(default)
3450+
}
3451+
let turns = env_usize("DIRGE_STRESS_TURNS", 200);
3452+
let updates = env_usize("DIRGE_STRESS_UPDATES", 25);
3453+
let response_bytes = env_usize("DIRGE_STRESS_RESPONSE_BYTES", 20_000);
3454+
let load_user_plugins = std::env::var("DIRGE_STRESS_USER_PLUGINS")
3455+
.map(|v| v != "0")
3456+
.unwrap_or(true);
3457+
3458+
let mut mgr = PluginManager::try_new().unwrap();
3459+
3460+
// The crashing sessions had the user's whole plugin dir loaded
3461+
// (~38 entries, several of them response-capturing). Reproduce that
3462+
// env; per-plugin failures are not the point of the harness.
3463+
if load_user_plugins && let Ok(home) = std::env::var("HOME") {
3464+
let dir = std::path::PathBuf::from(home).join(".config/dirge/plugins");
3465+
if dir.is_dir() {
3466+
let mut entries: Vec<_> = std::fs::read_dir(&dir)
3467+
.unwrap()
3468+
.filter_map(|e| e.ok().map(|e| e.path()))
3469+
.filter(|p| p.extension().is_some_and(|e| e == "janet") || p.is_dir())
3470+
.collect();
3471+
entries.sort();
3472+
for path in entries {
3473+
let _ = load_plugin(&mut mgr, &path);
3474+
}
3475+
}
3476+
}
3477+
3478+
// Realistic response material: markdown + code fences + quotes +
3479+
// backslashes + unicode, so the ctx escaper sees the same shape of
3480+
// text the crashing sessions carried.
3481+
let unit = "Some **markdown** with `code` and \"quotes\" and \\\\ paths — ünïcødé ✓\n```rust\nfn main() { println!(\"line\"); }\n```\n";
3482+
let big_response = unit.repeat(response_bytes / unit.len() + 1);
3483+
3484+
for turn in 0..turns {
3485+
mgr.dispatch("on-prompt", "@{:prompt \"stress turn\"}")
3486+
.unwrap();
3487+
mgr.dispatch("on-turn-start", "@{}").unwrap();
3488+
for u in 0..updates {
3489+
let partial_len = turn * 100 + u * 50;
3490+
let mut pend = partial_len.min(big_response.len());
3491+
while pend > 0 && !big_response.is_char_boundary(pend) {
3492+
pend -= 1;
3493+
}
3494+
let partial = &big_response[..pend];
3495+
let ctx = format!(
3496+
"@{{:index {} :partial \"{}\"}}",
3497+
u,
3498+
escape_janet_string(partial)
3499+
);
3500+
mgr.dispatch("on-message-update", &ctx).unwrap();
3501+
}
3502+
let mut rend = (response_bytes + turn * 10).min(big_response.len());
3503+
while rend > 0 && !big_response.is_char_boundary(rend) {
3504+
rend -= 1;
3505+
}
3506+
let response = &big_response[..rend];
3507+
let ctx = format!("@{{:response \"{}\"}}", escape_janet_string(response));
3508+
mgr.dispatch("on-response", &ctx).unwrap();
3509+
// Force collection every turn so freed slots get reused — the
3510+
// dirge-eona window. No-op turns: a normal, collected GC cycle.
3511+
mgr.eval("(gccollect)").unwrap();
3512+
let r = mgr.invoke_command("resp-clipboard-handler", "").unwrap();
3513+
if turn % 50 == 0 {
3514+
println!(
3515+
"turn {turn}: {:?}",
3516+
r.map(|s| s.chars().take(60).collect::<String>())
3517+
);
3518+
}
3519+
}
3520+
println!("stress completed without crashing: {turns} turns");
3521+
}
3522+
3523+
/// dirge-eona: pins the `JanetVMPrefix` layout assumption that the fix rests
3524+
/// on. `top_dyns_ptr` reads offset 8 of a struct whose definition is private
3525+
/// to janet.c, and `janetrs` depends on `evil-janet = "1"`, so a `cargo
3526+
/// update` can move the field under us. A wrong offset would not fail the
3527+
/// build — it would hand `janet_gcroot` whatever now lives there, which is
3528+
/// far worse than the crash it replaced. The stress harness above cannot
3529+
/// catch that: it is `#[ignore]`d and needs the user's plugin dir.
3530+
///
3531+
/// This runs in the normal suite instead. It owns a bare VM on its own
3532+
/// thread (no default env, so nothing but this test writes a dyn), and leans
3533+
/// on the fact that no fiber is live here: `janet_setdyn` therefore writes
3534+
/// `top_dyns` (janet.c:4657), which is the same state the worker mirrors in.
3535+
///
3536+
/// The null-before assertion is what discriminates the field: every nearby
3537+
/// `JanetTable *` in `struct JanetVM` behaves differently across these
3538+
/// steps. `abstract_registry` is already non-null after `janet_init`
3539+
/// (janet.c:35577), and `core_env` stays null and never gains entries from
3540+
/// `janet_setdyn`.
3541+
#[cfg(feature = "plugin")]
3542+
#[test]
3543+
fn top_dyns_offset_is_stable() {
3544+
use janetrs::lowlevel::{janet_equals, janet_setdyn, janet_table, janet_wrap_table};
3545+
3546+
let _client = janetrs::client::JanetClient::init().expect("bare VM on a fresh test thread");
3547+
3548+
assert!(
3549+
worker::top_dyns_ptr().is_null(),
3550+
"top_dyns must start null (janet_init sets it NULL, janet.c:35592); \
3551+
a non-null read here means offset 8 is no longer top_dyns"
3552+
);
3553+
3554+
// A table, not a number: `janet_wrap_integer` is a macro under some
3555+
// nanbox configs and is not exported from libjanet, so it does not link.
3556+
// SAFETY: the VM is initialized on this thread, so `janet_table`
3557+
// allocates on its heap and `janet_wrap_table` only tags the pointer.
3558+
// Nothing collects during this test, so the value stays live unrooted.
3559+
let probe = unsafe { janet_wrap_table(janet_table(0)) };
3560+
// SAFETY: VM live on this thread and no fiber is running, so
3561+
// `janet_setdyn` lazily creates `top_dyns` and writes into it.
3562+
unsafe { janet_setdyn(c"dirge-eona-probe".as_ptr(), probe) };
3563+
3564+
let top = worker::top_dyns_ptr();
3565+
assert!(
3566+
!top.is_null(),
3567+
"the first janet_setdyn must create top_dyns"
3568+
);
3569+
// SAFETY: `top` is the table janet_setdyn just created; reading `count`
3570+
// is an in-bounds field read on a live JanetTable.
3571+
assert_eq!(
3572+
unsafe { (*top).count },
3573+
1,
3574+
"the table at offset 8 must be the one janet_setdyn wrote to"
3575+
);
3576+
3577+
// A second key lands in the SAME table, and the pointer does not move.
3578+
// SAFETY: as above — VM live on this thread, still no fiber.
3579+
unsafe { janet_setdyn(c"dirge-eona-probe-2".as_ptr(), probe) };
3580+
assert_eq!(
3581+
worker::top_dyns_ptr(),
3582+
top,
3583+
"top_dyns is created once, then reused (janet.c:4657)"
3584+
);
3585+
// SAFETY: as above.
3586+
assert_eq!(
3587+
unsafe { (*top).count },
3588+
2,
3589+
"both dyns must be in this table"
3590+
);
3591+
3592+
// And it is the table the public reader consults: with no fiber live,
3593+
// `janet_dyn` reads `top_dyns` directly (janet.c:4645).
3594+
// SAFETY: VM live on this thread, and both operands are live Janet
3595+
// values — `probe` and whatever the dyn lookup returns for its key.
3596+
assert_eq!(
3597+
unsafe {
3598+
janet_equals(
3599+
janetrs::lowlevel::janet_dyn(c"dirge-eona-probe".as_ptr()),
3600+
probe,
3601+
)
3602+
},
3603+
1,
3604+
"janet_dyn must read back what janet_setdyn wrote"
3605+
);
3606+
}

src/plugin/worker.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,42 @@ fn run_command_loop(
21902190
}
21912191
}
21922192

2193+
/// dirge-eona: `janet_vm` is `__thread` in evil-janet's janet.c (that is
2194+
/// how the plugin and notebook VMs coexist on separate threads), and its
2195+
/// layout is file-local to janet.c — `janet.h` only forward-declares
2196+
/// `struct JanetVM`, so bindgen renders it opaque (`_unused: [u8; 0]`) and
2197+
/// the bindings cannot reach a field. Only the first two are needed;
2198+
/// `top_dyns` sits at offset 8, right after the `void *user` slot
2199+
/// (janet.c:172).
2200+
///
2201+
/// That offset is an assumption about a private C layout, and it has to hold
2202+
/// across any `evil-janet` 1.x (`janetrs` depends on `"1"`, so a `cargo
2203+
/// update` can move it). `top_dyns_offset_is_stable` in the plugin test
2204+
/// module pins it, so an upstream field insertion fails a test rather than
2205+
/// silently rooting whatever now sits at offset 8.
2206+
#[cfg(feature = "plugin")]
2207+
#[repr(C)]
2208+
struct JanetVMPrefix {
2209+
#[allow(dead_code)]
2210+
user: *mut core::ffi::c_void,
2211+
top_dyns: *mut janetrs::lowlevel::JanetTable,
2212+
}
2213+
2214+
/// The calling thread's `janet_vm.top_dyns` — null until the first
2215+
/// `janet_setdyn` on this thread creates it (janet.c:4657).
2216+
///
2217+
/// Reads through [`JanetVMPrefix`]; see there for the layout assumption.
2218+
#[cfg(feature = "plugin")]
2219+
pub(super) fn top_dyns_ptr() -> *mut janetrs::lowlevel::JanetTable {
2220+
// SAFETY: `janet_local_vm` returns `&janet_vm`, this thread's own
2221+
// thread-local VM, so it is never null once Janet is initialized on the
2222+
// thread — true for every caller, each of which holds a live
2223+
// `JanetClient`. The cast reinterprets the bindings' opaque `JanetVM` as
2224+
// its own leading fields, so reading `top_dyns` touches only bytes
2225+
// inside the real struct.
2226+
unsafe { (*(janetrs::lowlevel::janet_local_vm() as *mut JanetVMPrefix)).top_dyns }
2227+
}
2228+
21932229
/// Point Janet's TOP-LEVEL dyn table at the same `:out`/`:err` buffers the
21942230
/// harness prelude installed in the root env (dirge-c8lh).
21952231
///
@@ -2223,6 +2259,26 @@ fn mirror_capture_buffers_into_top_dyns(client: &JanetClient) {
22232259
janetrs::lowlevel::janet_setdyn(c_name.as_ptr(), raw);
22242260
}
22252261
}
2262+
// dirge-eona: `janet_setdyn` above created `janet_vm.top_dyns` (no
2263+
// fiber is live here), and Janet's collector never marks that table —
2264+
// it appears nowhere in the mark phase of janet.c. The first full
2265+
// collection frees it, and the next no-fiber `janet_dyn` (the stack
2266+
// trace printer's `:err-color` lookup) reads the freed table and
2267+
// segfaults the VM. Root the table itself, not just the buffers in it.
2268+
// Janet roots `abstract_registry` the same way, for the same reason
2269+
// (janet.c:35577) — a VM-struct field is not a GC root on its own.
2270+
let top = top_dyns_ptr();
2271+
if !top.is_null() {
2272+
// SAFETY: `top` is the table `janet_setdyn` just created on this
2273+
// thread, so it is a live Janet object; `janet_wrap_table` only
2274+
// tags the pointer. Rooting is permanent by design and cannot leak
2275+
// past one entry per VM: both callers run once at thread init, and
2276+
// `top_dyns` is assigned nowhere but `janet_init`/`janet_deinit`
2277+
// (NULL) and this lazy create, so the root can never go stale.
2278+
unsafe {
2279+
janetrs::lowlevel::janet_gcroot(janetrs::lowlevel::janet_wrap_table(top));
2280+
}
2281+
}
22262282
}
22272283

22282284
/// The notebook VM's thread (dirge-9xjg.2).

0 commit comments

Comments
 (0)