Skip to content

Commit efc2edd

Browse files
committed
fix(dirge-eona): gcroot janet_vm.top_dyns to stop plugin-worker SIGSEGV
`mirror_capture_buffers_into_top_dyns` created the top-level dyn table via `janet_setdyn` but only rooted the buffers inside it. Janet's collector never marks `top_dyns` (absent from the mark phase in 1.37.2 and 1.41.3 alike), so the first full GC freed the table and the next no-fiber `janet_dyn` (the stack-trace printer's `:err-color` lookup) read freed memory, faulting in `janet_dict_find`. - Root the table itself after the mirror; access the TLS `janet_vm` through `janet_local_vm()`. - Add `plugin_worker_uaf_stress` regression test (env-driven, `#[ignore]`-gated) replaying the hook sequence that preceded the crash; previously SIGSEGV'd in 3 turns, now passes 3/30. Refs: dirge-eona
1 parent fd097e9 commit efc2edd

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

src/plugin/mod_tests.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3420,3 +3420,97 @@ 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).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
3447+
}
3448+
let turns = env_usize("DIRGE_STRESS_TURNS", 200);
3449+
let updates = env_usize("DIRGE_STRESS_UPDATES", 25);
3450+
let response_bytes = env_usize("DIRGE_STRESS_RESPONSE_BYTES", 20_000);
3451+
let load_user_plugins = std::env::var("DIRGE_STRESS_USER_PLUGINS")
3452+
.map(|v| v != "0")
3453+
.unwrap_or(true);
3454+
3455+
let mut mgr = PluginManager::try_new().unwrap();
3456+
3457+
// The crashing sessions had the user's whole plugin dir loaded
3458+
// (~38 entries, several of them response-capturing). Reproduce that
3459+
// env; per-plugin failures are not the point of the harness.
3460+
if load_user_plugins {
3461+
if let Ok(home) = std::env::var("HOME") {
3462+
let dir = std::path::PathBuf::from(home).join(".config/dirge/plugins");
3463+
if dir.is_dir() {
3464+
let mut entries: Vec<_> = std::fs::read_dir(&dir)
3465+
.unwrap()
3466+
.filter_map(|e| e.ok().map(|e| e.path()))
3467+
.filter(|p| p.extension().is_some_and(|e| e == "janet") || p.is_dir())
3468+
.collect();
3469+
entries.sort();
3470+
for path in entries {
3471+
let _ = load_plugin(&mut mgr, &path);
3472+
}
3473+
}
3474+
}
3475+
}
3476+
3477+
// Realistic response material: markdown + code fences + quotes +
3478+
// backslashes + unicode, so the ctx escaper sees the same shape of
3479+
// text the crashing sessions carried.
3480+
let unit = "Some **markdown** with `code` and \"quotes\" and \\\\ paths — ünïcødé ✓\n```rust\nfn main() { println!(\"line\"); }\n```\n";
3481+
let big_response = unit.repeat(response_bytes / unit.len() + 1);
3482+
3483+
for turn in 0..turns {
3484+
mgr.dispatch("on-prompt", "@{:prompt \"stress turn\"}").unwrap();
3485+
mgr.dispatch("on-turn-start", "@{}").unwrap();
3486+
for u in 0..updates {
3487+
let partial_len = turn * 100 + u * 50;
3488+
let mut pend = partial_len.min(big_response.len());
3489+
while pend > 0 && !big_response.is_char_boundary(pend) {
3490+
pend -= 1;
3491+
}
3492+
let partial = &big_response[..pend];
3493+
let ctx = format!(
3494+
"@{{:index {} :partial \"{}\"}}",
3495+
u,
3496+
escape_janet_string(partial)
3497+
);
3498+
mgr.dispatch("on-message-update", &ctx).unwrap();
3499+
}
3500+
let mut rend = (response_bytes + turn * 10).min(big_response.len());
3501+
while rend > 0 && !big_response.is_char_boundary(rend) {
3502+
rend -= 1;
3503+
}
3504+
let response = &big_response[..rend];
3505+
let ctx = format!("@{{:response \"{}\"}}", escape_janet_string(response));
3506+
mgr.dispatch("on-response", &ctx).unwrap();
3507+
// Force collection every turn so freed slots get reused — the
3508+
// dirge-eona window. No-op turns: a normal, collected GC cycle.
3509+
mgr.eval("(gccollect)").unwrap();
3510+
let r = mgr.invoke_command("resp-clipboard-handler", "").unwrap();
3511+
if turn % 50 == 0 {
3512+
println!("turn {turn}: {:?}", r.map(|s| s.chars().take(60).collect::<String>()));
3513+
}
3514+
}
3515+
println!("stress completed without crashing: {turns} turns");
3516+
}

src/plugin/worker.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,24 @@ 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 so the bindings cannot expose it. Only
2196+
/// the first two fields are needed; `top_dyns` sits at offset 8 in
2197+
/// `struct JanetVM`. Access goes through `janet_local_vm`, which returns
2198+
/// the calling thread's copy of the VM.
2199+
#[cfg(feature = "plugin")]
2200+
#[repr(C)]
2201+
struct JanetVMPrefix {
2202+
user: *mut core::ffi::c_void,
2203+
top_dyns: *mut janetrs::lowlevel::JanetTable,
2204+
}
2205+
2206+
#[cfg(feature = "plugin")]
2207+
unsafe extern "C" {
2208+
fn janet_local_vm() -> *mut JanetVMPrefix;
2209+
}
2210+
21932211
/// Point Janet's TOP-LEVEL dyn table at the same `:out`/`:err` buffers the
21942212
/// harness prelude installed in the root env (dirge-c8lh).
21952213
///
@@ -2223,6 +2241,18 @@ fn mirror_capture_buffers_into_top_dyns(client: &JanetClient) {
22232241
janetrs::lowlevel::janet_setdyn(c_name.as_ptr(), raw);
22242242
}
22252243
}
2244+
// dirge-eona: `janet_setdyn` above created `janet_vm.top_dyns` (no
2245+
// fiber is live here), and Janet's collector never marks that table —
2246+
// it appears nowhere in the mark phase of janet.c. The first full
2247+
// collection frees it, and the next no-fiber `janet_dyn` (the stack
2248+
// trace printer's `:err-color` lookup) reads the freed table and
2249+
// segfaults the VM. Root the table itself, not just the buffers in it.
2250+
unsafe {
2251+
let top = (*janet_local_vm()).top_dyns;
2252+
if !top.is_null() {
2253+
janetrs::lowlevel::janet_gcroot(janetrs::lowlevel::janet_wrap_table(top));
2254+
}
2255+
}
22262256
}
22272257

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

0 commit comments

Comments
 (0)