@@ -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\n fn 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+ }
0 commit comments