char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
4d5ee6a
main
1#! [ cfg ( any ( target_os = "linux" , target_os = "macos" ))] 2mod common; 3 4use std:: fs; 5use std:: os:: fd:: AsRawFd ; 6use std:: os:: unix:: fs::{ PermissionsExt , symlink}; 7use std:: os:: unix:: process:: CommandExt ; 8use std:: path:: PathBuf ; 9use std:: process::{ Command , Output , Stdio }; 10use std:: time::{ Duration , Instant }; 11 12fn shell () ->PathBuf { 13 std:: env:: split_paths ( & std:: env:: var_os ( "PATH" ). unwrap ()) 14. map ( |directory| directory. join ( "sh" )) 15. find ( |path| path. is_file ()) 16. unwrap () 17. canonicalize () 18. unwrap () 19} 20fn native_command ( 21scope : & std:: sync:: Arc < avril_subprocess:: Scope >, 22command : & str , 23) -> std:: io:: Result < std:: sync:: Arc < avril_subprocess:: Process >> { 24 avril_subprocess:: Launcher { 25runtime : tokio:: runtime:: Handle :: current (), 26caps : avril_caps:: Capabilities :: new ( 27 avril_caps:: Initial :: new ( false ), 28 std:: env:: current_dir (). unwrap (), 29), 30owners : Vec :: new (), 31} 32. spawn ( 33 avril_subprocess:: Options :: new ( 34shell (). to_str (). unwrap (), 35vec! [ "-c" . into (), command. into ()], 36), 37 std:: env:: vars_os (), 38Some ( scope), 39 |_, _, _|unreachable! (), 40) 41} 42 43# [ tokio :: test ( flavor = "multi_thread" , worker_threads = 2 )] 44async fn native_group_outlives_a_command_and_cleans_up_on_drop () { 45let scope = std:: sync:: Arc :: new ( 46 avril_subprocess:: Scope :: new ( 47 std:: path:: Path :: new ( env! ( "CARGO_BIN_EXE_avril" )), 48Vec :: new (), 49) 50. unwrap (), 51); 52let first =native_command ( & scope, "exit 7" ). unwrap (); 53assert_eq! ( first. wait ( true , None ). await . unwrap (). unwrap (). code , Some ( 7 )); 54let second =native_command ( & scope, "while :; do :; done" ). unwrap (); 55drop ( scope); 56let status = tokio:: time:: timeout ( Duration :: from_secs ( 5 ), second. wait ( true , None )) 57. await 58. unwrap () 59. unwrap () 60. unwrap (); 61assert! ( !status. success ); 62} 63 64# [ tokio :: test ( flavor = "multi_thread" , worker_threads = 2 )] 65async fn native_group_cannot_spawn_after_close () { 66let scope = std:: sync:: Arc :: new ( 67 avril_subprocess:: Scope :: new ( 68 std:: path:: Path :: new ( env! ( "CARGO_BIN_EXE_avril" )), 69Vec :: new (), 70) 71. unwrap (), 72); 73 scope. force_close (); 74 scope. force_close (); 75assert! ( 76 scope 77. if_running ( ||panic! ( "published after close" )) 78. is_none () 79); 80assert_eq! ( 81native_command ( & scope, "exit 0" ). err (). unwrap (). kind (), 82 std:: io:: ErrorKind :: Interrupted , 83); 84} 85 86struct Fixture { 87dir : tempfile:: TempDir , 88} 89/// Resets SIGINT/SIGTERM to their defaults in the child, so results never 90/// depend on dispositions the test runner itself ignores: a shell running 91/// `cargo test` as a background job ignores SIGINT, which children would 92/// otherwise inherit through the snapshot. 93fn inherit_defaults ( mut command : Command ) ->Command { 94unsafe { 95 command. pre_exec ( ||{ 96 libc:: signal ( libc:: SIGINT , libc:: SIG_DFL ); 97 libc:: signal ( libc:: SIGTERM , libc:: SIG_DFL ); 98Ok (()) 99}); 100} 101 command 102} 103impl Fixture { 104fn new () ->Self { 105Self { 106dir : tempfile:: tempdir (). unwrap (), 107} 108} 109fn script ( & self , name : & str , body : & str ) { 110let path =self . dir . path (). join ( name); 111 fs:: write ( & path, format! ( "#!{}\n{body}\n" , shell (). display ())). unwrap (); 112 fs:: set_permissions ( path, fs:: Permissions :: from_mode ( 0o755 )). unwrap (); 113} 114fn run ( & self , source : & str , flags : & [ String ]) ->Output { 115let prefix =format! ( 116r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}}; 117async function rejects(action,name){{try{{await action()}}catch(error){{assert(error.name===name,'Expected '+name+', got '+error);return error}}throw Error('Expected '+name)}} 118"# , 119 serde_json:: to_string ( & shell ()). unwrap () 120); 121 fs:: write ( 122self . dir . path (). join ( "main.js" ), 123format! ( "{prefix}\n{source}" ), 124) 125. unwrap (); 126 common:: wait ( 127inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 128. arg ( "run" ) 129. args ( flags) 130. arg ( "main.js" ) 131. current_dir ( self . dir . path ()) 132. env ( "AVRIL_PROCESS_SECRET" , "host-secret" ) 133. stdin ( Stdio :: null ()) 134. stdout ( Stdio :: piped ()) 135. stderr ( Stdio :: piped ()) 136. spawn () 137. unwrap (), 138) 139} 140fn pass ( & self , source : & str , flags : & [ String ]) ->Output { 141let output =self . run ( source, flags); 142assert! ( 143 output. status . success (), 144"{}" , 145String :: from_utf8_lossy ( & output. stderr ) 146); 147 output 148} 149/// Writes main.js with the shared prelude and spawns it for interaction: 150/// the caller reads lines, sends signals, then finishes with common::wait. 151fn interact ( & self , source : & str ) -> std:: process:: Child { 152let prefix =format! ( 153r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}}; 154async function rejects(action,name){{try{{await action()}}catch(error){{assert(error.name===name,'Expected '+name+', got '+error);return error}}throw Error('Expected '+name)}} 155"# , 156 serde_json:: to_string ( & shell ()). unwrap () 157); 158 fs:: write ( 159self . dir . path (). join ( "main.js" ), 160format! ( "{prefix}\n{source}" ), 161) 162. unwrap (); 163inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 164. arg ( "run" ) 165. arg ( "main.js" ) 166. current_dir ( self . dir . path ()) 167. env ( "AVRIL_PROCESS_SECRET" , "host-secret" ) 168. stdin ( Stdio :: null ()) 169. stdout ( Stdio :: piped ()) 170. stderr ( Stdio :: piped ()) 171. spawn () 172. unwrap () 173} 174} 175/// Whether a process is running, as opposed to gone or a zombie. 176fn alive ( pid : i32 ) ->bool { 177# [ cfg ( target_os = "linux" )] 178return fs:: read_to_string ( format! ( "/proc/{pid}/stat" )) 179. is_ok_and ( |stat| !stat. split ( ") " ). nth ( 1 ). unwrap (). starts_with ( 'Z' )); 180// Without procfs, give launchd a moment to reap orphaned zombies. 181# [ cfg ( not ( target_os = "linux" ))] 182{ 183let deadline =Instant :: now () +Duration :: from_secs ( 1 ); 184while unsafe { libc:: kill ( pid, 0 ) } ==0 { 185if Instant :: now () >= deadline{ 186return true ; 187} 188 std:: thread:: sleep ( Duration :: from_millis ( 10 )); 189} 190false 191} 192} 193fn pass ( source : & str ) { 194Fixture :: new (). pass ( source, & []); 195} 196 197# [ test ] 198fn invocation_results_bytes_encoding_nonzero_and_no_implicit_shell () { 199pass ( 200r#" 201import {run,runSync,spawn,ProcessError} from 'avril:std'; 202const first=await run(SH,{args:['-c','printf hello; printf problem >&2']}); 203assert(first.code===0 && first.success && first.reason===null,'status'); 204assert(new TextDecoder().decode(first.stdout)==='hello' && new TextDecoder().decode(first.stderr)==='problem','bytes'); 205const decoded=runSync(SH,{args:['-c','printf "%s" "$1"','sh','a; echo injected'],encoding:'utf-8'}); 206assert(decoded.stdout==='a; echo injected','literal argv'); 207const failed=await rejects(()=>run(SH,{args:['-c','printf partial; exit 23'],encoding:'utf-8'}),'ProcessError'); 208assert(failed instanceof ProcessError && failed.result.code===23 && failed.result.stdout==='partial','partial exit error'); 209const allowed=await run(SH,{args:['-c','exit 7'],check:false}); assert(allowed.code===7 && !allowed.success,'unchecked'); 210const child=spawn(SH,{args:['-c','printf x'],encoding:'utf-8'}); 211assert((await child.output()).stdout==='x','one invocation'); 212await rejects(()=>child.output(),'TypeError'); 213assert(!child.kill(),'kill after exit'); 214"# , 215); 216} 217 218# [ test ] 219fn positional_arguments_and_stdout_shortcuts () { 220pass ( 221r#" 222import {run,runSync,spawn} from 'avril:std'; 223assert(await run(SH,['-c','printf "a\\nb"']).text()==='a\nb','text'); 224assert((await run(SH,['-c','printf \'{"n":1}\'']).json()).n===1,'json'); 225assert((await run(SH,['-c','printf hi']).bytes()).length===2,'bytes'); 226assert(await run(SH,['-c','printf é'],{encoding:'utf-8'}).text()==='é','encoded text'); 227assert(runSync(SH,['-c','printf sync'],{encoding:'utf-8'}).stdout==='sync','runSync'); 228assert((await spawn(SH,['-c','printf spawn'],{encoding:'utf-8'}).output()).stdout==='spawn','spawn'); 229await rejects(()=>run(SH,['-c','true'],{args:[]}),'TypeError'); 230await rejects(()=>run(SH,'-c'),'TypeError'); 231await rejects(()=>run(SH,['-c','exit 3']).text(),'ProcessError'); 232await rejects(()=>run(SH,['-c','true'],{stdout:'null'}).text(),'TypeError'); 233"# , 234); 235} 236 237# [ test ] 238fn lines_stream_while_the_child_runs () { 239pass ( 240r#" 241import {run} from 'avril:std'; 242const seen=[]; 243for await (const line of run(SH,['-c','echo one; read _; printf "two\\r\\nthree"'],{input:new ReadableStream({ 244async pull(controller){ while(!seen.length) await new Promise(r=>setTimeout(r,5)); controller.enqueue(new TextEncoder().encode('\n')); controller.close(); } 245})}).lines()) seen.push(line); 246assert(JSON.stringify(seen)==='["one","two","three"]','streamed lines: '+JSON.stringify(seen)); 247const failing=run(SH,['-c','echo kept; echo why >&2; exit 4']); 248const lines=[]; 249const error=await rejects(async()=>{ for await (const line of failing.lines()) lines.push(line); },'ProcessError'); 250assert(lines[0]==='kept'&&error.result.code===4&&new TextDecoder().decode(error.result.stderr)==='why\n','failure after lines'); 251await rejects(()=>failing,'ProcessError'); 252const endless=run(SH,['-c','echo first; exec sleep 30'],{termination:{graceMs:100}}); 253for await (const line of endless.lines()) break; 254assert((await rejects(()=>endless,'ProcessError')).result.reason==='terminated','early exit terminates'); 255const late=run(SH,['-c','echo a; echo b']); 256await late; 257const buffered=[]; for await (const line of late.lines()) buffered.push(line); 258assert(JSON.stringify(buffered)==='["a","b"]','collected lines'); 259"# , 260); 261} 262 263# [ test ] 264fn streamed_lines_apply_backpressure_instead_of_a_total_cap () { 265let fixture =Fixture :: new (); 266 fixture. pass ( 267r#" 268import {run,stat} from 'avril:std'; 269const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); 270const exists=async(name)=>{try{await stat(name);return true}catch{return false}}; 271// A slow reader blocks the child instead of letting it race ahead. 272let count=0; 273for await (const line of run(SH,['-c','yes 0123456789 | head -n 400000; : > written']).lines()) { 274if (count++===0) { await sleep(300); assert(!await exists('written'),'child ran ahead of the reader'); } 275} 276assert(count===400000&&await exists('written'),'every line'); 277// Draining is not capped by the run's default 16 MiB. 278let bytes=0; 279for await (const line of run(SH,['-c','yes $(printf %0100d 0) | head -n 200000']).lines()) bytes+=line.length+1; 280assert(bytes===200000*101,'uncapped stream'); 281// Only what is kept counts toward maxCollectedBytes: stderr, not streamed stdout. 282let streamed=0; 283for await (const line of run(SH,['-c','yes x | head -n 100000'],{maxCollectedBytes:1000}).lines()) streamed++; 284assert(streamed===100000,'streamed stdout is not collected'); 285const kept=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; printf "%05000d" 0 >&2'],{maxCollectedBytes:1000}).lines()); },'OutputLimitError'); 286assert(kept.result.stderr.length===1000,'collected stderr is bounded'); 287"# , 288& [], 289); 290} 291 292# [ test ] 293fn backpressured_lines_still_end_promptly () { 294pass ( 295r#" 296import {run} from 'avril:std'; 297const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); 298// Leaving while the pump waits on the reader terminates the child. 299let started=Date.now(); 300const left=run(SH,['-c','exec yes'],{termination:{graceMs:100}}); 301for await (const line of left.lines()) { await sleep(100); break; } 302assert((await rejects(()=>left,'ProcessError')).result.reason==='terminated','left'); 303assert(Date.now()-started<2000,'leaving waited on the reader'); 304// A timeout still stops a child whose reader fell behind. 305started=Date.now(); 306await rejects(async()=>{ for await (const line of run(SH,['-c','exec yes'],{timeoutMs:200,drainTimeoutMs:200}).lines()) await sleep(1); },'ProcessTimeoutError'); 307assert(Date.now()-started<3000,'timeout waited on the reader'); 308// A reader catching up after exit is not a straggler holding the 309// pipe: this output outlasts the drain timeout in the queue, after 310// the child has written its last line into the pipe and exited. 311let count=0; 312for await (const line of run(SH,['-c','yes x | head -n 580000'],{drainTimeoutMs:300}).lines()) { 313if (count++===0) await sleep(600); 314} 315assert(count===580000,'reader kept its lines: '+count); 316// A real straggler still ends the run. 317started=Date.now(); 318const error=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; exec sleep 30 &'],{drainTimeoutMs:300}).lines()); },'ProcessError'); 319assert(error.result.reason==='drain-timeout'&&Date.now()-started<2000,'straggler'); 320"# , 321); 322} 323 324# [ test ] 325fn one_shot_bytes_streams_and_sync_close_stdin () { 326pass ( 327r#" 328import {run,runSync} from 'avril:std'; 329const script='while IFS= read -r line; do printf "%s\\n" "$line"; done'; 330for(const method of [run,runSync]) { 331const out=await method(SH,{args:['-c',script],input:'alpha\nbeta\n',encoding:'utf-8'}); 332assert(out.stdout==='alpha\nbeta\n','string input and EOF'); 333const empty=await method(SH,{args:['-c',script],stdin:'piped'}); assert(empty.stdout.length===0,'empty input EOF'); 334} 335const stream=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream\n'));c.close()}}); 336assert((await run(SH,{args:['-c',script],input:stream,encoding:'utf-8'})).stdout==='stream\n','stream input'); 337await rejects(()=>runSync(SH,{input:new ReadableStream()}),'TypeError'); 338const never=new ReadableStream({pull(){return new Promise(()=>{})},cancel(){return new Promise(()=>{})}}); 339const done=await run(SH,{args:['-c','exit'],input:never}); assert(done.success,'input cancellation does not await source cancel'); 340"# , 341); 342} 343 344# [ test ] 345fn synchronous_and_asynchronous_pumps_do_not_deadlock_on_full_pipes () { 346pass ( 347r#" 348import {run,runSync} from 'avril:std'; 349const input=new Uint8Array(300000).fill(97); input[input.length-1]=10; 350const script='printf "%0300000d" 0; printf "%0300000d" 0 >&2; IFS= read -r value; printf "%s" "$value"'; 351for(const method of [runSync,run]) { 352const output=await method(SH,{args:['-c',script],input,maxCollectedBytes:1000000}); 353assert(output.stdout.length===599999 && output.stderr.length===300000,'concurrent independent pumps'); 354} 355"# , 356); 357} 358 359# [ test ] 360fn capture_and_unread_queue_limits_preserve_bounded_partial_output () { 361pass ( 362r#" 363import {run,runSync,spawn,OutputLimitError} from 'avril:std'; 364for(const method of [run,runSync]) { 365const error=await rejects(()=>method(SH,{args:['-c','printf abcdefghij; printf klmnopqrst >&2'],maxCollectedBytes:13,termination:{graceMs:10}}),'OutputLimitError'); 366assert(error instanceof OutputLimitError && error.result.reason==='output-limit','limit reason'); 367assert(error.result.stdout.length+error.result.stderr.length===13,'combined capture bound'); 368} 369const child=spawn(SH,{args:['-c','printf "%080000d" 0'],maxBufferedBytes:128,termination:{graceMs:10}}); 370const status=await child.completed; assert(status.reason==='output-limit','native unread overflow without a JS consumer'); 371const error=await rejects(()=>child.output(),'OutputLimitError'); assert(error.result.stdout.length<=128,'unread prefix bound'); 372"# , 373); 374} 375 376# [ test ] 377fn native_timeouts_abort_and_forced_termination_share_cleanup () { 378pass ( 379r#" 380import {run,runSync,spawn} from 'avril:std'; 381const stubborn=['-c','trap "" TERM; printf started; while :; do :; done']; 382for(const method of [run,runSync]) { 383const error=await rejects(()=>method(SH,{args:stubborn,timeoutMs:50,termination:{graceMs:20},encoding:'utf-8'}),'ProcessTimeoutError'); 384assert(error.result.signal==='SIGKILL' && error.result.stdout==='started','native escalation and partial output'); 385} 386const control=new AbortController(), marker={marker:1}; 387const task=run(SH,{args:stubborn,signal:control.signal,termination:{graceMs:20}}); control.abort(marker); 388const error=await rejects(()=>task,'ProcessAbortError'); assert(error.cause===marker && error.result.reason==='abort','abort cause'); 389const already=AbortSignal.abort(marker); await rejects(()=>run(SH,{signal:already}),'ProcessAbortError'); 390const child=spawn(SH,{args:stubborn,termination:{graceMs:20}}); const one=child.terminate(),two=child.terminate(); 391assert(one===two && (await one).reason==='terminated','idempotent terminate'); 392await child[Symbol.asyncDispose](); await child[Symbol.asyncDispose](); assert(!child.kill(),'disposed'); 393"# , 394); 395} 396 397# [ test ] 398fn status_is_distinct_from_bounded_descendant_pipe_completion () { 399pass ( 400r#" 401import {spawn} from 'avril:std'; 402const child=spawn(SH,{args:['-c','"$1" -c "while :; do :; done" & printf prefix; exit','sh',SH],drainTimeoutMs:40,encoding:'utf-8'}); 403const status=await child.status; assert(status.code===0 && status.reason===null,'leader exit'); 404const completed=await child.completed; assert(completed.reason==='drain-timeout','bounded inherited pipes'); 405const error=await rejects(()=>child.output(),'ProcessError'); assert(error.result.stdout==='prefix','drain preserves output'); 406assert(!child.kill(),'no signaling exited leader'); 407"# , 408); 409} 410 411# [ test ] 412fn streaming_stdio_byob_merge_and_consumption_ownership () { 413pass ( 414r#" 415import {spawn} from 'avril:std'; 416const child=spawn(SH,{args:['-c','IFS= read -r line; printf "%s" "$line"; printf err >&2'],stdin:'piped',stderr:'stdout'}); 417assert(child.stderr===null,'merged stderr'); 418const writer=child.stdin.getWriter(); await writer.write(new TextEncoder().encode('byob\n')); await writer.close(); 419const reader=child.stdout.getReader({mode:'byob'}); let text=''; 420while(true){const next=await reader.read(new Uint8Array(3)); if(next.done)break;text+=new TextDecoder().decode(next.value)} 421assert(text==='byoberr' && (await child.completed).success,'BYOB and merge'); reader.releaseLock(); 422await rejects(()=>child.output(),'TypeError'); 423const canceled=spawn(SH,{args:['-c','printf "%0200000d" 0'],maxBufferedBytes:32}); 424await canceled.stdout.cancel(); await canceled.stderr.cancel(); assert((await canceled.completed).success,'canceled streams keep native pipes draining'); 425"# , 426); 427} 428 429# [ test ] 430fn path_search_uses_effective_environment_and_can_be_disabled () { 431let fixture =Fixture :: new (); 432 fixture. script ( "local-tool" , "printf local" ); 433let path = serde_json:: to_string ( fixture. dir . path ()). unwrap (); 434 fixture. pass ( & format! ( r#" 435import {{run}} from 'avril:std'; 436const env={{PATH:{path}}}; 437assert((await run('local-tool',{{env,clearEnv:true,encoding:'utf-8'}})).stdout==='local','child PATH'); 438assert((await run('local-tool',{{searchPath:false,env:{{PATH:'/missing'}},encoding:'utf-8'}})).stdout==='local','bare pathname'); 439assert((await run('./local-tool',{{clearEnv:true,encoding:'utf-8'}})).stdout==='local','explicit bypasses PATH'); 440await rejects(()=>run('sh',{{clearEnv:true}}),'NotFound'); 441await rejects(()=>run('sh',{{searchPath:false,env:{{PATH:{path}}}}}),'NotFound'); 442assert((await run('local-tool',{{cwd:{path},env:{{PATH:'.'}},clearEnv:true,encoding:'utf-8'}})).stdout==='local','relative child PATH'); 443"# ), & []); 444} 445 446# [ test ] 447fn a_denied_first_path_match_does_not_search_for_an_allowed_replacement () { 448let fixture =Fixture :: new (); 449 fs:: create_dir ( fixture. dir . path (). join ( "first" )). unwrap (); 450 fs:: create_dir ( fixture. dir . path (). join ( "second" )). unwrap (); 451 fixture. script ( "first/tool" , "printf forbidden" ); 452 fixture. script ( "second/tool" , "printf allowed" ); 453 fixture. pass ( r#" 454import {run} from 'avril:std'; 455await rejects(()=>run('tool',{clearEnv:true,env:{PATH:'first:second'}}),'PermissionDenied'); 456assert((await run('./second/tool',{encoding:'utf-8'})).stdout==='allowed','allowed replacement only explicitly'); 457"# , & [ "--deny-run=first/tool" . into ()]); 458} 459 460# [ test ] 461fn run_capabilities_are_independent_exact_and_cleanup_survives_revocation () { 462let fixture =Fixture :: new (); 463let grant =format! ( "--allow-run={}" , shell (). display ()); 464 fixture. pass ( r#" 465import {run,spawn,pledge} from 'avril:std'; 466assert((await run(SH,{args:['-c','printf native'],encoding:'utf-8'})).stdout==='native','native run does not need read'); 467const child=spawn(SH,{args:['-c','while :; do :; done'],termination:{graceMs:10}}); 468pledge({run:false}); await rejects(()=>run(SH),'PermissionDenied'); 469assert((await child.terminate()).reason==='terminated','retained cleanup authority'); 470"# , & [ "-0" . into (), grant]); 471 fixture. pass ( 472r#"import {run} from 'avril:std'; await rejects(()=>run(SH),'PermissionDenied');"# , 473& [ 474"-0" . into (), 475format! ( "--allow-read={}" , shell (). display ()), 476format! ( "--allow-js-exec={}" , shell (). display ()), 477], 478); 479} 480 481# [ test ] 482fn inherited_environment_is_filtered_runtime_local_and_explicit_values_need_no_read_grant () { 483let fixture =Fixture :: new (); 484 fixture. pass ( r#" 485import {env,run,pledge} from 'avril:std'; 486env.set('AVRIL_PROCESS_LOCAL','local'); 487const out=await run(SH,{args:['-c','printf "%s:%s" "${AVRIL_PROCESS_SECRET-unset}" "$AVRIL_PROCESS_LOCAL"'],encoding:'utf-8'}); 488assert(out.stdout==='unset:local','filtered runtime-local inherited environment'); 489pledge({env:false}); 490const explicit=await run(SH,{args:['-c','printf "%s" "$AVRIL_PROCESS_SECRET"'],env:{AVRIL_PROCESS_SECRET:'explicit'},encoding:'utf-8'}); 491assert(explicit.stdout==='explicit','explicit values bypass parent read permissions'); 492await rejects(()=>run('sh'),'NotFound'); 493"# , & [ "--deny-env=AVRIL_PROCESS_SECRET" . into ()]); 494} 495 496# [ test ] 497fn file_redirection_modes_borrowed_handles_and_current_permissions () { 498let fixture =Fixture :: new (); 499 fs:: write ( fixture. dir . path (). join ( "input" ), "data\n" ). unwrap (); 500 fixture. pass ( 501r#" 502import {run,runSync,open,pledge} from 'avril:std'; import {readTextFile} from 'avril:fs'; 503const script='IFS= read -r line; printf "%s" "$line"'; 504const first=await run(SH,{args:['-c',script],stdin:{file:'input'},stdout:{file:'output'}}); 505assert(first.stdout===null && await readTextFile('output')==='data','secure direct files'); 506runSync(SH,{args:['-c','printf plus'],stdout:{file:'output',mode:'append'}}); 507assert(await readTextFile('output')==='dataplus','append'); 508await rejects(()=>run(SH,{stdout:{file:'output',mode:'create-new'}}),'AlreadyExists'); 509const handle=await open('output',{read:true,write:true}); await handle.seek(0,0); 510await run(SH,{args:['-c','printf borrowed'],stdout:{handle}}); 511assert(await handle.seek(0,1)===8,'borrowed cursor'); 512pledge({write:false}); await rejects(()=>run(SH,{stdout:{handle}}),'PermissionDenied'); 513await handle.close(); 514"# , 515& [], 516); 517assert_eq! ( 518 fs:: read ( fixture. dir . path (). join ( "output" )). unwrap (), 519b"borrowed" 520); 521} 522 523# [ test ] 524fn executable_authorization_precedes_redirection_mutation () { 525let fixture =Fixture :: new (); 526 fs:: write ( fixture. dir . path (). join ( "kept" ), "keep" ). unwrap (); 527 fixture. pass ( r#"import {run} from 'avril:std';await rejects(()=>run(SH,{stdout:{file:'kept'}}),'PermissionDenied');"# , & [ format! ( "--deny-run={}" , shell (). display ())]); 528assert_eq! ( fs:: read ( fixture. dir . path (). join ( "kept" )). unwrap (), b"keep" ); 529} 530 531# [ test ] 532fn pinned_shebang_scripts_and_plain_text_have_no_shell_fallback () { 533let fixture =Fixture :: new (); 534 fixture. script ( "script" , "printf pinned" ); 535 fs:: write ( fixture. dir . path (). join ( "plain" ), "printf implicit" ). unwrap (); 536 fs:: set_permissions ( 537 fixture. dir . path (). join ( "plain" ), 538 fs:: Permissions :: from_mode ( 0o755 ), 539) 540. unwrap (); 541 fixture. pass ( r#" 542import {run} from 'avril:std'; 543assert((await run(new URL('./script',import.meta.url),{encoding:'utf-8'})).stdout==='pinned','pinned script'); 544try{await run('./plain');throw Error('fallback')}catch(error){assert(error.name==='IoError','exec format error')} 545"# , & [ "-0" . into (), "--allow-run=script" . into (), "--allow-run=plain" . into ()]); 546} 547 548# [ test ] 549fn additional_descriptors_are_declared_pipes_not_arbitrary_runtime_fds () { 550pass ( 551r#" 552import {spawn} from 'avril:std'; 553const child=spawn(SH,{args:['-c','IFS= read -r line <&3; printf "%s" "$line" >&4; printf standard'],extraStdio:{3:{pipe:'parent-to-child'},4:{pipe:'child-to-parent'}},encoding:'utf-8'}); 554const writer=child.stdio[3].getWriter(); await writer.write(new TextEncoder().encode('extra\n')); await writer.close(); 555const extra=await new Response(child.stdio[4]).text(); assert(extra==='extra','extra pipe'); 556assert((await child.output()).stdout==='standard','standard pipe'); 557await rejects(()=>spawn(SH,{extraStdio:{3:'inherit'}}),'TypeError'); 558"# , 559); 560} 561 562# [ test ] 563fn terminal_is_real_combined_resizable_and_explicit () { 564pass ( 565r#" 566import {spawn,run} from 'avril:std'; 567const child=spawn(SH,{args:['-c','[ -t 0 ] && [ -t 1 ] && [ -t 2 ] || exit 31; IFS= read -r line; printf "tty:%s" "$line"'],terminal:{columns:90,rows:30},encoding:'utf-8'}); 568assert(child.terminal && child.stderr===null,'explicit terminal'); 569child.terminal.resize(100,40); 570const writer=child.terminal.writable.getWriter(); await writer.write(new TextEncoder().encode('terminal\n')); 571const out=await child.output(); assert(out.stdout.includes('tty:terminal') && out.success,'isatty and terminal IO'); 572await writer.close(); 573await rejects(()=>run(SH,{terminal:{}}),'TypeError'); 574"# , 575); 576} 577 578# [ test ] 579fn unref_does_not_keep_runtime_alive_and_owner_shutdown_kills_children () { 580let fixture =Fixture :: new (); 581let start =Instant :: now (); 582let output=fixture. pass ( r#" 583import {spawn} from 'avril:std'; 584const child=spawn(SH,{args:['-c','while :; do :; done'],stdout:'null',stderr:'null',termination:{graceMs:10}}); 585console.log(child.pid); child.unref(); 586"# , & []); 587assert! ( start. elapsed () <Duration :: from_secs ( 3 )); 588let pid =String :: from_utf8 ( output. stdout ) 589. unwrap () 590. trim () 591. parse ::< i32 >() 592. unwrap (); 593assert_eq! ( 594unsafe { libc:: kill ( pid, 0 ) }, 595 -1 , 596"owned child survived isolate teardown" 597); 598} 599 600# [ test ] 601fn child_handles_are_branded_uncloneable_and_validate_before_launch () { 602pass ( 603r#" 604import {spawn,ChildProcess,run,runSync} from 'avril:std'; 605await rejects(()=>new ChildProcess(),'TypeError'); 606const child=spawn(SH,{args:['-c','exit']}); 607await rejects(()=>structuredClone(child),'DataCloneError'); 608await rejects(()=>ChildProcess.prototype.kill.call({}),'TypeError'); 609await child.completed; 610for(const options of [{searchPath:0},{searchpath:false},{maxCollectedBytes:0},{timeoutMs:-1},{input:new Uint16Array(2)},{env:{'BAD=NAME':'x'}},{args:['\0']},{termination:{scope:'unsafe'}}]) { 611await rejects(()=>run(SH,options), options.maxCollectedBytes===0 || options.timeoutMs===-1 ? 'RangeError' : options.env || options.args ? 'InvalidInput' : 'TypeError'); 612} 613await rejects(()=>runSync(SH,{signal:new AbortController().signal}),'TypeError'); 614"# , 615); 616} 617 618# [ test ] 619fn worker_run_sync_teardown_is_prompt () { 620let fixture =Fixture :: new (); 621 fs:: write ( 622 fixture. dir . path (). join ( "worker.js" ), 623r#" 624import {runSync} from 'avril:std'; 625try { runSync('sleep', {args:['30']}); } catch {} 626"# , 627) 628. unwrap (); 629// The harness fails after five seconds, so a wedged teardown fails loudly. 630let output = fixture. pass ( 631r#" 632const worker = new Worker('./worker.js', {type:'module'}); 633await new Promise(resolve => setTimeout(resolve, 400)); 634worker.terminate(); 635console.log('terminated'); 636"# , 637& [], 638); 639assert_eq! ( output. stdout , b"terminated\n" ); 640} 641 642# [ test ] 643fn multicall_entries_are_distinct_authorities () { 644let fixture =Fixture :: new (); 645 fixture. script ( "real-tool" , "printf tool" ); 646symlink ( "real-tool" , fixture. dir . path (). join ( "alias-tool" )). unwrap (); 647 fixture. pass ( 648r#" 649import {run} from 'avril:std'; 650assert((await run('./real-tool',{encoding:'utf-8'})).stdout==='tool','granted entry'); 651await rejects(()=>run('./alias-tool'),'PermissionDenied'); 652"# , 653& [ "-0" . into (), "--allow-run=real-tool" . into ()], 654); 655 fixture. pass ( 656r#" 657import {run} from 'avril:std'; 658assert((await run('./alias-tool',{encoding:'utf-8'})).stdout==='tool','directory grant covers entries'); 659"# , 660& [ "-0" . into (), "--allow-run=./" . into ()], 661); 662} 663 664# [ cfg ( target_os = "macos" )] 665# [ test ] 666fn respelled_entries_meet_the_same_grants () { 667let fixture =Fixture :: new (); 668 fixture. script ( "tool" , "printf tool" ); 669// Respelling only reaches the same entry on case-insensitive volumes. 670if !fixture. dir . path (). join ( "TOOL" ). exists () { 671return ; 672} 673 fixture. pass ( 674r#"import {run} from 'avril:std'; await rejects(()=>run('./TOOL'),'PermissionDenied');"# , 675& [ "--deny-run=tool" . into ()], 676); 677 fixture. pass ( 678r#"import {run} from 'avril:std'; assert((await run('./Tool',{encoding:'utf-8'})).stdout==='tool','respelled grant');"# , 679& [ "-0" . into (), "--allow-run=TOOL" . into ()], 680); 681} 682 683# [ test ] 684fn early_stdin_close_is_benign () { 685pass ( 686r#" 687import {run,runSync} from 'avril:std'; 688const input = new Uint8Array(70000).fill(65); 689for (const method of [run, runSync]) { 690const early = await method(SH, {args:['-c','exit 3'], input, check:false}); 691assert(early.code === 3, 'exit without reading stdin'); 692const headed = await method(SH, {args:['-c','head -c 1'], input, encoding:'utf-8'}); 693assert(headed.stdout === 'A', 'partial read'); 694} 695"# , 696); 697} 698 699# [ test ] 700fn only_declared_descriptors_survive_exec () { 701pass ( 702r#" 703import {spawn,run} from 'avril:std'; 704const holder = spawn(SH, {args:['-c','sleep 30'], stdout:'null', stderr:'null', extraStdio:{3:{pipe:'parent-to-child'}}}); 705const out = await run(SH, {args:['-c','ls /dev/fd'], encoding:'utf-8'}); 706for (const name of out.stdout.trim().split('\n')) { 707assert(Number(name) <= 3, 'leaked descriptor '+name); 708} 709await holder.terminate(); 710"# , 711); 712} 713 714# [ test ] 715fn foreign_descriptors_do_not_survive_exec () { 716let fixture =Fixture :: new (); 717 fs:: write ( 718 fixture. dir . path (). join ( "main.js" ), 719format! ( 720r#" 721const SH={}; 722import {{run}} from 'avril:std'; 723const out = await run(SH, {{args:['-c','ls /dev/fd'], encoding:'utf-8'}}); 724for (const name of out.stdout.trim().split('\n')) {{ 725if (Number(name) > 3) throw Error('leaked descriptor '+name); 726}} 727"# , 728 serde_json:: to_string ( & shell ()). unwrap () 729), 730) 731. unwrap (); 732// dup2 clears CLOEXEC, so fd 5 reaches avril as a foreign descriptor that 733// subprocess descriptor isolation must remove. 734let probe = fs:: File :: open ( "/dev/null" ). unwrap (); 735let fd = probe. as_raw_fd (); 736let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 737 command 738. arg ( "run" ) 739. arg ( "main.js" ) 740. current_dir ( fixture. dir . path ()) 741. stdin ( Stdio :: null ()) 742. stdout ( Stdio :: piped ()) 743. stderr ( Stdio :: piped ()); 744unsafe { 745 command. pre_exec ( move ||{ 746 libc:: signal ( libc:: SIGINT , libc:: SIG_DFL ); 747 libc:: signal ( libc:: SIGTERM , libc:: SIG_DFL ); 748if libc:: dup2 ( fd, 5 ) <0 { 749return Err ( std:: io:: Error :: last_os_error ()); 750} 751Ok (()) 752}); 753} 754let output = common:: wait ( command. spawn (). unwrap ()); 755assert! ( 756 output. status . success (), 757"{}" , 758String :: from_utf8_lossy ( & output. stderr ) 759); 760} 761 762# [ test ] 763fn first_signal_exits_when_nothing_owns_it () { 764// A prior spawn must not swallow a lone signal: with no live child and 765// no owner (run mode), SIGTERM/SIGINT stop children and exit 143/130. 766use std:: os:: unix:: process:: ExitStatusExt ; 767for signalin [ libc:: SIGTERM , libc:: SIGINT ] { 768let fixture =Fixture :: new (); 769 fs:: write ( 770 fixture. dir . path (). join ( "main.js" ), 771format! ( 772r#" 773const SH={}; 774import {{runSync}} from 'avril:std'; 775runSync(SH, {{args:['-c','exit 0']}}); 776console.log('ready'); 777setInterval(()=>{{}}, 1000); 778"# , 779 serde_json:: to_string ( & shell ()). unwrap () 780), 781) 782. unwrap (); 783let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 784. arg ( "run" ) 785. arg ( "main.js" ) 786. current_dir ( fixture. dir . path ()) 787. stdin ( Stdio :: null ()) 788. stdout ( Stdio :: piped ()) 789. stderr ( Stdio :: piped ()) 790. spawn () 791. unwrap (); 792// Wait for the spawn to complete so the signal exercises the 793// supervisor rather than the default disposition. 794let mut ready =String :: new (); 795 std:: io:: BufRead :: read_line ( 796& mut std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()), 797& mut ready, 798) 799. unwrap (); 800assert_eq! ( ready. trim (), "ready" ); 801assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , signal) }, 0 ); 802let output = common:: wait ( child); 803assert_eq! ( 804 output. status . signal (), 805Some ( signal), 806"signal {signal}: {}" , 807String :: from_utf8_lossy ( & output. stderr ) 808); 809} 810} 811 812# [ test ] 813fn inherited_signal_ignore_is_preserved () { 814// A process launched with SIGINT/SIGHUP ignored keeps those fates: 815// the supervisor must not take over the signals, even when binds come 816// and go before it installs, and children must keep the ignores. 817let fixture =Fixture :: new (); 818 fs:: write ( 819 fixture. dir . path (). join ( "main.js" ), 820format! ( 821r#" 822const SH={}; 823import {{runSync, addSignalListener, removeSignalListener}} from 'avril:std'; 824// Binds that come and go must not move the startup baseline: 825// the supervisor installs after they are gone. 826const onInt = () => {{}}; 827addSignalListener('SIGINT', onInt); 828removeSignalListener('SIGINT', onInt); 829const onHup = () => {{}}; 830addSignalListener('SIGHUP', onHup); 831removeSignalListener('SIGHUP', onHup); 832runSync(SH, {{args:['-c','exit 0']}}); 833// Children keep the startup ignores too: these self-kills 834// survive only through an inherited SIG_IGN. 835const hup = runSync(SH, {{args:['-c','kill -HUP $$; echo survived']}}); 836console.log('child-hup ' + hup.code + '/' + hup.signal); 837const int = runSync(SH, {{args:['-c','kill -INT $$; echo survived']}}); 838console.log('child-int ' + int.code + '/' + int.signal); 839console.log('ready'); 840setInterval(()=>{{}}, 1000); 841"# , 842 serde_json:: to_string ( & shell ()). unwrap () 843), 844) 845. unwrap (); 846let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 847 command 848. arg ( "run" ) 849. arg ( "main.js" ) 850. current_dir ( fixture. dir . path ()) 851. stdin ( Stdio :: null ()) 852. stdout ( Stdio :: piped ()) 853. stderr ( Stdio :: piped ()); 854unsafe { 855 command. pre_exec ( ||{ 856 libc:: signal ( libc:: SIGINT , libc:: SIG_IGN ); 857 libc:: signal ( libc:: SIGHUP , libc:: SIG_IGN ); 858Ok (()) 859}); 860} 861let mut child = command. spawn (). unwrap (); 862let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 863let mut text =String :: new (); 864 std:: io:: BufRead :: read_line ( & mut stdout, & mut text). unwrap (); 865assert_eq! ( text. trim (), "child-hup 0/null" , "{text}" ); 866 text. clear (); 867 std:: io:: BufRead :: read_line ( & mut stdout, & mut text). unwrap (); 868assert_eq! ( text. trim (), "child-int 0/null" , "{text}" ); 869 text. clear (); 870 std:: io:: BufRead :: read_line ( & mut stdout, & mut text). unwrap (); 871assert_eq! ( text. trim (), "ready" , "{text}" ); 872 child. stdout =Some ( stdout. into_inner ()); 873assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGINT ) }, 0 ); 874 std:: thread:: sleep ( Duration :: from_millis ( 300 )); 875assert! ( 876 child. try_wait (). unwrap (). is_none (), 877"ignored SIGINT killed the process" 878); 879assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 880let output = common:: wait ( child); 881use std:: os:: unix:: process:: ExitStatusExt ; 882assert_eq! ( output. status . signal (), Some ( libc:: SIGTERM )); 883} 884 885# [ test ] 886fn headless_group_timeouts_reach_grandchildren () { 887let fixture =Fixture :: new (); 888 fs:: write ( 889 fixture. dir . path (). join ( "main.js" ), 890format! ( 891r#" 892import {{spawn}} from 'avril:std'; 893const child = spawn({}, {{args:['-c','sleep 303 & echo $! > pid.txt; wait'], stdout:'null', stderr:'inherit', timeoutMs:300}}); 894const status = await child.completed; 895if (status.reason !== 'timeout') throw Error('expected timeout'); 896"# , 897 serde_json:: to_string ( & shell ()). unwrap () 898), 899) 900. unwrap (); 901let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 902 command 903. args ([ "run" , "main.js" ]) 904. current_dir ( fixture. dir . path ()) 905. stdin ( Stdio :: null ()) 906. stdout ( Stdio :: null ()) 907// A leaked descendant must not hold a capture-thread pipe open. 908. stderr ( fs:: File :: create ( fixture. dir . path (). join ( "stderr.txt" )). unwrap ()); 909unsafe { 910 command. pre_exec ( ||{ 911 libc:: signal ( libc:: SIGINT , libc:: SIG_DFL ); 912 libc:: signal ( libc:: SIGTERM , libc:: SIG_DFL ); 913if libc:: setsid () <0 { 914return Err ( std:: io:: Error :: last_os_error ()); 915} 916Ok (()) 917}); 918} 919let output = common:: wait ( command. spawn (). unwrap ()); 920let pid: i32 = fs:: read_to_string ( fixture. dir . path (). join ( "pid.txt" )) 921. unwrap () 922. trim () 923. parse () 924. unwrap (); 925let alive =alive ( pid); 926if alive{ 927unsafe { 928 libc:: kill ( pid, libc:: SIGKILL ); 929} 930} 931assert! ( output. status . success ()); 932assert! ( !alive, "grandchild survived the group timeout" ); 933} 934 935# [ test ] 936fn capability_denials_do_not_disclose_filesystem_existence () { 937let fixture =Fixture :: new (); 938 fixture. pass ( 939r#" 940import {run} from 'avril:std'; 941await rejects(()=>run('./missing-tool',{cwd:'/sys/kernel'}),'PermissionDenied'); 942await rejects(()=>run('missing-tool-xyz'),'PermissionDenied'); 943"# , 944& [ "-0" . into (), format! ( "--allow-run={}" , shell (). display ())], 945); 946pass ( 947r#" 948import {run} from 'avril:std'; 949await rejects(()=>run('missing-tool-xyz'),'NotFound'); 950"# , 951); 952} 953 954# [ test ] 955fn serve_drains_an_in_flight_subprocess_on_sigterm () { 956use std:: io::{ BufRead , BufReader , Read , Write }; 957use std:: os:: unix:: net:: UnixStream ; 958let fixture =Fixture :: new (); 959let socket = fixture. dir . path (). join ( "http.sock" ); 960 fs:: write ( 961 fixture. dir . path (). join ( "main.js" ), 962format! ( 963r#" 964import {{spawn}} from 'avril:std'; 965export default {{async fetch() {{ 966const child = spawn({}, {{args:['-c','sleep 0.3; printf done'], encoding:'utf-8'}}); 967console.log('child-started'); 968const output = await child.output(); 969return new Response(output.stdout); 970}}}}; 971"# , 972 serde_json:: to_string ( & shell ()). unwrap () 973), 974) 975. unwrap (); 976let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 977. arg ( "serve" ) 978. arg ( format! ( "--unix={}" , socket. display ())) 979. arg ( "main.js" ) 980. current_dir ( fixture. dir . path ()) 981. stdin ( Stdio :: null ()) 982. stdout ( Stdio :: piped ()) 983. stderr ( Stdio :: piped ()) 984. spawn () 985. unwrap (); 986let mut stderr =BufReader :: new ( child. stderr . take (). unwrap ()); 987let mut line =String :: new (); 988 stderr. read_line ( & mut line). unwrap (); 989assert! ( line. starts_with ( "Listening on " ), "{line}" ); 990 child. stderr =Some ( stderr. into_inner ()); 991let mut connection =UnixStream :: connect ( & socket). unwrap (); 992 connection 993. set_read_timeout ( Some ( Duration :: from_secs ( 3 ))) 994. unwrap (); 995 connection 996. write_all ( b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" ) 997. unwrap (); 998let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 999 line. clear (); 1000 stdout. read_line ( & mut line). unwrap (); 1001assert_eq! ( line. trim (), "child-started" ); 1002 child. stdout =Some ( stdout. into_inner ()); 1003assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1004let mut response =String :: new (); 1005 connection. read_to_string ( & mut response). unwrap (); 1006let output = common:: wait ( child); 1007assert! ( 1008 output. status . success (), 1009"{}" , 1010String :: from_utf8_lossy ( & output. stderr ) 1011); 1012assert! ( response. starts_with ( "HTTP/1.1 200" ), "{response}" ); 1013assert! ( response. ends_with ( "done" ), "{response}" ); 1014assert! ( !socket. exists (), "socket left behind after shutdown" ); 1015} 1016 1017# [ test ] 1018fn unowned_signal_shutdown_kills_grace_resisting_descendants () { 1019use std:: io::{ BufRead , BufReader }; 1020use std:: os:: unix:: process:: ExitStatusExt ; 1021for repeatin [ false , true ] { 1022let fixture =Fixture :: new (); 1023 fs:: write ( 1024 fixture. dir . path (). join ( "main.js" ), 1025format! ( 1026r#" 1027import {{spawn}} from 'avril:std'; 1028const child = spawn({}, {{args:['-c',"trap '' TERM; sleep 304 & echo $!; wait"], stdin:'null', stderr:'null', termination:{{graceMs:10000}}}}); 1029const reader = child.stdout.getReader(); 1030console.log(new TextDecoder().decode((await reader.read()).value).trim()); 1031setInterval(()=>{{}},1000); 1032"# , 1033 serde_json:: to_string ( & shell ()). unwrap () 1034), 1035) 1036. unwrap (); 1037let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 1038. args ([ "run" , "main.js" ]) 1039. current_dir ( fixture. dir . path ()) 1040. stdin ( Stdio :: null ()) 1041. stdout ( Stdio :: piped ()) 1042. stderr ( Stdio :: piped ()) 1043. spawn () 1044. unwrap (); 1045let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 1046let mut line =String :: new (); 1047 stdout. read_line ( & mut line). unwrap (); 1048let grandchild: i32 = line. trim (). parse (). unwrap (); 1049 child. stdout =Some ( stdout. into_inner ()); 1050let started =Instant :: now (); 1051assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1052if repeat{ 1053 std:: thread:: sleep ( Duration :: from_millis ( 150 )); 1054assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1055} 1056let output = common:: wait ( child); 1057let alive =alive ( grandchild); 1058if alive{ 1059unsafe { 1060 libc:: kill ( grandchild, libc:: SIGKILL ); 1061} 1062} 1063assert_eq! ( output. status . signal (), Some ( libc:: SIGTERM )); 1064assert! ( !alive, "grandchild survived owner shutdown" ); 1065if repeat{ 1066assert! ( 1067 started. elapsed () <Duration :: from_secs ( 1 ), 1068"repeat did not shorten shutdown" 1069); 1070} 1071} 1072} 1073 1074# [ test ] 1075fn repeated_server_shutdown_signals_stop_referenced_subprocesses () { 1076use std:: io::{ BufRead , BufReader , Write }; 1077use std:: os:: unix:: net:: UnixStream ; 1078for signalin [ libc:: SIGTERM , libc:: SIGINT ] { 1079let fixture =Fixture :: new (); 1080let socket = fixture. dir . path (). join ( "http.sock" ); 1081 fs:: write ( 1082 fixture. dir . path (). join ( "main.js" ), 1083format! ( 1084r#" 1085import {{spawn}} from 'avril:std'; 1086const sidecar = spawn({}, {{args:['-c','exec sleep 30'],stdin:'null',stdout:'null',stderr:'null'}}); 1087console.log(sidecar.pid); 1088export default {{async fetch() {{ 1089console.log('request-started'); 1090await new Promise(resolve=>setTimeout(resolve,10000)); 1091return new Response('done'); 1092}}}}; 1093"# , 1094 serde_json:: to_string ( & shell ()). unwrap () 1095), 1096) 1097. unwrap (); 1098let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 1099. arg ( "serve" ) 1100. arg ( format! ( "--unix={}" , socket. display ())) 1101. arg ( "main.js" ) 1102. current_dir ( fixture. dir . path ()) 1103. stdin ( Stdio :: null ()) 1104. stdout ( Stdio :: piped ()) 1105. stderr ( Stdio :: piped ()) 1106. spawn () 1107. unwrap (); 1108let mut stderr =BufReader :: new ( child. stderr . take (). unwrap ()); 1109let mut line =String :: new (); 1110 stderr. read_line ( & mut line). unwrap (); 1111assert! ( line. starts_with ( "Listening on " ), "{line}" ); 1112 child. stderr =Some ( stderr. into_inner ()); 1113let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 1114 line. clear (); 1115 stdout. read_line ( & mut line). unwrap (); 1116let sidecar: i32 = line. trim (). parse (). unwrap (); 1117let mut connection =UnixStream :: connect ( & socket). unwrap (); 1118 connection 1119. write_all ( b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" ) 1120. unwrap (); 1121 line. clear (); 1122 stdout. read_line ( & mut line). unwrap (); 1123assert_eq! ( line. trim (), "request-started" ); 1124 child. stdout =Some ( stdout. into_inner ()); 1125assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , signal) }, 0 ); 1126 std:: thread:: sleep ( Duration :: from_millis ( 150 )); 1127assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , signal) }, 0 ); 1128let started =Instant :: now (); 1129while child. try_wait (). unwrap (). is_none () && started. elapsed () <Duration :: from_secs ( 3 ) { 1130 std:: thread:: sleep ( Duration :: from_millis ( 20 )); 1131} 1132let hung = child. try_wait (). unwrap (). is_none (); 1133if hung{ 1134 child. kill (). unwrap (); 1135} 1136let output = child. wait_with_output (). unwrap (); 1137assert! ( 1138 !hung, 1139"signal {signal}: sidecar alive={} stderr={}" , 1140unsafe { libc:: kill ( sidecar, 0 ) } ==0 , 1141String :: from_utf8_lossy ( & output. stderr ) 1142); 1143assert_eq! ( 1144unsafe { libc:: kill ( sidecar, 0 ) }, 1145 -1 , 1146"sidecar survived forced shutdown" 1147); 1148} 1149} 1150 1151# [ cfg ( target_os = "linux" )] 1152# [ test ] 1153fn descriptor_fallback_handles_unavailable_syscalls_and_lowered_limits () { 1154for errorin [ libc:: ENOSYS , libc:: EINVAL , libc:: EPERM ] { 1155let fixture =Fixture :: new (); 1156 fixture. script ( "script" , "printf pinned" ); 1157 fs:: write ( fixture. dir . path (). join ( "plain" ), "not an executable format" ). unwrap (); 1158 fs:: set_permissions ( 1159 fixture. dir . path (). join ( "plain" ), 1160 fs:: Permissions :: from_mode ( 0o755 ), 1161) 1162. unwrap (); 1163 fs:: write ( 1164 fixture. dir . path (). join ( "main.js" ), 1165format! ( 1166r#" 1167const SH={}; 1168const assert=(value,message)=>{{if(!value)throw Error(message)}}; 1169import {{run,runSync,spawn}} from 'avril:std'; 1170for (const method of [run,runSync]) {{ 1171const out=await method(SH,{{args:['-c','ls /proc/self/fd'],encoding:'utf-8'}}); 1172for (const name of out.stdout.trim().split('\n')) {{ 1173assert(Number(name)<=3,'foreign descriptor leaked: '+name); 1174}} 1175assert((await method('./script',{{encoding:'utf-8'}})).stdout==='pinned','shebang exec'); 1176try {{ await method('./plain'); throw Error('exec accepted plain text'); }} 1177catch (error) {{assert(error.name==='IoError','missing exec failure: '+error)}} 1178}} 1179const child=spawn(SH,{{args:['-c','IFS= read -r line <&3; printf "%s" "$line" >&4; printf standard'],extraStdio:{{3:{{pipe:'parent-to-child'}},4:{{pipe:'child-to-parent'}}}},encoding:'utf-8'}}); 1180const writer=child.stdio[3].getWriter(); 1181await writer.write(new TextEncoder().encode('extra\n')); await writer.close(); 1182assert((await new Response(child.stdio[4]).text())==='extra','declared descriptor lost'); 1183assert((await child.output()).stdout==='standard','standard output'); 1184"# , 1185 serde_json:: to_string ( & shell ()). unwrap () 1186), 1187). unwrap (); 1188let probe = fs:: File :: open ( "/dev/null" ). unwrap (); 1189let fd = probe. as_raw_fd (); 1190let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 1191 command 1192. args ([ "run" , "main.js" ]) 1193. current_dir ( fixture. dir . path ()) 1194. stdin ( Stdio :: null ()) 1195. stdout ( Stdio :: piped ()) 1196. stderr ( Stdio :: piped ()); 1197unsafe { 1198 command. pre_exec ( move ||{ 1199 libc:: signal ( libc:: SIGINT , libc:: SIG_DFL ); 1200 libc:: signal ( libc:: SIGTERM , libc:: SIG_DFL ); 1201// Inherit a descriptor beyond the *new* soft limit: a bounded 1202// fcntl loop using RLIMIT_NOFILE would miss it. 1203if libc:: dup2 ( fd, 200 ) <0 { 1204return Err ( std:: io:: Error :: last_os_error ()); 1205} 1206let mut limit: libc:: rlimit = std:: mem:: zeroed (); 1207if libc:: getrlimit ( libc:: RLIMIT_NOFILE , & mut limit) <0 { 1208return Err ( std:: io:: Error :: last_os_error ()); 1209} 1210 limit. rlim_cur =128 ; 1211if libc:: setrlimit ( libc:: RLIMIT_NOFILE , & limit) <0 { 1212return Err ( std:: io:: Error :: last_os_error ()); 1213} 1214let mut filter =[ 1215 libc:: sock_filter { 1216code : ( libc:: BPF_LD | libc:: BPF_W | libc:: BPF_ABS ) as u16 , 1217jt : 0 , 1218jf : 0 , 1219k : 0 , 1220}, 1221 libc:: sock_filter { 1222code : ( libc:: BPF_JMP | libc:: BPF_JEQ | libc:: BPF_K ) as u16 , 1223jt : 0 , 1224jf : 1 , 1225k : libc:: SYS_close_range as u32 , 1226}, 1227 libc:: sock_filter { 1228code : ( libc:: BPF_RET | libc:: BPF_K ) as u16 , 1229jt : 0 , 1230jf : 0 , 1231k : libc:: SECCOMP_RET_ERRNO | erroras u32 , 1232}, 1233 libc:: sock_filter { 1234code : ( libc:: BPF_RET | libc:: BPF_K ) as u16 , 1235jt : 0 , 1236jf : 0 , 1237k : libc:: SECCOMP_RET_ALLOW , 1238}, 1239]; 1240let program = libc:: sock_fprog { 1241len : filter. len () as u16 , 1242filter : filter. as_mut_ptr (), 1243}; 1244if libc:: prctl ( libc:: PR_SET_NO_NEW_PRIVS , 1 , 0 , 0 , 0 ) <0 1245 || libc:: prctl ( libc:: PR_SET_SECCOMP , libc:: SECCOMP_MODE_FILTER , & program) <0 1246{ 1247return Err ( std:: io:: Error :: last_os_error ()); 1248} 1249Ok (()) 1250}); 1251} 1252let output = common:: wait ( command. spawn (). unwrap ()); 1253assert! ( 1254 output. status . success (), 1255"errno {error}: {}" , 1256String :: from_utf8_lossy ( & output. stderr ) 1257); 1258} 1259} 1260 1261/// Reads one stdout line from an interacted child. 1262fn line ( reader : & mut std:: io:: BufReader < std:: process:: ChildStdout >) ->String { 1263let mut text =String :: new (); 1264 std:: io:: BufRead :: read_line ( reader, & mut text). unwrap (); 1265 text. trim_end (). to_owned () 1266} 1267 1268# [ test ] 1269fn signal_listeners_deliver_in_order_to_many_listeners () { 1270let fixture =Fixture :: new (); 1271let mut child = fixture. interact ( 1272r#" 1273import {addSignalListener, removeSignalListener} from 'avril:std'; 1274const seen = []; 1275const first = () => { seen.push('usr1'); console.log('saw usr1'); }; 1276const second = () => seen.push('usr1b'); 1277const other = () => { 1278seen.push('usr2'); 1279console.log('done ' + JSON.stringify(seen)); 1280removeSignalListener('SIGUSR1', first); 1281removeSignalListener('SIGUSR1', second); 1282removeSignalListener('SIGUSR2', other); 1283}; 1284addSignalListener('SIGUSR1', first); 1285addSignalListener('SIGUSR1', second); 1286addSignalListener('SIGUSR2', other); 1287console.log('ready'); 1288"# , 1289); 1290let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1291assert_eq! ( line ( & mut stdout), "ready" ); 1292assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1293assert_eq! ( line ( & mut stdout), "saw usr1" ); 1294assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR2 ) }, 0 ); 1295let done =line ( & mut stdout); 1296 child. stdout =Some ( stdout. into_inner ()); 1297// Both USR1 listeners fire in binding order before the USR2 listener. 1298assert_eq! ( done, r#"done ["usr1","usr1b","usr2"]"# , "{done}" ); 1299let output = common:: wait ( child); 1300assert! ( 1301 output. status . success (), 1302"{}" , 1303String :: from_utf8_lossy ( & output. stderr ) 1304); 1305} 1306 1307# [ test ] 1308fn signal_listener_removal_stops_delivery () { 1309let fixture =Fixture :: new (); 1310let mut child = fixture. interact ( 1311r#" 1312import {addSignalListener, removeSignalListener} from 'avril:std'; 1313let count = 0; 1314const keep = () => { 1315count++; 1316console.log('kept ' + count); 1317if (count === 2) { 1318console.log('final'); 1319removeSignalListener('SIGUSR1', keep); 1320} 1321}; 1322const drop = () => { count += 10; }; 1323addSignalListener('SIGUSR1', keep); 1324addSignalListener('SIGUSR1', keep); 1325addSignalListener('SIGUSR1', drop); 1326removeSignalListener('SIGUSR1', drop); 1327removeSignalListener('SIGUSR1', () => {}); 1328console.log('ready'); 1329"# , 1330); 1331let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1332assert_eq! ( line ( & mut stdout), "ready" ); 1333// The duplicate add fires once per delivery, and the removed listener 1334// never fires: no jump by ten. 1335assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1336assert_eq! ( line ( & mut stdout), "kept 1" ); 1337assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1338assert_eq! ( line ( & mut stdout), "kept 2" ); 1339assert_eq! ( line ( & mut stdout), "final" ); 1340 child. stdout =Some ( stdout. into_inner ()); 1341let output = common:: wait ( child); 1342assert! ( 1343 output. status . success (), 1344"{}" , 1345String :: from_utf8_lossy ( & output. stderr ) 1346); 1347} 1348 1349# [ test ] 1350fn listened_sigterm_survives_then_kills_once_removed () { 1351use std:: os:: unix:: process:: ExitStatusExt ; 1352let fixture =Fixture :: new (); 1353let mut child = fixture. interact ( 1354r#" 1355import {addSignalListener, removeSignalListener} from 'avril:std'; 1356const onTerm = () => { 1357removeSignalListener('SIGTERM', onTerm); 1358console.log('survived'); 1359}; 1360addSignalListener('SIGTERM', onTerm); 1361console.log('ready'); 1362setInterval(() => {}, 1000); 1363"# , 1364); 1365let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1366assert_eq! ( line ( & mut stdout), "ready" ); 1367// A listened SIGTERM reaches JS instead of the supervisor's death path. 1368assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1369assert_eq! ( line ( & mut stdout), "survived" ); 1370 child. stdout =Some ( stdout. into_inner ()); 1371// With no listeners left the prior fate returns. 1372assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1373let output = common:: wait ( child); 1374assert_eq! ( 1375 output. status . signal (), 1376Some ( libc:: SIGTERM ), 1377"{}" , 1378String :: from_utf8_lossy ( & output. stderr ) 1379); 1380} 1381 1382# [ test ] 1383fn unbound_signals_restore_default_death () { 1384use std:: os:: unix:: process:: ExitStatusExt ; 1385// A signal nobody ever binds keeps its default disposition. 1386let fixture =Fixture :: new (); 1387let mut child = fixture. interact ( r#"console.log('ready'); setInterval(() => {}, 1000);"# ); 1388let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1389assert_eq! ( line ( & mut stdout), "ready" ); 1390 child. stdout =Some ( stdout. into_inner ()); 1391assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1392let output = common:: wait ( child); 1393assert_eq! ( output. status . signal (), Some ( libc:: SIGUSR1 )); 1394// So does a signal whose last listener went away: the feed emulates the 1395// prior fate instead of swallowing the delivery. 1396let fixture =Fixture :: new (); 1397let mut child = fixture. interact ( 1398r#" 1399import {addSignalListener, removeSignalListener} from 'avril:std'; 1400const onUsr = () => { 1401removeSignalListener('SIGUSR1', onUsr); 1402console.log('unbound'); 1403}; 1404addSignalListener('SIGUSR1', onUsr); 1405console.log('ready'); 1406setInterval(() => {}, 1000); 1407"# , 1408); 1409let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1410assert_eq! ( line ( & mut stdout), "ready" ); 1411assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1412assert_eq! ( line ( & mut stdout), "unbound" ); 1413 child. stdout =Some ( stdout. into_inner ()); 1414assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1415let output = common:: wait ( child); 1416assert_eq! ( 1417 output. status . signal (), 1418Some ( libc:: SIGUSR1 ), 1419"{}" , 1420String :: from_utf8_lossy ( & output. stderr ) 1421); 1422} 1423 1424# [ test ] 1425fn signal_listener_validation_rejects_bad_signals () { 1426pass ( 1427r#" 1428import {addSignalListener, removeSignalListener} from 'avril:std'; 1429await rejects(() => addSignalListener('SIGFOO', () => {}), 'InvalidInput'); 1430await rejects(() => addSignalListener('', () => {}), 'InvalidInput'); 1431await rejects(() => addSignalListener('SIGKILL', () => {}), 'InvalidInput'); 1432await rejects(() => addSignalListener('SIGSTOP', () => {}), 'InvalidInput'); 1433await rejects(() => addSignalListener('SIGQUIT', () => {}), 'NotSupported'); 1434await rejects(() => addSignalListener('SIGPIPE', () => {}), 'NotSupported'); 1435await rejects(() => addSignalListener('SIGUSR1'), 'TypeError'); 1436await rejects(() => addSignalListener('SIGUSR1', 42), 'TypeError'); 1437await rejects(() => addSignalListener(Symbol('sig'), () => {}), 'TypeError'); 1438removeSignalListener('SIGUSR1', () => {}); 1439removeSignalListener('SIGFOO', () => {}); 1440"# , 1441); 1442} 1443 1444# [ test ] 1445fn serve_sigterm_runs_listeners_while_draining () { 1446use std:: io::{ BufRead , BufReader , Read , Write }; 1447use std:: os:: unix:: net:: UnixStream ; 1448let fixture =Fixture :: new (); 1449let socket = fixture. dir . path (). join ( "http.sock" ); 1450 fs:: write ( 1451 fixture. dir . path (). join ( "main.js" ), 1452r#" 1453import {addSignalListener} from 'avril:std'; 1454addSignalListener('SIGTERM', () => console.log('listener-ran')); 1455export default {async fetch() { 1456console.log('child-started'); 1457await new Promise(resolve => setTimeout(resolve, 300)); 1458return new Response('done'); 1459}}; 1460"# , 1461) 1462. unwrap (); 1463let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 1464. arg ( "serve" ) 1465. arg ( format! ( "--unix={}" , socket. display ())) 1466. arg ( "main.js" ) 1467. current_dir ( fixture. dir . path ()) 1468. stdin ( Stdio :: null ()) 1469. stdout ( Stdio :: piped ()) 1470. stderr ( Stdio :: piped ()) 1471. spawn () 1472. unwrap (); 1473let mut stderr =BufReader :: new ( child. stderr . take (). unwrap ()); 1474let mut text =String :: new (); 1475 stderr. read_line ( & mut text). unwrap (); 1476assert! ( text. starts_with ( "Listening on " ), "{text}" ); 1477 child. stderr =Some ( stderr. into_inner ()); 1478let mut connection =UnixStream :: connect ( & socket). unwrap (); 1479 connection 1480. set_read_timeout ( Some ( Duration :: from_secs ( 3 ))) 1481. unwrap (); 1482 connection 1483. write_all ( b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" ) 1484. unwrap (); 1485let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 1486 text. clear (); 1487 stdout. read_line ( & mut text). unwrap (); 1488assert_eq! ( text. trim (), "child-started" ); 1489 child. stdout =Some ( stdout. into_inner ()); 1490assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1491let mut response =String :: new (); 1492 connection. read_to_string ( & mut response). unwrap (); 1493let output = common:: wait ( child); 1494assert! ( 1495 output. status . success (), 1496"{}" , 1497String :: from_utf8_lossy ( & output. stderr ) 1498); 1499assert! ( response. starts_with ( "HTTP/1.1 200" ), "{response}" ); 1500assert! ( response. ends_with ( "done" ), "{response}" ); 1501// The listener observes the owned shutdown rather than replacing it. 1502let stdout =String :: from_utf8 ( output. stdout ). unwrap (); 1503assert! ( stdout. contains ( "listener-ran" ), "{stdout}" ); 1504assert! ( !socket. exists (), "socket left behind after shutdown" ); 1505} 1506 1507# [ test ] 1508fn serve_sigint_runs_listeners_while_draining () { 1509use std:: io::{ BufRead , BufReader , Read , Write }; 1510use std:: os:: unix:: net:: UnixStream ; 1511let fixture =Fixture :: new (); 1512let socket = fixture. dir . path (). join ( "http.sock" ); 1513 fs:: write ( 1514 fixture. dir . path (). join ( "main.js" ), 1515r#" 1516import {addSignalListener} from 'avril:std'; 1517addSignalListener('SIGINT', () => console.log('int-listener-ran')); 1518export default {async fetch() { 1519console.log('child-started'); 1520await new Promise(resolve => setTimeout(resolve, 300)); 1521return new Response('done'); 1522}}; 1523"# , 1524) 1525. unwrap (); 1526let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 1527. arg ( "serve" ) 1528. arg ( format! ( "--unix={}" , socket. display ())) 1529. arg ( "main.js" ) 1530. current_dir ( fixture. dir . path ()) 1531. stdin ( Stdio :: null ()) 1532. stdout ( Stdio :: piped ()) 1533. stderr ( Stdio :: piped ()) 1534. spawn () 1535. unwrap (); 1536let mut stderr =BufReader :: new ( child. stderr . take (). unwrap ()); 1537let mut text =String :: new (); 1538 stderr. read_line ( & mut text). unwrap (); 1539assert! ( text. starts_with ( "Listening on " ), "{text}" ); 1540 child. stderr =Some ( stderr. into_inner ()); 1541let mut connection =UnixStream :: connect ( & socket). unwrap (); 1542 connection 1543. set_read_timeout ( Some ( Duration :: from_secs ( 3 ))) 1544. unwrap (); 1545 connection 1546. write_all ( b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" ) 1547. unwrap (); 1548let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 1549 text. clear (); 1550 stdout. read_line ( & mut text). unwrap (); 1551assert_eq! ( text. trim (), "child-started" ); 1552 child. stdout =Some ( stdout. into_inner ()); 1553// A top-level listener observes the owned shutdown: the claim shares 1554// one chaining registry instead of clobbering the listener's feed. 1555assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGINT ) }, 0 ); 1556let mut response =String :: new (); 1557 connection. read_to_string ( & mut response). unwrap (); 1558let output = common:: wait ( child); 1559assert! ( 1560 output. status . success (), 1561"{}" , 1562String :: from_utf8_lossy ( & output. stderr ) 1563); 1564assert! ( response. starts_with ( "HTTP/1.1 200" ), "{response}" ); 1565assert! ( response. ends_with ( "done" ), "{response}" ); 1566let stdout =String :: from_utf8 ( output. stdout ). unwrap (); 1567assert! ( stdout. contains ( "int-listener-ran" ), "{stdout}" ); 1568assert! ( !socket. exists (), "socket left behind after shutdown" ); 1569} 1570 1571# [ test ] 1572fn listened_sigterm_suppresses_installed_supervisor () { 1573use std:: os:: unix:: process:: ExitStatusExt ; 1574// Unlike the emulation-only variant, the supervisor installs first 1575// (via spawn), then the listener binds: suppression must still win. 1576let fixture =Fixture :: new (); 1577let mut child = fixture. interact ( 1578r#" 1579import {runSync, addSignalListener, removeSignalListener} from 'avril:std'; 1580runSync(SH, {args:['-c','exit 0']}); 1581const onTerm = () => { 1582removeSignalListener('SIGTERM', onTerm); 1583console.log('survived'); 1584}; 1585addSignalListener('SIGTERM', onTerm); 1586console.log('ready'); 1587setInterval(() => {}, 1000); 1588"# , 1589); 1590let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1591assert_eq! ( line ( & mut stdout), "ready" ); 1592assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1593assert_eq! ( line ( & mut stdout), "survived" ); 1594 child. stdout =Some ( stdout. into_inner ()); 1595assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1596let output = common:: wait ( child); 1597assert_eq! ( 1598 output. status . signal (), 1599Some ( libc:: SIGTERM ), 1600"{}" , 1601String :: from_utf8_lossy ( & output. stderr ) 1602); 1603} 1604 1605# [ test ] 1606fn jailed_worker_bindings_do_not_suppress_death () { 1607use std:: os:: unix:: process:: ExitStatusExt ; 1608// A worker with every pledgeable capability but jsExec taken away 1609// binds INT and TERM: with no entry listener, neither delivery is 1610// suppressed, and SIGKILL is never needed. 1611let fixture =Fixture :: new (); 1612 fs:: write ( 1613 fixture. dir . path (). join ( "worker.js" ), 1614r#" 1615import {addSignalListener} from 'avril:std'; 1616addSignalListener('SIGINT', () => {}); 1617addSignalListener('SIGTERM', () => {}); 1618postMessage('worker-ready'); 1619setInterval(() => {}, 1000); 1620"# , 1621) 1622. unwrap (); 1623let entry =r#" 1624import {runSync, avril} from 'avril:std'; 1625runSync(SH, {args:['-c','exit 0']}); 1626const worker = new Worker('./worker.js', {type:'module', [avril]: {caps: {net: false, read: false, write: false, env: false, run: false}}}); 1627worker.onmessage = (event) => console.log(event.data); 1628console.log('ready'); 1629setInterval(() => {}, 1000); 1630"# ; 1631// SIGINT kills despite the worker's binding. 1632let mut child = fixture. interact ( entry); 1633let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1634let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1635 first. sort (); 1636assert_eq! ( first, [ "ready" , "worker-ready" ]); 1637 child. stdout =Some ( stdout. into_inner ()); 1638assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGINT ) }, 0 ); 1639let output = common:: wait ( child); 1640assert_eq! ( 1641 output. status . signal (), 1642Some ( libc:: SIGINT ), 1643"{}" , 1644String :: from_utf8_lossy ( & output. stderr ) 1645); 1646// So does SIGTERM. 1647let mut child = fixture. interact ( entry); 1648let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1649let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1650 first. sort (); 1651assert_eq! ( first, [ "ready" , "worker-ready" ]); 1652 child. stdout =Some ( stdout. into_inner ()); 1653assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1654let output = common:: wait ( child); 1655assert_eq! ( 1656 output. status . signal (), 1657Some ( libc:: SIGTERM ), 1658"{}" , 1659String :: from_utf8_lossy ( & output. stderr ) 1660); 1661} 1662 1663# [ test ] 1664fn worker_receives_while_entry_suppresses () { 1665use std:: os:: unix:: process:: ExitStatusExt ; 1666// Broadcast is unchanged: the worker observes the USR1 and TERM the 1667// entry listener survives. 1668let fixture =Fixture :: new (); 1669 fs:: write ( 1670 fixture. dir . path (). join ( "worker.js" ), 1671r#" 1672import {addSignalListener} from 'avril:std'; 1673addSignalListener('SIGUSR1', () => postMessage('worker-got-usr1')); 1674addSignalListener('SIGTERM', () => postMessage('worker-got-term')); 1675postMessage('worker-ready'); 1676"# , 1677) 1678. unwrap (); 1679let mut child = fixture. interact ( 1680r#" 1681import {addSignalListener} from 'avril:std'; 1682const worker = new Worker('./worker.js', {type:'module'}); 1683worker.onmessage = (event) => console.log(event.data); 1684addSignalListener('SIGUSR1', () => console.log('entry-got-usr1')); 1685addSignalListener('SIGTERM', () => console.log('entry-got-term')); 1686console.log('ready'); 1687"# , 1688); 1689let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1690let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1691 first. sort (); 1692assert_eq! ( first, [ "ready" , "worker-ready" ]); 1693assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1694let mut second =[ line ( & mut stdout), line ( & mut stdout)]; 1695 second. sort (); 1696assert_eq! ( second, [ "entry-got-usr1" , "worker-got-usr1" ]); 1697assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1698let mut third =[ line ( & mut stdout), line ( & mut stdout)]; 1699 third. sort (); 1700assert_eq! ( third, [ "entry-got-term" , "worker-got-term" ]); 1701 child. stdout =Some ( stdout. into_inner ()); 1702 child. kill (). unwrap (); 1703let output = common:: wait ( child); 1704assert_eq! ( output. status . signal (), Some ( libc:: SIGKILL )); 1705} 1706 1707# [ test ] 1708fn entry_unbind_while_worker_bound_restores_death () { 1709use std:: os:: unix:: process:: ExitStatusExt ; 1710// The entry listener holds off USR1 once, then goes away: the next 1711// delivery kills, though the worker stays bound throughout. 1712let fixture =Fixture :: new (); 1713 fs:: write ( 1714 fixture. dir . path (). join ( "worker.js" ), 1715r#" 1716import {addSignalListener} from 'avril:std'; 1717addSignalListener('SIGUSR1', () => {}); 1718postMessage('worker-ready'); 1719setInterval(() => {}, 1000); 1720"# , 1721) 1722. unwrap (); 1723let mut child = fixture. interact ( 1724r#" 1725import {addSignalListener, removeSignalListener} from 'avril:std'; 1726const worker = new Worker('./worker.js', {type:'module'}); 1727worker.onmessage = (event) => console.log(event.data); 1728const onUsr = () => { 1729removeSignalListener('SIGUSR1', onUsr); 1730console.log('unbound'); 1731}; 1732addSignalListener('SIGUSR1', onUsr); 1733console.log('ready'); 1734setInterval(() => {}, 1000); 1735"# , 1736); 1737let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1738let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1739 first. sort (); 1740assert_eq! ( first, [ "ready" , "worker-ready" ]); 1741assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1742assert_eq! ( line ( & mut stdout), "unbound" ); 1743 child. stdout =Some ( stdout. into_inner ()); 1744assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGUSR1 ) }, 0 ); 1745let output = common:: wait ( child); 1746assert_eq! ( 1747 output. status . signal (), 1748Some ( libc:: SIGUSR1 ), 1749"{}" , 1750String :: from_utf8_lossy ( & output. stderr ) 1751); 1752} 1753 1754# [ test ] 1755fn worker_only_sigwinch_is_received () { 1756use std:: os:: unix:: process:: ExitStatusExt ; 1757// SIGWINCH never kills, so a worker-only binding hears it with no 1758// entry listener at all. 1759let fixture =Fixture :: new (); 1760 fs:: write ( 1761 fixture. dir . path (). join ( "worker.js" ), 1762r#" 1763import {addSignalListener} from 'avril:std'; 1764addSignalListener('SIGWINCH', () => postMessage('worker-got-winch')); 1765postMessage('worker-ready'); 1766"# , 1767) 1768. unwrap (); 1769let mut child = fixture. interact ( 1770r#" 1771const worker = new Worker('./worker.js', {type:'module'}); 1772worker.onmessage = (event) => console.log(event.data); 1773console.log('ready'); 1774setInterval(() => {}, 1000); 1775"# , 1776); 1777let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1778let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1779 first. sort (); 1780assert_eq! ( first, [ "ready" , "worker-ready" ]); 1781assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGWINCH ) }, 0 ); 1782assert_eq! ( line ( & mut stdout), "worker-got-winch" ); 1783 child. stdout =Some ( stdout. into_inner ()); 1784 child. kill (). unwrap (); 1785let output = common:: wait ( child); 1786assert_eq! ( output. status . signal (), Some ( libc:: SIGKILL )); 1787} 1788 1789# [ test ] 1790fn ignored_hup_reaches_worker_only_listener () { 1791use std:: os:: unix:: process:: ExitStatusExt ; 1792// An inherited SIG_IGN keeps the process alive, and the worker-only 1793// listener still observes the delivery. 1794let fixture =Fixture :: new (); 1795 fs:: write ( 1796 fixture. dir . path (). join ( "worker.js" ), 1797r#" 1798import {addSignalListener} from 'avril:std'; 1799addSignalListener('SIGHUP', () => postMessage('worker-got-hup')); 1800postMessage('worker-ready'); 1801"# , 1802) 1803. unwrap (); 1804 fs:: write ( 1805 fixture. dir . path (). join ( "main.js" ), 1806r#" 1807const worker = new Worker('./worker.js', {type:'module'}); 1808worker.onmessage = (event) => console.log(event.data); 1809console.log('ready'); 1810setInterval(() => {}, 1000); 1811"# , 1812) 1813. unwrap (); 1814let mut command =Command :: new ( env! ( "CARGO_BIN_EXE_avril" )); 1815 command 1816. arg ( "run" ) 1817. arg ( "main.js" ) 1818. current_dir ( fixture. dir . path ()) 1819. stdin ( Stdio :: null ()) 1820. stdout ( Stdio :: piped ()) 1821. stderr ( Stdio :: piped ()); 1822unsafe { 1823 command. pre_exec ( ||{ 1824 libc:: signal ( libc:: SIGHUP , libc:: SIG_IGN ); 1825 libc:: signal ( libc:: SIGINT , libc:: SIG_DFL ); 1826 libc:: signal ( libc:: SIGTERM , libc:: SIG_DFL ); 1827Ok (()) 1828}); 1829} 1830let mut child = command. spawn (). unwrap (); 1831let mut stdout = std:: io:: BufReader :: new ( child. stdout . take (). unwrap ()); 1832let mut first =[ line ( & mut stdout), line ( & mut stdout)]; 1833 first. sort (); 1834assert_eq! ( first, [ "ready" , "worker-ready" ]); 1835assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGHUP ) }, 0 ); 1836assert_eq! ( line ( & mut stdout), "worker-got-hup" ); 1837 child. stdout =Some ( stdout. into_inner ()); 1838 std:: thread:: sleep ( Duration :: from_millis ( 300 )); 1839assert! ( 1840 child. try_wait (). unwrap (). is_none (), 1841"ignored SIGHUP killed the process" 1842); 1843 child. kill (). unwrap (); 1844let output = common:: wait ( child); 1845assert_eq! ( output. status . signal (), Some ( libc:: SIGKILL )); 1846} 1847 1848# [ test ] 1849fn serve_term_drain_reaches_worker_listener () { 1850use std:: io::{ BufRead , BufReader , Read , Write }; 1851use std:: os:: unix:: net:: UnixStream ; 1852// Serve owns TERM for its drain; a worker-only listener observes the 1853// delivery while the owned shutdown proceeds. 1854let fixture =Fixture :: new (); 1855 fs:: write ( 1856 fixture. dir . path (). join ( "worker.js" ), 1857r#" 1858import {addSignalListener} from 'avril:std'; 1859addSignalListener('SIGTERM', () => postMessage('worker-got-term')); 1860postMessage('worker-ready'); 1861"# , 1862) 1863. unwrap (); 1864 fs:: write ( 1865 fixture. dir . path (). join ( "main.js" ), 1866r#" 1867const worker = new Worker('./worker.js', {type:'module'}); 1868worker.onmessage = (event) => console.log(event.data); 1869console.log('ready'); 1870export default {async fetch() { 1871console.log('child-started'); 1872await new Promise(resolve => setTimeout(resolve, 300)); 1873return new Response('done'); 1874}}; 1875"# , 1876) 1877. unwrap (); 1878let socket = fixture. dir . path (). join ( "http.sock" ); 1879let mut child =inherit_defaults ( Command :: new ( env! ( "CARGO_BIN_EXE_avril" ))) 1880. arg ( "serve" ) 1881. arg ( format! ( "--unix={}" , socket. display ())) 1882. arg ( "main.js" ) 1883. current_dir ( fixture. dir . path ()) 1884. stdin ( Stdio :: null ()) 1885. stdout ( Stdio :: piped ()) 1886. stderr ( Stdio :: piped ()) 1887. spawn () 1888. unwrap (); 1889let mut stderr =BufReader :: new ( child. stderr . take (). unwrap ()); 1890let mut text =String :: new (); 1891 stderr. read_line ( & mut text). unwrap (); 1892assert! ( text. starts_with ( "Listening on " ), "{text}" ); 1893 child. stderr =Some ( stderr. into_inner ()); 1894let mut connection =UnixStream :: connect ( & socket). unwrap (); 1895 connection 1896. set_read_timeout ( Some ( Duration :: from_secs ( 3 ))) 1897. unwrap (); 1898 connection 1899. write_all ( b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" ) 1900. unwrap (); 1901let mut stdout =BufReader :: new ( child. stdout . take (). unwrap ()); 1902let mut started =[ String :: new (), String :: new (), String :: new ()]; 1903for linein started. iter_mut () { 1904 stdout. read_line ( line). unwrap (); 1905} 1906 started 1907. iter_mut () 1908. for_each ( |line|* line = line. trim (). to_owned ()); 1909 started. sort (); 1910assert_eq! ( started, [ "child-started" , "ready" , "worker-ready" ]); 1911 child. stdout =Some ( stdout. into_inner ()); 1912assert_eq! ( unsafe { libc:: kill ( child. id () as i32 , libc:: SIGTERM ) }, 0 ); 1913let mut response =String :: new (); 1914 connection. read_to_string ( & mut response). unwrap (); 1915let output = common:: wait ( child); 1916assert! ( 1917 output. status . success (), 1918"{}" , 1919String :: from_utf8_lossy ( & output. stderr ) 1920); 1921assert! ( response. starts_with ( "HTTP/1.1 200" ), "{response}" ); 1922assert! ( response. ends_with ( "done" ), "{response}" ); 1923let stdout =String :: from_utf8 ( output. stdout ). unwrap (); 1924assert! ( stdout. contains ( "worker-got-term" ), "{stdout}" ); 1925assert! ( !socket. exists (), "socket left behind after shutdown" ); 1926}