#![cfg(any(target_os = "linux", target_os = "macos"))] mod common; use std::fs; use std::os::fd::AsRawFd; use std::os::unix::fs::{PermissionsExt, symlink}; use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; fn shell() -> PathBuf { std::env::split_paths(&std::env::var_os("PATH").unwrap()) .map(|directory| directory.join("sh")) .find(|path| path.is_file()) .unwrap() .canonicalize() .unwrap() } fn native_command( scope: &std::sync::Arc, command: &str, ) -> std::io::Result> { avril_subprocess::Launcher { runtime: tokio::runtime::Handle::current(), caps: avril_caps::Capabilities::new( avril_caps::Initial::new(false), std::env::current_dir().unwrap(), ), owners: Vec::new(), } .spawn( avril_subprocess::Options::new( shell().to_str().unwrap(), vec!["-c".into(), command.into()], ), std::env::vars_os(), Some(scope), |_, _, _| unreachable!(), ) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_group_outlives_a_command_and_cleans_up_on_drop() { let scope = std::sync::Arc::new( avril_subprocess::Scope::new( std::path::Path::new(env!("CARGO_BIN_EXE_avril")), Vec::new(), ) .unwrap(), ); let first = native_command(&scope, "exit 7").unwrap(); assert_eq!(first.wait(true, None).await.unwrap().unwrap().code, Some(7)); let second = native_command(&scope, "while :; do :; done").unwrap(); drop(scope); let status = tokio::time::timeout(Duration::from_secs(5), second.wait(true, None)) .await .unwrap() .unwrap() .unwrap(); assert!(!status.success); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_group_cannot_spawn_after_close() { let scope = std::sync::Arc::new( avril_subprocess::Scope::new( std::path::Path::new(env!("CARGO_BIN_EXE_avril")), Vec::new(), ) .unwrap(), ); scope.force_close(); scope.force_close(); assert!( scope .if_running(|| panic!("published after close")) .is_none() ); assert_eq!( native_command(&scope, "exit 0").err().unwrap().kind(), std::io::ErrorKind::Interrupted, ); } struct Fixture { dir: tempfile::TempDir, } /// Resets SIGINT/SIGTERM to their defaults in the child, so results never /// depend on dispositions the test runner itself ignores: a shell running /// `cargo test` as a background job ignores SIGINT, which children would /// otherwise inherit through the snapshot. fn inherit_defaults(mut command: Command) -> Command { unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); Ok(()) }); } command } impl Fixture { fn new() -> Self { Self { dir: tempfile::tempdir().unwrap(), } } fn script(&self, name: &str, body: &str) { let path = self.dir.path().join(name); fs::write(&path, format!("#!{}\n{body}\n", shell().display())).unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); } fn run(&self, source: &str, flags: &[String]) -> Output { let prefix = format!( r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}}; async function rejects(action,name){{try{{await action()}}catch(error){{assert(error.name===name,'Expected '+name+', got '+error);return error}}throw Error('Expected '+name)}} "#, serde_json::to_string(&shell()).unwrap() ); fs::write( self.dir.path().join("main.js"), format!("{prefix}\n{source}"), ) .unwrap(); common::wait( inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("run") .args(flags) .arg("main.js") .current_dir(self.dir.path()) .env("AVRIL_PROCESS_SECRET", "host-secret") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(), ) } fn pass(&self, source: &str, flags: &[String]) -> Output { let output = self.run(source, flags); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); output } /// Writes main.js with the shared prelude and spawns it for interaction: /// the caller reads lines, sends signals, then finishes with common::wait. fn interact(&self, source: &str) -> std::process::Child { let prefix = format!( r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}}; async function rejects(action,name){{try{{await action()}}catch(error){{assert(error.name===name,'Expected '+name+', got '+error);return error}}throw Error('Expected '+name)}} "#, serde_json::to_string(&shell()).unwrap() ); fs::write( self.dir.path().join("main.js"), format!("{prefix}\n{source}"), ) .unwrap(); inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("run") .arg("main.js") .current_dir(self.dir.path()) .env("AVRIL_PROCESS_SECRET", "host-secret") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap() } } /// Whether a process is running, as opposed to gone or a zombie. fn alive(pid: i32) -> bool { #[cfg(target_os = "linux")] return fs::read_to_string(format!("/proc/{pid}/stat")) .is_ok_and(|stat| !stat.split(") ").nth(1).unwrap().starts_with('Z')); // Without procfs, give launchd a moment to reap orphaned zombies. #[cfg(not(target_os = "linux"))] { let deadline = Instant::now() + Duration::from_secs(1); while unsafe { libc::kill(pid, 0) } == 0 { if Instant::now() >= deadline { return true; } std::thread::sleep(Duration::from_millis(10)); } false } } fn pass(source: &str) { Fixture::new().pass(source, &[]); } #[test] fn invocation_results_bytes_encoding_nonzero_and_no_implicit_shell() { pass( r#" import {run,runSync,spawn,ProcessError} from 'avril:std'; const first=await run(SH,{args:['-c','printf hello; printf problem >&2']}); assert(first.code===0 && first.success && first.reason===null,'status'); assert(new TextDecoder().decode(first.stdout)==='hello' && new TextDecoder().decode(first.stderr)==='problem','bytes'); const decoded=runSync(SH,{args:['-c','printf "%s" "$1"','sh','a; echo injected'],encoding:'utf-8'}); assert(decoded.stdout==='a; echo injected','literal argv'); const failed=await rejects(()=>run(SH,{args:['-c','printf partial; exit 23'],encoding:'utf-8'}),'ProcessError'); assert(failed instanceof ProcessError && failed.result.code===23 && failed.result.stdout==='partial','partial exit error'); const allowed=await run(SH,{args:['-c','exit 7'],check:false}); assert(allowed.code===7 && !allowed.success,'unchecked'); const child=spawn(SH,{args:['-c','printf x'],encoding:'utf-8'}); assert((await child.output()).stdout==='x','one invocation'); await rejects(()=>child.output(),'TypeError'); assert(!child.kill(),'kill after exit'); "#, ); } #[test] fn positional_arguments_and_stdout_shortcuts() { pass( r#" import {run,runSync,spawn} from 'avril:std'; assert(await run(SH,['-c','printf "a\\nb"']).text()==='a\nb','text'); assert((await run(SH,['-c','printf \'{"n":1}\'']).json()).n===1,'json'); assert((await run(SH,['-c','printf hi']).bytes()).length===2,'bytes'); assert(await run(SH,['-c','printf é'],{encoding:'utf-8'}).text()==='é','encoded text'); assert(runSync(SH,['-c','printf sync'],{encoding:'utf-8'}).stdout==='sync','runSync'); assert((await spawn(SH,['-c','printf spawn'],{encoding:'utf-8'}).output()).stdout==='spawn','spawn'); await rejects(()=>run(SH,['-c','true'],{args:[]}),'TypeError'); await rejects(()=>run(SH,'-c'),'TypeError'); await rejects(()=>run(SH,['-c','exit 3']).text(),'ProcessError'); await rejects(()=>run(SH,['-c','true'],{stdout:'null'}).text(),'TypeError'); "#, ); } #[test] fn lines_stream_while_the_child_runs() { pass( r#" import {run} from 'avril:std'; const seen=[]; for await (const line of run(SH,['-c','echo one; read _; printf "two\\r\\nthree"'],{input:new ReadableStream({ async pull(controller){ while(!seen.length) await new Promise(r=>setTimeout(r,5)); controller.enqueue(new TextEncoder().encode('\n')); controller.close(); } })}).lines()) seen.push(line); assert(JSON.stringify(seen)==='["one","two","three"]','streamed lines: '+JSON.stringify(seen)); const failing=run(SH,['-c','echo kept; echo why >&2; exit 4']); const lines=[]; const error=await rejects(async()=>{ for await (const line of failing.lines()) lines.push(line); },'ProcessError'); assert(lines[0]==='kept'&&error.result.code===4&&new TextDecoder().decode(error.result.stderr)==='why\n','failure after lines'); await rejects(()=>failing,'ProcessError'); const endless=run(SH,['-c','echo first; exec sleep 30'],{termination:{graceMs:100}}); for await (const line of endless.lines()) break; assert((await rejects(()=>endless,'ProcessError')).result.reason==='terminated','early exit terminates'); const late=run(SH,['-c','echo a; echo b']); await late; const buffered=[]; for await (const line of late.lines()) buffered.push(line); assert(JSON.stringify(buffered)==='["a","b"]','collected lines'); "#, ); } #[test] fn streamed_lines_apply_backpressure_instead_of_a_total_cap() { let fixture = Fixture::new(); fixture.pass( r#" import {run,stat} from 'avril:std'; const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const exists=async(name)=>{try{await stat(name);return true}catch{return false}}; // A slow reader blocks the child instead of letting it race ahead. let count=0; for await (const line of run(SH,['-c','yes 0123456789 | head -n 400000; : > written']).lines()) { if (count++===0) { await sleep(300); assert(!await exists('written'),'child ran ahead of the reader'); } } assert(count===400000&&await exists('written'),'every line'); // Draining is not capped by the run's default 16 MiB. let bytes=0; for await (const line of run(SH,['-c','yes $(printf %0100d 0) | head -n 200000']).lines()) bytes+=line.length+1; assert(bytes===200000*101,'uncapped stream'); // Only what is kept counts toward maxCollectedBytes: stderr, not streamed stdout. let streamed=0; for await (const line of run(SH,['-c','yes x | head -n 100000'],{maxCollectedBytes:1000}).lines()) streamed++; assert(streamed===100000,'streamed stdout is not collected'); const kept=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; printf "%05000d" 0 >&2'],{maxCollectedBytes:1000}).lines()); },'OutputLimitError'); assert(kept.result.stderr.length===1000,'collected stderr is bounded'); "#, &[], ); } #[test] fn backpressured_lines_still_end_promptly() { pass( r#" import {run} from 'avril:std'; const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); // Leaving while the pump waits on the reader terminates the child. let started=Date.now(); const left=run(SH,['-c','exec yes'],{termination:{graceMs:100}}); for await (const line of left.lines()) { await sleep(100); break; } assert((await rejects(()=>left,'ProcessError')).result.reason==='terminated','left'); assert(Date.now()-started<2000,'leaving waited on the reader'); // A timeout still stops a child whose reader fell behind. started=Date.now(); await rejects(async()=>{ for await (const line of run(SH,['-c','exec yes'],{timeoutMs:200,drainTimeoutMs:200}).lines()) await sleep(1); },'ProcessTimeoutError'); assert(Date.now()-started<3000,'timeout waited on the reader'); // A reader catching up after exit is not a straggler holding the // pipe: this output outlasts the drain timeout in the queue, after // the child has written its last line into the pipe and exited. let count=0; for await (const line of run(SH,['-c','yes x | head -n 580000'],{drainTimeoutMs:300}).lines()) { if (count++===0) await sleep(600); } assert(count===580000,'reader kept its lines: '+count); // A real straggler still ends the run. started=Date.now(); const error=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; exec sleep 30 &'],{drainTimeoutMs:300}).lines()); },'ProcessError'); assert(error.result.reason==='drain-timeout'&&Date.now()-started<2000,'straggler'); "#, ); } #[test] fn one_shot_bytes_streams_and_sync_close_stdin() { pass( r#" import {run,runSync} from 'avril:std'; const script='while IFS= read -r line; do printf "%s\\n" "$line"; done'; for(const method of [run,runSync]) { const out=await method(SH,{args:['-c',script],input:'alpha\nbeta\n',encoding:'utf-8'}); assert(out.stdout==='alpha\nbeta\n','string input and EOF'); const empty=await method(SH,{args:['-c',script],stdin:'piped'}); assert(empty.stdout.length===0,'empty input EOF'); } const stream=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream\n'));c.close()}}); assert((await run(SH,{args:['-c',script],input:stream,encoding:'utf-8'})).stdout==='stream\n','stream input'); await rejects(()=>runSync(SH,{input:new ReadableStream()}),'TypeError'); const never=new ReadableStream({pull(){return new Promise(()=>{})},cancel(){return new Promise(()=>{})}}); const done=await run(SH,{args:['-c','exit'],input:never}); assert(done.success,'input cancellation does not await source cancel'); "#, ); } #[test] fn synchronous_and_asynchronous_pumps_do_not_deadlock_on_full_pipes() { pass( r#" import {run,runSync} from 'avril:std'; const input=new Uint8Array(300000).fill(97); input[input.length-1]=10; const script='printf "%0300000d" 0; printf "%0300000d" 0 >&2; IFS= read -r value; printf "%s" "$value"'; for(const method of [runSync,run]) { const output=await method(SH,{args:['-c',script],input,maxCollectedBytes:1000000}); assert(output.stdout.length===599999 && output.stderr.length===300000,'concurrent independent pumps'); } "#, ); } #[test] fn capture_and_unread_queue_limits_preserve_bounded_partial_output() { pass( r#" import {run,runSync,spawn,OutputLimitError} from 'avril:std'; for(const method of [run,runSync]) { const error=await rejects(()=>method(SH,{args:['-c','printf abcdefghij; printf klmnopqrst >&2'],maxCollectedBytes:13,termination:{graceMs:10}}),'OutputLimitError'); assert(error instanceof OutputLimitError && error.result.reason==='output-limit','limit reason'); assert(error.result.stdout.length+error.result.stderr.length===13,'combined capture bound'); } const child=spawn(SH,{args:['-c','printf "%080000d" 0'],maxBufferedBytes:128,termination:{graceMs:10}}); const status=await child.completed; assert(status.reason==='output-limit','native unread overflow without a JS consumer'); const error=await rejects(()=>child.output(),'OutputLimitError'); assert(error.result.stdout.length<=128,'unread prefix bound'); "#, ); } #[test] fn native_timeouts_abort_and_forced_termination_share_cleanup() { pass( r#" import {run,runSync,spawn} from 'avril:std'; const stubborn=['-c','trap "" TERM; printf started; while :; do :; done']; for(const method of [run,runSync]) { const error=await rejects(()=>method(SH,{args:stubborn,timeoutMs:50,termination:{graceMs:20},encoding:'utf-8'}),'ProcessTimeoutError'); assert(error.result.signal==='SIGKILL' && error.result.stdout==='started','native escalation and partial output'); } const control=new AbortController(), marker={marker:1}; const task=run(SH,{args:stubborn,signal:control.signal,termination:{graceMs:20}}); control.abort(marker); const error=await rejects(()=>task,'ProcessAbortError'); assert(error.cause===marker && error.result.reason==='abort','abort cause'); const already=AbortSignal.abort(marker); await rejects(()=>run(SH,{signal:already}),'ProcessAbortError'); const child=spawn(SH,{args:stubborn,termination:{graceMs:20}}); const one=child.terminate(),two=child.terminate(); assert(one===two && (await one).reason==='terminated','idempotent terminate'); await child[Symbol.asyncDispose](); await child[Symbol.asyncDispose](); assert(!child.kill(),'disposed'); "#, ); } #[test] fn status_is_distinct_from_bounded_descendant_pipe_completion() { pass( r#" import {spawn} from 'avril:std'; const child=spawn(SH,{args:['-c','"$1" -c "while :; do :; done" & printf prefix; exit','sh',SH],drainTimeoutMs:40,encoding:'utf-8'}); const status=await child.status; assert(status.code===0 && status.reason===null,'leader exit'); const completed=await child.completed; assert(completed.reason==='drain-timeout','bounded inherited pipes'); const error=await rejects(()=>child.output(),'ProcessError'); assert(error.result.stdout==='prefix','drain preserves output'); assert(!child.kill(),'no signaling exited leader'); "#, ); } #[test] fn streaming_stdio_byob_merge_and_consumption_ownership() { pass( r#" import {spawn} from 'avril:std'; const child=spawn(SH,{args:['-c','IFS= read -r line; printf "%s" "$line"; printf err >&2'],stdin:'piped',stderr:'stdout'}); assert(child.stderr===null,'merged stderr'); const writer=child.stdin.getWriter(); await writer.write(new TextEncoder().encode('byob\n')); await writer.close(); const reader=child.stdout.getReader({mode:'byob'}); let text=''; while(true){const next=await reader.read(new Uint8Array(3)); if(next.done)break;text+=new TextDecoder().decode(next.value)} assert(text==='byoberr' && (await child.completed).success,'BYOB and merge'); reader.releaseLock(); await rejects(()=>child.output(),'TypeError'); const canceled=spawn(SH,{args:['-c','printf "%0200000d" 0'],maxBufferedBytes:32}); await canceled.stdout.cancel(); await canceled.stderr.cancel(); assert((await canceled.completed).success,'canceled streams keep native pipes draining'); "#, ); } #[test] fn path_search_uses_effective_environment_and_can_be_disabled() { let fixture = Fixture::new(); fixture.script("local-tool", "printf local"); let path = serde_json::to_string(fixture.dir.path()).unwrap(); fixture.pass(&format!(r#" import {{run}} from 'avril:std'; const env={{PATH:{path}}}; assert((await run('local-tool',{{env,clearEnv:true,encoding:'utf-8'}})).stdout==='local','child PATH'); assert((await run('local-tool',{{searchPath:false,env:{{PATH:'/missing'}},encoding:'utf-8'}})).stdout==='local','bare pathname'); assert((await run('./local-tool',{{clearEnv:true,encoding:'utf-8'}})).stdout==='local','explicit bypasses PATH'); await rejects(()=>run('sh',{{clearEnv:true}}),'NotFound'); await rejects(()=>run('sh',{{searchPath:false,env:{{PATH:{path}}}}}),'NotFound'); assert((await run('local-tool',{{cwd:{path},env:{{PATH:'.'}},clearEnv:true,encoding:'utf-8'}})).stdout==='local','relative child PATH'); "#),&[]); } #[test] fn a_denied_first_path_match_does_not_search_for_an_allowed_replacement() { let fixture = Fixture::new(); fs::create_dir(fixture.dir.path().join("first")).unwrap(); fs::create_dir(fixture.dir.path().join("second")).unwrap(); fixture.script("first/tool", "printf forbidden"); fixture.script("second/tool", "printf allowed"); fixture.pass(r#" import {run} from 'avril:std'; await rejects(()=>run('tool',{clearEnv:true,env:{PATH:'first:second'}}),'PermissionDenied'); assert((await run('./second/tool',{encoding:'utf-8'})).stdout==='allowed','allowed replacement only explicitly'); "#,&["--deny-run=first/tool".into()]); } #[test] fn run_capabilities_are_independent_exact_and_cleanup_survives_revocation() { let fixture = Fixture::new(); let grant = format!("--allow-run={}", shell().display()); fixture.pass(r#" import {run,spawn,pledge} from 'avril:std'; assert((await run(SH,{args:['-c','printf native'],encoding:'utf-8'})).stdout==='native','native run does not need read'); const child=spawn(SH,{args:['-c','while :; do :; done'],termination:{graceMs:10}}); pledge({run:false}); await rejects(()=>run(SH),'PermissionDenied'); assert((await child.terminate()).reason==='terminated','retained cleanup authority'); "#,&["-0".into(),grant]); fixture.pass( r#"import {run} from 'avril:std'; await rejects(()=>run(SH),'PermissionDenied');"#, &[ "-0".into(), format!("--allow-read={}", shell().display()), format!("--allow-js-exec={}", shell().display()), ], ); } #[test] fn inherited_environment_is_filtered_runtime_local_and_explicit_values_need_no_read_grant() { let fixture = Fixture::new(); fixture.pass(r#" import {env,run,pledge} from 'avril:std'; env.set('AVRIL_PROCESS_LOCAL','local'); const out=await run(SH,{args:['-c','printf "%s:%s" "${AVRIL_PROCESS_SECRET-unset}" "$AVRIL_PROCESS_LOCAL"'],encoding:'utf-8'}); assert(out.stdout==='unset:local','filtered runtime-local inherited environment'); pledge({env:false}); const explicit=await run(SH,{args:['-c','printf "%s" "$AVRIL_PROCESS_SECRET"'],env:{AVRIL_PROCESS_SECRET:'explicit'},encoding:'utf-8'}); assert(explicit.stdout==='explicit','explicit values bypass parent read permissions'); await rejects(()=>run('sh'),'NotFound'); "#,&["--deny-env=AVRIL_PROCESS_SECRET".into()]); } #[test] fn file_redirection_modes_borrowed_handles_and_current_permissions() { let fixture = Fixture::new(); fs::write(fixture.dir.path().join("input"), "data\n").unwrap(); fixture.pass( r#" import {run,runSync,open,pledge} from 'avril:std'; import {readTextFile} from 'avril:fs'; const script='IFS= read -r line; printf "%s" "$line"'; const first=await run(SH,{args:['-c',script],stdin:{file:'input'},stdout:{file:'output'}}); assert(first.stdout===null && await readTextFile('output')==='data','secure direct files'); runSync(SH,{args:['-c','printf plus'],stdout:{file:'output',mode:'append'}}); assert(await readTextFile('output')==='dataplus','append'); await rejects(()=>run(SH,{stdout:{file:'output',mode:'create-new'}}),'AlreadyExists'); const handle=await open('output',{read:true,write:true}); await handle.seek(0,0); await run(SH,{args:['-c','printf borrowed'],stdout:{handle}}); assert(await handle.seek(0,1)===8,'borrowed cursor'); pledge({write:false}); await rejects(()=>run(SH,{stdout:{handle}}),'PermissionDenied'); await handle.close(); "#, &[], ); assert_eq!( fs::read(fixture.dir.path().join("output")).unwrap(), b"borrowed" ); } #[test] fn executable_authorization_precedes_redirection_mutation() { let fixture = Fixture::new(); fs::write(fixture.dir.path().join("kept"), "keep").unwrap(); fixture.pass(r#"import {run} from 'avril:std';await rejects(()=>run(SH,{stdout:{file:'kept'}}),'PermissionDenied');"#,&[format!("--deny-run={}",shell().display())]); assert_eq!(fs::read(fixture.dir.path().join("kept")).unwrap(), b"keep"); } #[test] fn pinned_shebang_scripts_and_plain_text_have_no_shell_fallback() { let fixture = Fixture::new(); fixture.script("script", "printf pinned"); fs::write(fixture.dir.path().join("plain"), "printf implicit").unwrap(); fs::set_permissions( fixture.dir.path().join("plain"), fs::Permissions::from_mode(0o755), ) .unwrap(); fixture.pass(r#" import {run} from 'avril:std'; assert((await run(new URL('./script',import.meta.url),{encoding:'utf-8'})).stdout==='pinned','pinned script'); try{await run('./plain');throw Error('fallback')}catch(error){assert(error.name==='IoError','exec format error')} "#,&["-0".into(),"--allow-run=script".into(),"--allow-run=plain".into()]); } #[test] fn additional_descriptors_are_declared_pipes_not_arbitrary_runtime_fds() { pass( r#" import {spawn} from 'avril:std'; const 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'}); const writer=child.stdio[3].getWriter(); await writer.write(new TextEncoder().encode('extra\n')); await writer.close(); const extra=await new Response(child.stdio[4]).text(); assert(extra==='extra','extra pipe'); assert((await child.output()).stdout==='standard','standard pipe'); await rejects(()=>spawn(SH,{extraStdio:{3:'inherit'}}),'TypeError'); "#, ); } #[test] fn terminal_is_real_combined_resizable_and_explicit() { pass( r#" import {spawn,run} from 'avril:std'; const 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'}); assert(child.terminal && child.stderr===null,'explicit terminal'); child.terminal.resize(100,40); const writer=child.terminal.writable.getWriter(); await writer.write(new TextEncoder().encode('terminal\n')); const out=await child.output(); assert(out.stdout.includes('tty:terminal') && out.success,'isatty and terminal IO'); await writer.close(); await rejects(()=>run(SH,{terminal:{}}),'TypeError'); "#, ); } #[test] fn unref_does_not_keep_runtime_alive_and_owner_shutdown_kills_children() { let fixture = Fixture::new(); let start = Instant::now(); let output=fixture.pass(r#" import {spawn} from 'avril:std'; const child=spawn(SH,{args:['-c','while :; do :; done'],stdout:'null',stderr:'null',termination:{graceMs:10}}); console.log(child.pid); child.unref(); "#,&[]); assert!(start.elapsed() < Duration::from_secs(3)); let pid = String::from_utf8(output.stdout) .unwrap() .trim() .parse::() .unwrap(); assert_eq!( unsafe { libc::kill(pid, 0) }, -1, "owned child survived isolate teardown" ); } #[test] fn child_handles_are_branded_uncloneable_and_validate_before_launch() { pass( r#" import {spawn,ChildProcess,run,runSync} from 'avril:std'; await rejects(()=>new ChildProcess(),'TypeError'); const child=spawn(SH,{args:['-c','exit']}); await rejects(()=>structuredClone(child),'DataCloneError'); await rejects(()=>ChildProcess.prototype.kill.call({}),'TypeError'); await child.completed; for(const options of [{searchPath:0},{searchpath:false},{maxCollectedBytes:0},{timeoutMs:-1},{input:new Uint16Array(2)},{env:{'BAD=NAME':'x'}},{args:['\0']},{termination:{scope:'unsafe'}}]) { await rejects(()=>run(SH,options), options.maxCollectedBytes===0 || options.timeoutMs===-1 ? 'RangeError' : options.env || options.args ? 'InvalidInput' : 'TypeError'); } await rejects(()=>runSync(SH,{signal:new AbortController().signal}),'TypeError'); "#, ); } #[test] fn worker_run_sync_teardown_is_prompt() { let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {runSync} from 'avril:std'; try { runSync('sleep', {args:['30']}); } catch {} "#, ) .unwrap(); // The harness fails after five seconds, so a wedged teardown fails loudly. let output = fixture.pass( r#" const worker = new Worker('./worker.js', {type:'module'}); await new Promise(resolve => setTimeout(resolve, 400)); worker.terminate(); console.log('terminated'); "#, &[], ); assert_eq!(output.stdout, b"terminated\n"); } #[test] fn multicall_entries_are_distinct_authorities() { let fixture = Fixture::new(); fixture.script("real-tool", "printf tool"); symlink("real-tool", fixture.dir.path().join("alias-tool")).unwrap(); fixture.pass( r#" import {run} from 'avril:std'; assert((await run('./real-tool',{encoding:'utf-8'})).stdout==='tool','granted entry'); await rejects(()=>run('./alias-tool'),'PermissionDenied'); "#, &["-0".into(), "--allow-run=real-tool".into()], ); fixture.pass( r#" import {run} from 'avril:std'; assert((await run('./alias-tool',{encoding:'utf-8'})).stdout==='tool','directory grant covers entries'); "#, &["-0".into(), "--allow-run=./".into()], ); } #[cfg(target_os = "macos")] #[test] fn respelled_entries_meet_the_same_grants() { let fixture = Fixture::new(); fixture.script("tool", "printf tool"); // Respelling only reaches the same entry on case-insensitive volumes. if !fixture.dir.path().join("TOOL").exists() { return; } fixture.pass( r#"import {run} from 'avril:std'; await rejects(()=>run('./TOOL'),'PermissionDenied');"#, &["--deny-run=tool".into()], ); fixture.pass( r#"import {run} from 'avril:std'; assert((await run('./Tool',{encoding:'utf-8'})).stdout==='tool','respelled grant');"#, &["-0".into(), "--allow-run=TOOL".into()], ); } #[test] fn early_stdin_close_is_benign() { pass( r#" import {run,runSync} from 'avril:std'; const input = new Uint8Array(70000).fill(65); for (const method of [run, runSync]) { const early = await method(SH, {args:['-c','exit 3'], input, check:false}); assert(early.code === 3, 'exit without reading stdin'); const headed = await method(SH, {args:['-c','head -c 1'], input, encoding:'utf-8'}); assert(headed.stdout === 'A', 'partial read'); } "#, ); } #[test] fn only_declared_descriptors_survive_exec() { pass( r#" import {spawn,run} from 'avril:std'; const holder = spawn(SH, {args:['-c','sleep 30'], stdout:'null', stderr:'null', extraStdio:{3:{pipe:'parent-to-child'}}}); const out = await run(SH, {args:['-c','ls /dev/fd'], encoding:'utf-8'}); for (const name of out.stdout.trim().split('\n')) { assert(Number(name) <= 3, 'leaked descriptor '+name); } await holder.terminate(); "#, ); } #[test] fn foreign_descriptors_do_not_survive_exec() { let fixture = Fixture::new(); fs::write( fixture.dir.path().join("main.js"), format!( r#" const SH={}; import {{run}} from 'avril:std'; const out = await run(SH, {{args:['-c','ls /dev/fd'], encoding:'utf-8'}}); for (const name of out.stdout.trim().split('\n')) {{ if (Number(name) > 3) throw Error('leaked descriptor '+name); }} "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); // dup2 clears CLOEXEC, so fd 5 reaches avril as a foreign descriptor that // subprocess descriptor isolation must remove. let probe = fs::File::open("/dev/null").unwrap(); let fd = probe.as_raw_fd(); let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .arg("run") .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); unsafe { command.pre_exec(move || { libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); if libc::dup2(fd, 5) < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) }); } let output = common::wait(command.spawn().unwrap()); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn first_signal_exits_when_nothing_owns_it() { // A prior spawn must not swallow a lone signal: with no live child and // no owner (run mode), SIGTERM/SIGINT stop children and exit 143/130. use std::os::unix::process::ExitStatusExt; for signal in [libc::SIGTERM, libc::SIGINT] { let fixture = Fixture::new(); fs::write( fixture.dir.path().join("main.js"), format!( r#" const SH={}; import {{runSync}} from 'avril:std'; runSync(SH, {{args:['-c','exit 0']}}); console.log('ready'); setInterval(()=>{{}}, 1000); "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("run") .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); // Wait for the spawn to complete so the signal exercises the // supervisor rather than the default disposition. let mut ready = String::new(); std::io::BufRead::read_line( &mut std::io::BufReader::new(child.stdout.take().unwrap()), &mut ready, ) .unwrap(); assert_eq!(ready.trim(), "ready"); assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(signal), "signal {signal}: {}", String::from_utf8_lossy(&output.stderr) ); } } #[test] fn inherited_signal_ignore_is_preserved() { // A process launched with SIGINT/SIGHUP ignored keeps those fates: // the supervisor must not take over the signals, even when binds come // and go before it installs, and children must keep the ignores. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("main.js"), format!( r#" const SH={}; import {{runSync, addSignalListener, removeSignalListener}} from 'avril:std'; // Binds that come and go must not move the startup baseline: // the supervisor installs after they are gone. const onInt = () => {{}}; addSignalListener('SIGINT', onInt); removeSignalListener('SIGINT', onInt); const onHup = () => {{}}; addSignalListener('SIGHUP', onHup); removeSignalListener('SIGHUP', onHup); runSync(SH, {{args:['-c','exit 0']}}); // Children keep the startup ignores too: these self-kills // survive only through an inherited SIG_IGN. const hup = runSync(SH, {{args:['-c','kill -HUP $$; echo survived']}}); console.log('child-hup ' + hup.code + '/' + hup.signal); const int = runSync(SH, {{args:['-c','kill -INT $$; echo survived']}}); console.log('child-int ' + int.code + '/' + int.signal); console.log('ready'); setInterval(()=>{{}}, 1000); "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .arg("run") .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_IGN); libc::signal(libc::SIGHUP, libc::SIG_IGN); Ok(()) }); } let mut child = command.spawn().unwrap(); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut text = String::new(); std::io::BufRead::read_line(&mut stdout, &mut text).unwrap(); assert_eq!(text.trim(), "child-hup 0/null", "{text}"); text.clear(); std::io::BufRead::read_line(&mut stdout, &mut text).unwrap(); assert_eq!(text.trim(), "child-int 0/null", "{text}"); text.clear(); std::io::BufRead::read_line(&mut stdout, &mut text).unwrap(); assert_eq!(text.trim(), "ready", "{text}"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0); std::thread::sleep(Duration::from_millis(300)); assert!( child.try_wait().unwrap().is_none(), "ignored SIGINT killed the process" ); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let output = common::wait(child); use std::os::unix::process::ExitStatusExt; assert_eq!(output.status.signal(), Some(libc::SIGTERM)); } #[test] fn headless_group_timeouts_reach_grandchildren() { let fixture = Fixture::new(); fs::write( fixture.dir.path().join("main.js"), format!( r#" import {{spawn}} from 'avril:std'; const child = spawn({}, {{args:['-c','sleep 303 & echo $! > pid.txt; wait'], stdout:'null', stderr:'inherit', timeoutMs:300}}); const status = await child.completed; if (status.reason !== 'timeout') throw Error('expected timeout'); "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .args(["run", "main.js"]) .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::null()) // A leaked descendant must not hold a capture-thread pipe open. .stderr(fs::File::create(fixture.dir.path().join("stderr.txt")).unwrap()); unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); if libc::setsid() < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) }); } let output = common::wait(command.spawn().unwrap()); let pid: i32 = fs::read_to_string(fixture.dir.path().join("pid.txt")) .unwrap() .trim() .parse() .unwrap(); let alive = alive(pid); if alive { unsafe { libc::kill(pid, libc::SIGKILL); } } assert!(output.status.success()); assert!(!alive, "grandchild survived the group timeout"); } #[test] fn capability_denials_do_not_disclose_filesystem_existence() { let fixture = Fixture::new(); fixture.pass( r#" import {run} from 'avril:std'; await rejects(()=>run('./missing-tool',{cwd:'/sys/kernel'}),'PermissionDenied'); await rejects(()=>run('missing-tool-xyz'),'PermissionDenied'); "#, &["-0".into(), format!("--allow-run={}", shell().display())], ); pass( r#" import {run} from 'avril:std'; await rejects(()=>run('missing-tool-xyz'),'NotFound'); "#, ); } #[test] fn serve_drains_an_in_flight_subprocess_on_sigterm() { use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; let fixture = Fixture::new(); let socket = fixture.dir.path().join("http.sock"); fs::write( fixture.dir.path().join("main.js"), format!( r#" import {{spawn}} from 'avril:std'; export default {{async fetch() {{ const child = spawn({}, {{args:['-c','sleep 0.3; printf done'], encoding:'utf-8'}}); console.log('child-started'); const output = await child.output(); return new Response(output.stdout); }}}}; "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("serve") .arg(format!("--unix={}", socket.display())) .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut line = String::new(); stderr.read_line(&mut line).unwrap(); assert!(line.starts_with("Listening on "), "{line}"); child.stderr = Some(stderr.into_inner()); let mut connection = UnixStream::connect(&socket).unwrap(); connection .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); connection .write_all(b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n") .unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); line.clear(); stdout.read_line(&mut line).unwrap(); assert_eq!(line.trim(), "child-started"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let mut response = String::new(); connection.read_to_string(&mut response).unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!(response.ends_with("done"), "{response}"); assert!(!socket.exists(), "socket left behind after shutdown"); } #[test] fn unowned_signal_shutdown_kills_grace_resisting_descendants() { use std::io::{BufRead, BufReader}; use std::os::unix::process::ExitStatusExt; for repeat in [false, true] { let fixture = Fixture::new(); fs::write( fixture.dir.path().join("main.js"), format!( r#" import {{spawn}} from 'avril:std'; const child = spawn({}, {{args:['-c',"trap '' TERM; sleep 304 & echo $!; wait"], stdin:'null', stderr:'null', termination:{{graceMs:10000}}}}); const reader = child.stdout.getReader(); console.log(new TextDecoder().decode((await reader.read()).value).trim()); setInterval(()=>{{}},1000); "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .args(["run", "main.js"]) .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut line = String::new(); stdout.read_line(&mut line).unwrap(); let grandchild: i32 = line.trim().parse().unwrap(); child.stdout = Some(stdout.into_inner()); let started = Instant::now(); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); if repeat { std::thread::sleep(Duration::from_millis(150)); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); } let output = common::wait(child); let alive = alive(grandchild); if alive { unsafe { libc::kill(grandchild, libc::SIGKILL); } } assert_eq!(output.status.signal(), Some(libc::SIGTERM)); assert!(!alive, "grandchild survived owner shutdown"); if repeat { assert!( started.elapsed() < Duration::from_secs(1), "repeat did not shorten shutdown" ); } } } #[test] fn repeated_server_shutdown_signals_stop_referenced_subprocesses() { use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; for signal in [libc::SIGTERM, libc::SIGINT] { let fixture = Fixture::new(); let socket = fixture.dir.path().join("http.sock"); fs::write( fixture.dir.path().join("main.js"), format!( r#" import {{spawn}} from 'avril:std'; const sidecar = spawn({}, {{args:['-c','exec sleep 30'],stdin:'null',stdout:'null',stderr:'null'}}); console.log(sidecar.pid); export default {{async fetch() {{ console.log('request-started'); await new Promise(resolve=>setTimeout(resolve,10000)); return new Response('done'); }}}}; "#, serde_json::to_string(&shell()).unwrap() ), ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("serve") .arg(format!("--unix={}", socket.display())) .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut line = String::new(); stderr.read_line(&mut line).unwrap(); assert!(line.starts_with("Listening on "), "{line}"); child.stderr = Some(stderr.into_inner()); let mut stdout = BufReader::new(child.stdout.take().unwrap()); line.clear(); stdout.read_line(&mut line).unwrap(); let sidecar: i32 = line.trim().parse().unwrap(); let mut connection = UnixStream::connect(&socket).unwrap(); connection .write_all(b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n") .unwrap(); line.clear(); stdout.read_line(&mut line).unwrap(); assert_eq!(line.trim(), "request-started"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); std::thread::sleep(Duration::from_millis(150)); assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); let started = Instant::now(); while child.try_wait().unwrap().is_none() && started.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(20)); } let hung = child.try_wait().unwrap().is_none(); if hung { child.kill().unwrap(); } let output = child.wait_with_output().unwrap(); assert!( !hung, "signal {signal}: sidecar alive={} stderr={}", unsafe { libc::kill(sidecar, 0) } == 0, String::from_utf8_lossy(&output.stderr) ); assert_eq!( unsafe { libc::kill(sidecar, 0) }, -1, "sidecar survived forced shutdown" ); } } #[cfg(target_os = "linux")] #[test] fn descriptor_fallback_handles_unavailable_syscalls_and_lowered_limits() { for error in [libc::ENOSYS, libc::EINVAL, libc::EPERM] { let fixture = Fixture::new(); fixture.script("script", "printf pinned"); fs::write(fixture.dir.path().join("plain"), "not an executable format").unwrap(); fs::set_permissions( fixture.dir.path().join("plain"), fs::Permissions::from_mode(0o755), ) .unwrap(); fs::write( fixture.dir.path().join("main.js"), format!( r#" const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}}; import {{run,runSync,spawn}} from 'avril:std'; for (const method of [run,runSync]) {{ const out=await method(SH,{{args:['-c','ls /proc/self/fd'],encoding:'utf-8'}}); for (const name of out.stdout.trim().split('\n')) {{ assert(Number(name)<=3,'foreign descriptor leaked: '+name); }} assert((await method('./script',{{encoding:'utf-8'}})).stdout==='pinned','shebang exec'); try {{ await method('./plain'); throw Error('exec accepted plain text'); }} catch (error) {{assert(error.name==='IoError','missing exec failure: '+error)}} }} const 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'}}); const writer=child.stdio[3].getWriter(); await writer.write(new TextEncoder().encode('extra\n')); await writer.close(); assert((await new Response(child.stdio[4]).text())==='extra','declared descriptor lost'); assert((await child.output()).stdout==='standard','standard output'); "#, serde_json::to_string(&shell()).unwrap() ), ).unwrap(); let probe = fs::File::open("/dev/null").unwrap(); let fd = probe.as_raw_fd(); let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .args(["run", "main.js"]) .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); unsafe { command.pre_exec(move || { libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); // Inherit a descriptor beyond the *new* soft limit: a bounded // fcntl loop using RLIMIT_NOFILE would miss it. if libc::dup2(fd, 200) < 0 { return Err(std::io::Error::last_os_error()); } let mut limit: libc::rlimit = std::mem::zeroed(); if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) < 0 { return Err(std::io::Error::last_os_error()); } limit.rlim_cur = 128; if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) < 0 { return Err(std::io::Error::last_os_error()); } let mut filter = [ libc::sock_filter { code: (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16, jt: 0, jf: 0, k: 0, }, libc::sock_filter { code: (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16, jt: 0, jf: 1, k: libc::SYS_close_range as u32, }, libc::sock_filter { code: (libc::BPF_RET | libc::BPF_K) as u16, jt: 0, jf: 0, k: libc::SECCOMP_RET_ERRNO | error as u32, }, libc::sock_filter { code: (libc::BPF_RET | libc::BPF_K) as u16, jt: 0, jf: 0, k: libc::SECCOMP_RET_ALLOW, }, ]; let program = libc::sock_fprog { len: filter.len() as u16, filter: filter.as_mut_ptr(), }; if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0 || libc::prctl(libc::PR_SET_SECCOMP, libc::SECCOMP_MODE_FILTER, &program) < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) }); } let output = common::wait(command.spawn().unwrap()); assert!( output.status.success(), "errno {error}: {}", String::from_utf8_lossy(&output.stderr) ); } } /// Reads one stdout line from an interacted child. fn line(reader: &mut std::io::BufReader) -> String { let mut text = String::new(); std::io::BufRead::read_line(reader, &mut text).unwrap(); text.trim_end().to_owned() } #[test] fn signal_listeners_deliver_in_order_to_many_listeners() { let fixture = Fixture::new(); let mut child = fixture.interact( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; const seen = []; const first = () => { seen.push('usr1'); console.log('saw usr1'); }; const second = () => seen.push('usr1b'); const other = () => { seen.push('usr2'); console.log('done ' + JSON.stringify(seen)); removeSignalListener('SIGUSR1', first); removeSignalListener('SIGUSR1', second); removeSignalListener('SIGUSR2', other); }; addSignalListener('SIGUSR1', first); addSignalListener('SIGUSR1', second); addSignalListener('SIGUSR2', other); console.log('ready'); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); assert_eq!(line(&mut stdout), "saw usr1"); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR2) }, 0); let done = line(&mut stdout); child.stdout = Some(stdout.into_inner()); // Both USR1 listeners fire in binding order before the USR2 listener. assert_eq!(done, r#"done ["usr1","usr1b","usr2"]"#, "{done}"); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn signal_listener_removal_stops_delivery() { let fixture = Fixture::new(); let mut child = fixture.interact( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; let count = 0; const keep = () => { count++; console.log('kept ' + count); if (count === 2) { console.log('final'); removeSignalListener('SIGUSR1', keep); } }; const drop = () => { count += 10; }; addSignalListener('SIGUSR1', keep); addSignalListener('SIGUSR1', keep); addSignalListener('SIGUSR1', drop); removeSignalListener('SIGUSR1', drop); removeSignalListener('SIGUSR1', () => {}); console.log('ready'); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); // The duplicate add fires once per delivery, and the removed listener // never fires: no jump by ten. assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); assert_eq!(line(&mut stdout), "kept 1"); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); assert_eq!(line(&mut stdout), "kept 2"); assert_eq!(line(&mut stdout), "final"); child.stdout = Some(stdout.into_inner()); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn listened_sigterm_survives_then_kills_once_removed() { use std::os::unix::process::ExitStatusExt; let fixture = Fixture::new(); let mut child = fixture.interact( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; const onTerm = () => { removeSignalListener('SIGTERM', onTerm); console.log('survived'); }; addSignalListener('SIGTERM', onTerm); console.log('ready'); setInterval(() => {}, 1000); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); // A listened SIGTERM reaches JS instead of the supervisor's death path. assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); assert_eq!(line(&mut stdout), "survived"); child.stdout = Some(stdout.into_inner()); // With no listeners left the prior fate returns. assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGTERM), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn unbound_signals_restore_default_death() { use std::os::unix::process::ExitStatusExt; // A signal nobody ever binds keeps its default disposition. let fixture = Fixture::new(); let mut child = fixture.interact(r#"console.log('ready'); setInterval(() => {}, 1000);"#); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); let output = common::wait(child); assert_eq!(output.status.signal(), Some(libc::SIGUSR1)); // So does a signal whose last listener went away: the feed emulates the // prior fate instead of swallowing the delivery. let fixture = Fixture::new(); let mut child = fixture.interact( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; const onUsr = () => { removeSignalListener('SIGUSR1', onUsr); console.log('unbound'); }; addSignalListener('SIGUSR1', onUsr); console.log('ready'); setInterval(() => {}, 1000); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); assert_eq!(line(&mut stdout), "unbound"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGUSR1), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn signal_listener_validation_rejects_bad_signals() { pass( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; await rejects(() => addSignalListener('SIGFOO', () => {}), 'InvalidInput'); await rejects(() => addSignalListener('', () => {}), 'InvalidInput'); await rejects(() => addSignalListener('SIGKILL', () => {}), 'InvalidInput'); await rejects(() => addSignalListener('SIGSTOP', () => {}), 'InvalidInput'); await rejects(() => addSignalListener('SIGQUIT', () => {}), 'NotSupported'); await rejects(() => addSignalListener('SIGPIPE', () => {}), 'NotSupported'); await rejects(() => addSignalListener('SIGUSR1'), 'TypeError'); await rejects(() => addSignalListener('SIGUSR1', 42), 'TypeError'); await rejects(() => addSignalListener(Symbol('sig'), () => {}), 'TypeError'); removeSignalListener('SIGUSR1', () => {}); removeSignalListener('SIGFOO', () => {}); "#, ); } #[test] fn serve_sigterm_runs_listeners_while_draining() { use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; let fixture = Fixture::new(); let socket = fixture.dir.path().join("http.sock"); fs::write( fixture.dir.path().join("main.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGTERM', () => console.log('listener-ran')); export default {async fetch() { console.log('child-started'); await new Promise(resolve => setTimeout(resolve, 300)); return new Response('done'); }}; "#, ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("serve") .arg(format!("--unix={}", socket.display())) .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut text = String::new(); stderr.read_line(&mut text).unwrap(); assert!(text.starts_with("Listening on "), "{text}"); child.stderr = Some(stderr.into_inner()); let mut connection = UnixStream::connect(&socket).unwrap(); connection .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); connection .write_all(b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n") .unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); text.clear(); stdout.read_line(&mut text).unwrap(); assert_eq!(text.trim(), "child-started"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let mut response = String::new(); connection.read_to_string(&mut response).unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!(response.ends_with("done"), "{response}"); // The listener observes the owned shutdown rather than replacing it. let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("listener-ran"), "{stdout}"); assert!(!socket.exists(), "socket left behind after shutdown"); } #[test] fn serve_sigint_runs_listeners_while_draining() { use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; let fixture = Fixture::new(); let socket = fixture.dir.path().join("http.sock"); fs::write( fixture.dir.path().join("main.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGINT', () => console.log('int-listener-ran')); export default {async fetch() { console.log('child-started'); await new Promise(resolve => setTimeout(resolve, 300)); return new Response('done'); }}; "#, ) .unwrap(); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("serve") .arg(format!("--unix={}", socket.display())) .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut text = String::new(); stderr.read_line(&mut text).unwrap(); assert!(text.starts_with("Listening on "), "{text}"); child.stderr = Some(stderr.into_inner()); let mut connection = UnixStream::connect(&socket).unwrap(); connection .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); connection .write_all(b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n") .unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); text.clear(); stdout.read_line(&mut text).unwrap(); assert_eq!(text.trim(), "child-started"); child.stdout = Some(stdout.into_inner()); // A top-level listener observes the owned shutdown: the claim shares // one chaining registry instead of clobbering the listener's feed. assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0); let mut response = String::new(); connection.read_to_string(&mut response).unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!(response.ends_with("done"), "{response}"); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("int-listener-ran"), "{stdout}"); assert!(!socket.exists(), "socket left behind after shutdown"); } #[test] fn listened_sigterm_suppresses_installed_supervisor() { use std::os::unix::process::ExitStatusExt; // Unlike the emulation-only variant, the supervisor installs first // (via spawn), then the listener binds: suppression must still win. let fixture = Fixture::new(); let mut child = fixture.interact( r#" import {runSync, addSignalListener, removeSignalListener} from 'avril:std'; runSync(SH, {args:['-c','exit 0']}); const onTerm = () => { removeSignalListener('SIGTERM', onTerm); console.log('survived'); }; addSignalListener('SIGTERM', onTerm); console.log('ready'); setInterval(() => {}, 1000); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); assert_eq!(line(&mut stdout), "ready"); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); assert_eq!(line(&mut stdout), "survived"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGTERM), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn jailed_worker_bindings_do_not_suppress_death() { use std::os::unix::process::ExitStatusExt; // A worker with every pledgeable capability but jsExec taken away // binds INT and TERM: with no entry listener, neither delivery is // suppressed, and SIGKILL is never needed. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGINT', () => {}); addSignalListener('SIGTERM', () => {}); postMessage('worker-ready'); setInterval(() => {}, 1000); "#, ) .unwrap(); let entry = r#" import {runSync, avril} from 'avril:std'; runSync(SH, {args:['-c','exit 0']}); const worker = new Worker('./worker.js', {type:'module', [avril]: {caps: {net: false, read: false, write: false, env: false, run: false}}}); worker.onmessage = (event) => console.log(event.data); console.log('ready'); setInterval(() => {}, 1000); "#; // SIGINT kills despite the worker's binding. let mut child = fixture.interact(entry); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGINT), "{}", String::from_utf8_lossy(&output.stderr) ); // So does SIGTERM. let mut child = fixture.interact(entry); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGTERM), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn worker_receives_while_entry_suppresses() { use std::os::unix::process::ExitStatusExt; // Broadcast is unchanged: the worker observes the USR1 and TERM the // entry listener survives. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGUSR1', () => postMessage('worker-got-usr1')); addSignalListener('SIGTERM', () => postMessage('worker-got-term')); postMessage('worker-ready'); "#, ) .unwrap(); let mut child = fixture.interact( r#" import {addSignalListener} from 'avril:std'; const worker = new Worker('./worker.js', {type:'module'}); worker.onmessage = (event) => console.log(event.data); addSignalListener('SIGUSR1', () => console.log('entry-got-usr1')); addSignalListener('SIGTERM', () => console.log('entry-got-term')); console.log('ready'); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); let mut second = [line(&mut stdout), line(&mut stdout)]; second.sort(); assert_eq!(second, ["entry-got-usr1", "worker-got-usr1"]); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let mut third = [line(&mut stdout), line(&mut stdout)]; third.sort(); assert_eq!(third, ["entry-got-term", "worker-got-term"]); child.stdout = Some(stdout.into_inner()); child.kill().unwrap(); let output = common::wait(child); assert_eq!(output.status.signal(), Some(libc::SIGKILL)); } #[test] fn entry_unbind_while_worker_bound_restores_death() { use std::os::unix::process::ExitStatusExt; // The entry listener holds off USR1 once, then goes away: the next // delivery kills, though the worker stays bound throughout. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGUSR1', () => {}); postMessage('worker-ready'); setInterval(() => {}, 1000); "#, ) .unwrap(); let mut child = fixture.interact( r#" import {addSignalListener, removeSignalListener} from 'avril:std'; const worker = new Worker('./worker.js', {type:'module'}); worker.onmessage = (event) => console.log(event.data); const onUsr = () => { removeSignalListener('SIGUSR1', onUsr); console.log('unbound'); }; addSignalListener('SIGUSR1', onUsr); console.log('ready'); setInterval(() => {}, 1000); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); assert_eq!(line(&mut stdout), "unbound"); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0); let output = common::wait(child); assert_eq!( output.status.signal(), Some(libc::SIGUSR1), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn worker_only_sigwinch_is_received() { use std::os::unix::process::ExitStatusExt; // SIGWINCH never kills, so a worker-only binding hears it with no // entry listener at all. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGWINCH', () => postMessage('worker-got-winch')); postMessage('worker-ready'); "#, ) .unwrap(); let mut child = fixture.interact( r#" const worker = new Worker('./worker.js', {type:'module'}); worker.onmessage = (event) => console.log(event.data); console.log('ready'); setInterval(() => {}, 1000); "#, ); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGWINCH) }, 0); assert_eq!(line(&mut stdout), "worker-got-winch"); child.stdout = Some(stdout.into_inner()); child.kill().unwrap(); let output = common::wait(child); assert_eq!(output.status.signal(), Some(libc::SIGKILL)); } #[test] fn ignored_hup_reaches_worker_only_listener() { use std::os::unix::process::ExitStatusExt; // An inherited SIG_IGN keeps the process alive, and the worker-only // listener still observes the delivery. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGHUP', () => postMessage('worker-got-hup')); postMessage('worker-ready'); "#, ) .unwrap(); fs::write( fixture.dir.path().join("main.js"), r#" const worker = new Worker('./worker.js', {type:'module'}); worker.onmessage = (event) => console.log(event.data); console.log('ready'); setInterval(() => {}, 1000); "#, ) .unwrap(); let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .arg("run") .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); unsafe { command.pre_exec(|| { libc::signal(libc::SIGHUP, libc::SIG_IGN); libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); Ok(()) }); } let mut child = command.spawn().unwrap(); let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); let mut first = [line(&mut stdout), line(&mut stdout)]; first.sort(); assert_eq!(first, ["ready", "worker-ready"]); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGHUP) }, 0); assert_eq!(line(&mut stdout), "worker-got-hup"); child.stdout = Some(stdout.into_inner()); std::thread::sleep(Duration::from_millis(300)); assert!( child.try_wait().unwrap().is_none(), "ignored SIGHUP killed the process" ); child.kill().unwrap(); let output = common::wait(child); assert_eq!(output.status.signal(), Some(libc::SIGKILL)); } #[test] fn serve_term_drain_reaches_worker_listener() { use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; // Serve owns TERM for its drain; a worker-only listener observes the // delivery while the owned shutdown proceeds. let fixture = Fixture::new(); fs::write( fixture.dir.path().join("worker.js"), r#" import {addSignalListener} from 'avril:std'; addSignalListener('SIGTERM', () => postMessage('worker-got-term')); postMessage('worker-ready'); "#, ) .unwrap(); fs::write( fixture.dir.path().join("main.js"), r#" const worker = new Worker('./worker.js', {type:'module'}); worker.onmessage = (event) => console.log(event.data); console.log('ready'); export default {async fetch() { console.log('child-started'); await new Promise(resolve => setTimeout(resolve, 300)); return new Response('done'); }}; "#, ) .unwrap(); let socket = fixture.dir.path().join("http.sock"); let mut child = inherit_defaults(Command::new(env!("CARGO_BIN_EXE_avril"))) .arg("serve") .arg(format!("--unix={}", socket.display())) .arg("main.js") .current_dir(fixture.dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut text = String::new(); stderr.read_line(&mut text).unwrap(); assert!(text.starts_with("Listening on "), "{text}"); child.stderr = Some(stderr.into_inner()); let mut connection = UnixStream::connect(&socket).unwrap(); connection .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); connection .write_all(b"GET / HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n") .unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut started = [String::new(), String::new(), String::new()]; for line in started.iter_mut() { stdout.read_line(line).unwrap(); } started .iter_mut() .for_each(|line| *line = line.trim().to_owned()); started.sort(); assert_eq!(started, ["child-started", "ready", "worker-ready"]); child.stdout = Some(stdout.into_inner()); assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); let mut response = String::new(); connection.read_to_string(&mut response).unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!(response.ends_with("done"), "{response}"); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("worker-got-term"), "{stdout}"); assert!(!socket.exists(), "socket left behind after shutdown"); }