char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somclean up tests and clippy4d5ee6a

main
77.4 KiB1926 linesraw
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(
21    scope: &std::sync::Arc<avril_subprocess::Scope>,
22    command: &str,
23) -> std::io::Result<std::sync::Arc<avril_subprocess::Process>> {
24    avril_subprocess::Launcher {
25        runtime: tokio::runtime::Handle::current(),
26        caps: avril_caps::Capabilities::new(
27            avril_caps::Initial::new(false),
28            std::env::current_dir().unwrap(),
29        ),
30        owners: Vec::new(),
31    }
32    .spawn(
33        avril_subprocess::Options::new(
34            shell().to_str().unwrap(),
35            vec!["-c".into(), command.into()],
36        ),
37        std::env::vars_os(),
38        Some(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() {
45    let scope = std::sync::Arc::new(
46        avril_subprocess::Scope::new(
47            std::path::Path::new(env!("CARGO_BIN_EXE_avril")),
48            Vec::new(),
49        )
50        .unwrap(),
51    );
52    let first = native_command(&scope, "exit 7").unwrap();
53    assert_eq!(first.wait(true, None).await.unwrap().unwrap().code, Some(7));
54    let second = native_command(&scope, "while :; do :; done").unwrap();
55    drop(scope);
56    let status = tokio::time::timeout(Duration::from_secs(5), second.wait(true, None))
57        .await
58        .unwrap()
59        .unwrap()
60        .unwrap();
61    assert!(!status.success);
62}
63
64#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
65async fn native_group_cannot_spawn_after_close() {
66    let scope = std::sync::Arc::new(
67        avril_subprocess::Scope::new(
68            std::path::Path::new(env!("CARGO_BIN_EXE_avril")),
69            Vec::new(),
70        )
71        .unwrap(),
72    );
73    scope.force_close();
74    scope.force_close();
75    assert!(
76        scope
77            .if_running(|| panic!("published after close"))
78            .is_none()
79    );
80    assert_eq!(
81        native_command(&scope, "exit 0").err().unwrap().kind(),
82        std::io::ErrorKind::Interrupted,
83    );
84}
85
86struct Fixture {
87    dir: 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 {
94    unsafe {
95        command.pre_exec(|| {
96            libc::signal(libc::SIGINT, libc::SIG_DFL);
97            libc::signal(libc::SIGTERM, libc::SIG_DFL);
98            Ok(())
99        });
100    }
101    command
102}
103impl Fixture {
104    fn new() -> Self {
105        Self {
106            dir: tempfile::tempdir().unwrap(),
107        }
108    }
109    fn script(&self, name: &str, body: &str) {
110        let 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    }
114    fn run(&self, source: &str, flags: &[String]) -> Output {
115        let prefix = format!(
116            r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}};
117            async 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(
122            self.dir.path().join("main.js"),
123            format!("{prefix}\n{source}"),
124        )
125        .unwrap();
126        common::wait(
127            inherit_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    }
140    fn pass(&self, source: &str, flags: &[String]) -> Output {
141        let output = self.run(source, flags);
142        assert!(
143            output.status.success(),
144            "{}",
145            String::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.
151    fn interact(&self, source: &str) -> std::process::Child {
152        let prefix = format!(
153            r#"const SH={}; const assert=(value,message)=>{{if(!value)throw Error(message)}};
154            async 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(
159            self.dir.path().join("main.js"),
160            format!("{prefix}\n{source}"),
161        )
162        .unwrap();
163        inherit_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")]
178    return 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    {
183        let deadline = Instant::now() + Duration::from_secs(1);
184        while unsafe { libc::kill(pid, 0) } == 0 {
185            if Instant::now() >= deadline {
186                return true;
187            }
188            std::thread::sleep(Duration::from_millis(10));
189        }
190        false
191    }
192}
193fn pass(source: &str) {
194    Fixture::new().pass(source, &[]);
195}
196
197#[test]
198fn invocation_results_bytes_encoding_nonzero_and_no_implicit_shell() {
199    pass(
200        r#"
201        import {run,runSync,spawn,ProcessError} from 'avril:std';
202        const first=await run(SH,{args:['-c','printf hello; printf problem >&2']});
203        assert(first.code===0 && first.success && first.reason===null,'status');
204        assert(new TextDecoder().decode(first.stdout)==='hello' && new TextDecoder().decode(first.stderr)==='problem','bytes');
205        const decoded=runSync(SH,{args:['-c','printf "%s" "$1"','sh','a; echo injected'],encoding:'utf-8'});
206        assert(decoded.stdout==='a; echo injected','literal argv');
207        const failed=await rejects(()=>run(SH,{args:['-c','printf partial; exit 23'],encoding:'utf-8'}),'ProcessError');
208        assert(failed instanceof ProcessError && failed.result.code===23 && failed.result.stdout==='partial','partial exit error');
209        const allowed=await run(SH,{args:['-c','exit 7'],check:false}); assert(allowed.code===7 && !allowed.success,'unchecked');
210        const child=spawn(SH,{args:['-c','printf x'],encoding:'utf-8'});
211        assert((await child.output()).stdout==='x','one invocation');
212        await rejects(()=>child.output(),'TypeError');
213        assert(!child.kill(),'kill after exit');
214    "#,
215    );
216}
217
218#[test]
219fn positional_arguments_and_stdout_shortcuts() {
220    pass(
221        r#"
222        import {run,runSync,spawn} from 'avril:std';
223        assert(await run(SH,['-c','printf "a\\nb"']).text()==='a\nb','text');
224        assert((await run(SH,['-c','printf \'{"n":1}\'']).json()).n===1,'json');
225        assert((await run(SH,['-c','printf hi']).bytes()).length===2,'bytes');
226        assert(await run(SH,['-c','printf é'],{encoding:'utf-8'}).text()==='é','encoded text');
227        assert(runSync(SH,['-c','printf sync'],{encoding:'utf-8'}).stdout==='sync','runSync');
228        assert((await spawn(SH,['-c','printf spawn'],{encoding:'utf-8'}).output()).stdout==='spawn','spawn');
229        await rejects(()=>run(SH,['-c','true'],{args:[]}),'TypeError');
230        await rejects(()=>run(SH,'-c'),'TypeError');
231        await rejects(()=>run(SH,['-c','exit 3']).text(),'ProcessError');
232        await rejects(()=>run(SH,['-c','true'],{stdout:'null'}).text(),'TypeError');
233    "#,
234    );
235}
236
237#[test]
238fn lines_stream_while_the_child_runs() {
239    pass(
240        r#"
241        import {run} from 'avril:std';
242        const seen=[];
243        for await (const line of run(SH,['-c','echo one; read _; printf "two\\r\\nthree"'],{input:new ReadableStream({
244            async 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);
246        assert(JSON.stringify(seen)==='["one","two","three"]','streamed lines: '+JSON.stringify(seen));
247        const failing=run(SH,['-c','echo kept; echo why >&2; exit 4']);
248        const lines=[];
249        const error=await rejects(async()=>{ for await (const line of failing.lines()) lines.push(line); },'ProcessError');
250        assert(lines[0]==='kept'&&error.result.code===4&&new TextDecoder().decode(error.result.stderr)==='why\n','failure after lines');
251        await rejects(()=>failing,'ProcessError');
252        const endless=run(SH,['-c','echo first; exec sleep 30'],{termination:{graceMs:100}});
253        for await (const line of endless.lines()) break;
254        assert((await rejects(()=>endless,'ProcessError')).result.reason==='terminated','early exit terminates');
255        const late=run(SH,['-c','echo a; echo b']);
256        await late;
257        const buffered=[]; for await (const line of late.lines()) buffered.push(line);
258        assert(JSON.stringify(buffered)==='["a","b"]','collected lines');
259    "#,
260    );
261}
262
263#[test]
264fn streamed_lines_apply_backpressure_instead_of_a_total_cap() {
265    let fixture = Fixture::new();
266    fixture.pass(
267        r#"
268        import {run,stat} from 'avril:std';
269        const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
270        const 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.
272        let count=0;
273        for await (const line of run(SH,['-c','yes 0123456789 | head -n 400000; : > written']).lines()) {
274            if (count++===0) { await sleep(300); assert(!await exists('written'),'child ran ahead of the reader'); }
275        }
276        assert(count===400000&&await exists('written'),'every line');
277        // Draining is not capped by the run's default 16 MiB.
278        let bytes=0;
279        for await (const line of run(SH,['-c','yes $(printf %0100d 0) | head -n 200000']).lines()) bytes+=line.length+1;
280        assert(bytes===200000*101,'uncapped stream');
281        // Only what is kept counts toward maxCollectedBytes: stderr, not streamed stdout.
282        let streamed=0;
283        for await (const line of run(SH,['-c','yes x | head -n 100000'],{maxCollectedBytes:1000}).lines()) streamed++;
284        assert(streamed===100000,'streamed stdout is not collected');
285        const kept=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; printf "%05000d" 0 >&2'],{maxCollectedBytes:1000}).lines()); },'OutputLimitError');
286        assert(kept.result.stderr.length===1000,'collected stderr is bounded');
287    "#,
288        &[],
289    );
290}
291
292#[test]
293fn backpressured_lines_still_end_promptly() {
294    pass(
295        r#"
296        import {run} from 'avril:std';
297        const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
298        // Leaving while the pump waits on the reader terminates the child.
299        let started=Date.now();
300        const left=run(SH,['-c','exec yes'],{termination:{graceMs:100}});
301        for await (const line of left.lines()) { await sleep(100); break; }
302        assert((await rejects(()=>left,'ProcessError')).result.reason==='terminated','left');
303        assert(Date.now()-started<2000,'leaving waited on the reader');
304        // A timeout still stops a child whose reader fell behind.
305        started=Date.now();
306        await rejects(async()=>{ for await (const line of run(SH,['-c','exec yes'],{timeoutMs:200,drainTimeoutMs:200}).lines()) await sleep(1); },'ProcessTimeoutError');
307        assert(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.
311        let count=0;
312        for await (const line of run(SH,['-c','yes x | head -n 580000'],{drainTimeoutMs:300}).lines()) {
313            if (count++===0) await sleep(600);
314        }
315        assert(count===580000,'reader kept its lines: '+count);
316        // A real straggler still ends the run.
317        started=Date.now();
318        const error=await rejects(async()=>{ for await (const line of run(SH,['-c','echo a; exec sleep 30 &'],{drainTimeoutMs:300}).lines()); },'ProcessError');
319        assert(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() {
326    pass(
327        r#"
328        import {run,runSync} from 'avril:std';
329        const script='while IFS= read -r line; do printf "%s\\n" "$line"; done';
330        for(const method of [run,runSync]) {
331            const out=await method(SH,{args:['-c',script],input:'alpha\nbeta\n',encoding:'utf-8'});
332            assert(out.stdout==='alpha\nbeta\n','string input and EOF');
333            const empty=await method(SH,{args:['-c',script],stdin:'piped'}); assert(empty.stdout.length===0,'empty input EOF');
334        }
335        const stream=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream\n'));c.close()}});
336        assert((await run(SH,{args:['-c',script],input:stream,encoding:'utf-8'})).stdout==='stream\n','stream input');
337        await rejects(()=>runSync(SH,{input:new ReadableStream()}),'TypeError');
338        const never=new ReadableStream({pull(){return new Promise(()=>{})},cancel(){return new Promise(()=>{})}});
339        const 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() {
346    pass(
347        r#"
348        import {run,runSync} from 'avril:std';
349        const input=new Uint8Array(300000).fill(97); input[input.length-1]=10;
350        const script='printf "%0300000d" 0; printf "%0300000d" 0 >&2; IFS= read -r value; printf "%s" "$value"';
351        for(const method of [runSync,run]) {
352            const output=await method(SH,{args:['-c',script],input,maxCollectedBytes:1000000});
353            assert(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() {
361    pass(
362        r#"
363        import {run,runSync,spawn,OutputLimitError} from 'avril:std';
364        for(const method of [run,runSync]) {
365            const error=await rejects(()=>method(SH,{args:['-c','printf abcdefghij; printf klmnopqrst >&2'],maxCollectedBytes:13,termination:{graceMs:10}}),'OutputLimitError');
366            assert(error instanceof OutputLimitError && error.result.reason==='output-limit','limit reason');
367            assert(error.result.stdout.length+error.result.stderr.length===13,'combined capture bound');
368        }
369        const child=spawn(SH,{args:['-c','printf "%080000d" 0'],maxBufferedBytes:128,termination:{graceMs:10}});
370        const status=await child.completed; assert(status.reason==='output-limit','native unread overflow without a JS consumer');
371        const 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() {
378    pass(
379        r#"
380        import {run,runSync,spawn} from 'avril:std';
381        const stubborn=['-c','trap "" TERM; printf started; while :; do :; done'];
382        for(const method of [run,runSync]) {
383            const error=await rejects(()=>method(SH,{args:stubborn,timeoutMs:50,termination:{graceMs:20},encoding:'utf-8'}),'ProcessTimeoutError');
384            assert(error.result.signal==='SIGKILL' && error.result.stdout==='started','native escalation and partial output');
385        }
386        const control=new AbortController(), marker={marker:1};
387        const task=run(SH,{args:stubborn,signal:control.signal,termination:{graceMs:20}}); control.abort(marker);
388        const error=await rejects(()=>task,'ProcessAbortError'); assert(error.cause===marker && error.result.reason==='abort','abort cause');
389        const already=AbortSignal.abort(marker); await rejects(()=>run(SH,{signal:already}),'ProcessAbortError');
390        const child=spawn(SH,{args:stubborn,termination:{graceMs:20}}); const one=child.terminate(),two=child.terminate();
391        assert(one===two && (await one).reason==='terminated','idempotent terminate');
392        await 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() {
399    pass(
400        r#"
401        import {spawn} from 'avril:std';
402        const child=spawn(SH,{args:['-c','"$1" -c "while :; do :; done" & printf prefix; exit','sh',SH],drainTimeoutMs:40,encoding:'utf-8'});
403        const status=await child.status; assert(status.code===0 && status.reason===null,'leader exit');
404        const completed=await child.completed; assert(completed.reason==='drain-timeout','bounded inherited pipes');
405        const error=await rejects(()=>child.output(),'ProcessError'); assert(error.result.stdout==='prefix','drain preserves output');
406        assert(!child.kill(),'no signaling exited leader');
407    "#,
408    );
409}
410
411#[test]
412fn streaming_stdio_byob_merge_and_consumption_ownership() {
413    pass(
414        r#"
415        import {spawn} from 'avril:std';
416        const child=spawn(SH,{args:['-c','IFS= read -r line; printf "%s" "$line"; printf err >&2'],stdin:'piped',stderr:'stdout'});
417        assert(child.stderr===null,'merged stderr');
418        const writer=child.stdin.getWriter(); await writer.write(new TextEncoder().encode('byob\n')); await writer.close();
419        const reader=child.stdout.getReader({mode:'byob'}); let text='';
420        while(true){const next=await reader.read(new Uint8Array(3)); if(next.done)break;text+=new TextDecoder().decode(next.value)}
421        assert(text==='byoberr' && (await child.completed).success,'BYOB and merge'); reader.releaseLock();
422        await rejects(()=>child.output(),'TypeError');
423        const canceled=spawn(SH,{args:['-c','printf "%0200000d" 0'],maxBufferedBytes:32});
424        await 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() {
431    let fixture = Fixture::new();
432    fixture.script("local-tool", "printf local");
433    let path = serde_json::to_string(fixture.dir.path()).unwrap();
434    fixture.pass(&format!(r#"
435        import {{run}} from 'avril:std';
436        const env={{PATH:{path}}};
437        assert((await run('local-tool',{{env,clearEnv:true,encoding:'utf-8'}})).stdout==='local','child PATH');
438        assert((await run('local-tool',{{searchPath:false,env:{{PATH:'/missing'}},encoding:'utf-8'}})).stdout==='local','bare pathname');
439        assert((await run('./local-tool',{{clearEnv:true,encoding:'utf-8'}})).stdout==='local','explicit bypasses PATH');
440        await rejects(()=>run('sh',{{clearEnv:true}}),'NotFound');
441        await rejects(()=>run('sh',{{searchPath:false,env:{{PATH:{path}}}}}),'NotFound');
442        assert((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() {
448    let 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#"
454        import {run} from 'avril:std';
455        await rejects(()=>run('tool',{clearEnv:true,env:{PATH:'first:second'}}),'PermissionDenied');
456        assert((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() {
462    let fixture = Fixture::new();
463    let grant = format!("--allow-run={}", shell().display());
464    fixture.pass(r#"
465        import {run,spawn,pledge} from 'avril:std';
466        assert((await run(SH,{args:['-c','printf native'],encoding:'utf-8'})).stdout==='native','native run does not need read');
467        const child=spawn(SH,{args:['-c','while :; do :; done'],termination:{graceMs:10}});
468        pledge({run:false}); await rejects(()=>run(SH),'PermissionDenied');
469        assert((await child.terminate()).reason==='terminated','retained cleanup authority');
470    "#,&["-0".into(),grant]);
471    fixture.pass(
472        r#"import {run} from 'avril:std'; await rejects(()=>run(SH),'PermissionDenied');"#,
473        &[
474            "-0".into(),
475            format!("--allow-read={}", shell().display()),
476            format!("--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() {
483    let fixture = Fixture::new();
484    fixture.pass(r#"
485        import {env,run,pledge} from 'avril:std';
486        env.set('AVRIL_PROCESS_LOCAL','local');
487        const out=await run(SH,{args:['-c','printf "%s:%s" "${AVRIL_PROCESS_SECRET-unset}" "$AVRIL_PROCESS_LOCAL"'],encoding:'utf-8'});
488        assert(out.stdout==='unset:local','filtered runtime-local inherited environment');
489        pledge({env:false});
490        const explicit=await run(SH,{args:['-c','printf "%s" "$AVRIL_PROCESS_SECRET"'],env:{AVRIL_PROCESS_SECRET:'explicit'},encoding:'utf-8'});
491        assert(explicit.stdout==='explicit','explicit values bypass parent read permissions');
492        await rejects(()=>run('sh'),'NotFound');
493    "#,&["--deny-env=AVRIL_PROCESS_SECRET".into()]);
494}
495
496#[test]
497fn file_redirection_modes_borrowed_handles_and_current_permissions() {
498    let fixture = Fixture::new();
499    fs::write(fixture.dir.path().join("input"), "data\n").unwrap();
500    fixture.pass(
501        r#"
502        import {run,runSync,open,pledge} from 'avril:std'; import {readTextFile} from 'avril:fs';
503        const script='IFS= read -r line; printf "%s" "$line"';
504        const first=await run(SH,{args:['-c',script],stdin:{file:'input'},stdout:{file:'output'}});
505        assert(first.stdout===null && await readTextFile('output')==='data','secure direct files');
506        runSync(SH,{args:['-c','printf plus'],stdout:{file:'output',mode:'append'}});
507        assert(await readTextFile('output')==='dataplus','append');
508        await rejects(()=>run(SH,{stdout:{file:'output',mode:'create-new'}}),'AlreadyExists');
509        const handle=await open('output',{read:true,write:true}); await handle.seek(0,0);
510        await run(SH,{args:['-c','printf borrowed'],stdout:{handle}});
511        assert(await handle.seek(0,1)===8,'borrowed cursor');
512        pledge({write:false}); await rejects(()=>run(SH,{stdout:{handle}}),'PermissionDenied');
513        await handle.close();
514    "#,
515        &[],
516    );
517    assert_eq!(
518        fs::read(fixture.dir.path().join("output")).unwrap(),
519        b"borrowed"
520    );
521}
522
523#[test]
524fn executable_authorization_precedes_redirection_mutation() {
525    let 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())]);
528    assert_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() {
533    let 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#"
542        import {run} from 'avril:std';
543        assert((await run(new URL('./script',import.meta.url),{encoding:'utf-8'})).stdout==='pinned','pinned script');
544        try{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() {
550    pass(
551        r#"
552        import {spawn} from 'avril:std';
553        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'});
554        const writer=child.stdio[3].getWriter(); await writer.write(new TextEncoder().encode('extra\n')); await writer.close();
555        const extra=await new Response(child.stdio[4]).text(); assert(extra==='extra','extra pipe');
556        assert((await child.output()).stdout==='standard','standard pipe');
557        await rejects(()=>spawn(SH,{extraStdio:{3:'inherit'}}),'TypeError');
558    "#,
559    );
560}
561
562#[test]
563fn terminal_is_real_combined_resizable_and_explicit() {
564    pass(
565        r#"
566        import {spawn,run} from 'avril:std';
567        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'});
568        assert(child.terminal && child.stderr===null,'explicit terminal');
569        child.terminal.resize(100,40);
570        const writer=child.terminal.writable.getWriter(); await writer.write(new TextEncoder().encode('terminal\n'));
571        const out=await child.output(); assert(out.stdout.includes('tty:terminal') && out.success,'isatty and terminal IO');
572        await writer.close();
573        await rejects(()=>run(SH,{terminal:{}}),'TypeError');
574    "#,
575    );
576}
577
578#[test]
579fn unref_does_not_keep_runtime_alive_and_owner_shutdown_kills_children() {
580    let fixture = Fixture::new();
581    let start = Instant::now();
582    let output=fixture.pass(r#"
583        import {spawn} from 'avril:std';
584        const child=spawn(SH,{args:['-c','while :; do :; done'],stdout:'null',stderr:'null',termination:{graceMs:10}});
585        console.log(child.pid); child.unref();
586    "#,&[]);
587    assert!(start.elapsed() < Duration::from_secs(3));
588    let pid = String::from_utf8(output.stdout)
589        .unwrap()
590        .trim()
591        .parse::<i32>()
592        .unwrap();
593    assert_eq!(
594        unsafe { 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() {
602    pass(
603        r#"
604        import {spawn,ChildProcess,run,runSync} from 'avril:std';
605        await rejects(()=>new ChildProcess(),'TypeError');
606        const child=spawn(SH,{args:['-c','exit']});
607        await rejects(()=>structuredClone(child),'DataCloneError');
608        await rejects(()=>ChildProcess.prototype.kill.call({}),'TypeError');
609        await child.completed;
610        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'}}]) {
611            await rejects(()=>run(SH,options), options.maxCollectedBytes===0 || options.timeoutMs===-1 ? 'RangeError' : options.env || options.args ? 'InvalidInput' : 'TypeError');
612        }
613        await rejects(()=>runSync(SH,{signal:new AbortController().signal}),'TypeError');
614    "#,
615    );
616}
617
618#[test]
619fn worker_run_sync_teardown_is_prompt() {
620    let fixture = Fixture::new();
621    fs::write(
622        fixture.dir.path().join("worker.js"),
623        r#"
624        import {runSync} from 'avril:std';
625        try { runSync('sleep', {args:['30']}); } catch {}
626    "#,
627    )
628    .unwrap();
629    // The harness fails after five seconds, so a wedged teardown fails loudly.
630    let output = fixture.pass(
631        r#"
632        const worker = new Worker('./worker.js', {type:'module'});
633        await new Promise(resolve => setTimeout(resolve, 400));
634        worker.terminate();
635        console.log('terminated');
636    "#,
637        &[],
638    );
639    assert_eq!(output.stdout, b"terminated\n");
640}
641
642#[test]
643fn multicall_entries_are_distinct_authorities() {
644    let fixture = Fixture::new();
645    fixture.script("real-tool", "printf tool");
646    symlink("real-tool", fixture.dir.path().join("alias-tool")).unwrap();
647    fixture.pass(
648        r#"
649        import {run} from 'avril:std';
650        assert((await run('./real-tool',{encoding:'utf-8'})).stdout==='tool','granted entry');
651        await rejects(()=>run('./alias-tool'),'PermissionDenied');
652    "#,
653        &["-0".into(), "--allow-run=real-tool".into()],
654    );
655    fixture.pass(
656        r#"
657        import {run} from 'avril:std';
658        assert((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() {
667    let fixture = Fixture::new();
668    fixture.script("tool", "printf tool");
669    // Respelling only reaches the same entry on case-insensitive volumes.
670    if !fixture.dir.path().join("TOOL").exists() {
671        return;
672    }
673    fixture.pass(
674        r#"import {run} from 'avril:std'; await rejects(()=>run('./TOOL'),'PermissionDenied');"#,
675        &["--deny-run=tool".into()],
676    );
677    fixture.pass(
678        r#"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() {
685    pass(
686        r#"
687        import {run,runSync} from 'avril:std';
688        const input = new Uint8Array(70000).fill(65);
689        for (const method of [run, runSync]) {
690            const early = await method(SH, {args:['-c','exit 3'], input, check:false});
691            assert(early.code === 3, 'exit without reading stdin');
692            const headed = await method(SH, {args:['-c','head -c 1'], input, encoding:'utf-8'});
693            assert(headed.stdout === 'A', 'partial read');
694        }
695    "#,
696    );
697}
698
699#[test]
700fn only_declared_descriptors_survive_exec() {
701    pass(
702        r#"
703        import {spawn,run} from 'avril:std';
704        const holder = spawn(SH, {args:['-c','sleep 30'], stdout:'null', stderr:'null', extraStdio:{3:{pipe:'parent-to-child'}}});
705        const out = await run(SH, {args:['-c','ls /dev/fd'], encoding:'utf-8'});
706        for (const name of out.stdout.trim().split('\n')) {
707            assert(Number(name) <= 3, 'leaked descriptor '+name);
708        }
709        await holder.terminate();
710    "#,
711    );
712}
713
714#[test]
715fn foreign_descriptors_do_not_survive_exec() {
716    let fixture = Fixture::new();
717    fs::write(
718        fixture.dir.path().join("main.js"),
719        format!(
720            r#"
721        const SH={};
722        import {{run}} from 'avril:std';
723        const out = await run(SH, {{args:['-c','ls /dev/fd'], encoding:'utf-8'}});
724        for (const name of out.stdout.trim().split('\n')) {{
725            if (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.
734    let probe = fs::File::open("/dev/null").unwrap();
735    let fd = probe.as_raw_fd();
736    let 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());
744    unsafe {
745        command.pre_exec(move || {
746            libc::signal(libc::SIGINT, libc::SIG_DFL);
747            libc::signal(libc::SIGTERM, libc::SIG_DFL);
748            if libc::dup2(fd, 5) < 0 {
749                return Err(std::io::Error::last_os_error());
750            }
751            Ok(())
752        });
753    }
754    let output = common::wait(command.spawn().unwrap());
755    assert!(
756        output.status.success(),
757        "{}",
758        String::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.
766    use std::os::unix::process::ExitStatusExt;
767    for signal in [libc::SIGTERM, libc::SIGINT] {
768        let fixture = Fixture::new();
769        fs::write(
770            fixture.dir.path().join("main.js"),
771            format!(
772                r#"
773            const SH={};
774            import {{runSync}} from 'avril:std';
775            runSync(SH, {{args:['-c','exit 0']}});
776            console.log('ready');
777            setInterval(()=>{{}}, 1000);
778        "#,
779                serde_json::to_string(&shell()).unwrap()
780            ),
781        )
782        .unwrap();
783        let 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.
794        let 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();
800        assert_eq!(ready.trim(), "ready");
801        assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0);
802        let output = common::wait(child);
803        assert_eq!(
804            output.status.signal(),
805            Some(signal),
806            "signal {signal}: {}",
807            String::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.
817    let fixture = Fixture::new();
818    fs::write(
819        fixture.dir.path().join("main.js"),
820        format!(
821            r#"
822            const SH={};
823            import {{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.
826            const onInt = () => {{}};
827            addSignalListener('SIGINT', onInt);
828            removeSignalListener('SIGINT', onInt);
829            const onHup = () => {{}};
830            addSignalListener('SIGHUP', onHup);
831            removeSignalListener('SIGHUP', onHup);
832            runSync(SH, {{args:['-c','exit 0']}});
833            // Children keep the startup ignores too: these self-kills
834            // survive only through an inherited SIG_IGN.
835            const hup = runSync(SH, {{args:['-c','kill -HUP $$; echo survived']}});
836            console.log('child-hup ' + hup.code + '/' + hup.signal);
837            const int = runSync(SH, {{args:['-c','kill -INT $$; echo survived']}});
838            console.log('child-int ' + int.code + '/' + int.signal);
839            console.log('ready');
840            setInterval(()=>{{}}, 1000);
841        "#,
842            serde_json::to_string(&shell()).unwrap()
843        ),
844    )
845    .unwrap();
846    let 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());
854    unsafe {
855        command.pre_exec(|| {
856            libc::signal(libc::SIGINT, libc::SIG_IGN);
857            libc::signal(libc::SIGHUP, libc::SIG_IGN);
858            Ok(())
859        });
860    }
861    let mut child = command.spawn().unwrap();
862    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
863    let mut text = String::new();
864    std::io::BufRead::read_line(&mut stdout, &mut text).unwrap();
865    assert_eq!(text.trim(), "child-hup 0/null", "{text}");
866    text.clear();
867    std::io::BufRead::read_line(&mut stdout, &mut text).unwrap();
868    assert_eq!(text.trim(), "child-int 0/null", "{text}");
869    text.clear();
870    std::io::BufRead::read_line(&mut stdout, &mut text).unwrap();
871    assert_eq!(text.trim(), "ready", "{text}");
872    child.stdout = Some(stdout.into_inner());
873    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0);
874    std::thread::sleep(Duration::from_millis(300));
875    assert!(
876        child.try_wait().unwrap().is_none(),
877        "ignored SIGINT killed the process"
878    );
879    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
880    let output = common::wait(child);
881    use std::os::unix::process::ExitStatusExt;
882    assert_eq!(output.status.signal(), Some(libc::SIGTERM));
883}
884
885#[test]
886fn headless_group_timeouts_reach_grandchildren() {
887    let fixture = Fixture::new();
888    fs::write(
889        fixture.dir.path().join("main.js"),
890        format!(
891            r#"
892            import {{spawn}} from 'avril:std';
893            const child = spawn({}, {{args:['-c','sleep 303 & echo $! > pid.txt; wait'], stdout:'null', stderr:'inherit', timeoutMs:300}});
894            const status = await child.completed;
895            if (status.reason !== 'timeout') throw Error('expected timeout');
896        "#,
897            serde_json::to_string(&shell()).unwrap()
898        ),
899    )
900    .unwrap();
901    let 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());
909    unsafe {
910        command.pre_exec(|| {
911            libc::signal(libc::SIGINT, libc::SIG_DFL);
912            libc::signal(libc::SIGTERM, libc::SIG_DFL);
913            if libc::setsid() < 0 {
914                return Err(std::io::Error::last_os_error());
915            }
916            Ok(())
917        });
918    }
919    let output = common::wait(command.spawn().unwrap());
920    let pid: i32 = fs::read_to_string(fixture.dir.path().join("pid.txt"))
921        .unwrap()
922        .trim()
923        .parse()
924        .unwrap();
925    let alive = alive(pid);
926    if alive {
927        unsafe {
928            libc::kill(pid, libc::SIGKILL);
929        }
930    }
931    assert!(output.status.success());
932    assert!(!alive, "grandchild survived the group timeout");
933}
934
935#[test]
936fn capability_denials_do_not_disclose_filesystem_existence() {
937    let fixture = Fixture::new();
938    fixture.pass(
939        r#"
940        import {run} from 'avril:std';
941        await rejects(()=>run('./missing-tool',{cwd:'/sys/kernel'}),'PermissionDenied');
942        await rejects(()=>run('missing-tool-xyz'),'PermissionDenied');
943    "#,
944        &["-0".into(), format!("--allow-run={}", shell().display())],
945    );
946    pass(
947        r#"
948        import {run} from 'avril:std';
949        await rejects(()=>run('missing-tool-xyz'),'NotFound');
950    "#,
951    );
952}
953
954#[test]
955fn serve_drains_an_in_flight_subprocess_on_sigterm() {
956    use std::io::{BufRead, BufReader, Read, Write};
957    use std::os::unix::net::UnixStream;
958    let fixture = Fixture::new();
959    let socket = fixture.dir.path().join("http.sock");
960    fs::write(
961        fixture.dir.path().join("main.js"),
962        format!(
963            r#"
964            import {{spawn}} from 'avril:std';
965            export default {{async fetch() {{
966                const child = spawn({}, {{args:['-c','sleep 0.3; printf done'], encoding:'utf-8'}});
967                console.log('child-started');
968                const output = await child.output();
969                return new Response(output.stdout);
970            }}}};
971        "#,
972            serde_json::to_string(&shell()).unwrap()
973        ),
974    )
975    .unwrap();
976    let 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();
986    let mut stderr = BufReader::new(child.stderr.take().unwrap());
987    let mut line = String::new();
988    stderr.read_line(&mut line).unwrap();
989    assert!(line.starts_with("Listening on "), "{line}");
990    child.stderr = Some(stderr.into_inner());
991    let 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();
998    let mut stdout = BufReader::new(child.stdout.take().unwrap());
999    line.clear();
1000    stdout.read_line(&mut line).unwrap();
1001    assert_eq!(line.trim(), "child-started");
1002    child.stdout = Some(stdout.into_inner());
1003    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1004    let mut response = String::new();
1005    connection.read_to_string(&mut response).unwrap();
1006    let output = common::wait(child);
1007    assert!(
1008        output.status.success(),
1009        "{}",
1010        String::from_utf8_lossy(&output.stderr)
1011    );
1012    assert!(response.starts_with("HTTP/1.1 200"), "{response}");
1013    assert!(response.ends_with("done"), "{response}");
1014    assert!(!socket.exists(), "socket left behind after shutdown");
1015}
1016
1017#[test]
1018fn unowned_signal_shutdown_kills_grace_resisting_descendants() {
1019    use std::io::{BufRead, BufReader};
1020    use std::os::unix::process::ExitStatusExt;
1021    for repeat in [false, true] {
1022        let fixture = Fixture::new();
1023        fs::write(
1024            fixture.dir.path().join("main.js"),
1025            format!(
1026                r#"
1027                import {{spawn}} from 'avril:std';
1028                const child = spawn({}, {{args:['-c',"trap '' TERM; sleep 304 & echo $!; wait"], stdin:'null', stderr:'null', termination:{{graceMs:10000}}}});
1029                const reader = child.stdout.getReader();
1030                console.log(new TextDecoder().decode((await reader.read()).value).trim());
1031                setInterval(()=>{{}},1000);
1032            "#,
1033                serde_json::to_string(&shell()).unwrap()
1034            ),
1035        )
1036        .unwrap();
1037        let 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();
1045        let mut stdout = BufReader::new(child.stdout.take().unwrap());
1046        let mut line = String::new();
1047        stdout.read_line(&mut line).unwrap();
1048        let grandchild: i32 = line.trim().parse().unwrap();
1049        child.stdout = Some(stdout.into_inner());
1050        let started = Instant::now();
1051        assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1052        if repeat {
1053            std::thread::sleep(Duration::from_millis(150));
1054            assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1055        }
1056        let output = common::wait(child);
1057        let alive = alive(grandchild);
1058        if alive {
1059            unsafe {
1060                libc::kill(grandchild, libc::SIGKILL);
1061            }
1062        }
1063        assert_eq!(output.status.signal(), Some(libc::SIGTERM));
1064        assert!(!alive, "grandchild survived owner shutdown");
1065        if repeat {
1066            assert!(
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() {
1076    use std::io::{BufRead, BufReader, Write};
1077    use std::os::unix::net::UnixStream;
1078    for signal in [libc::SIGTERM, libc::SIGINT] {
1079        let fixture = Fixture::new();
1080        let socket = fixture.dir.path().join("http.sock");
1081        fs::write(
1082            fixture.dir.path().join("main.js"),
1083            format!(
1084                r#"
1085                import {{spawn}} from 'avril:std';
1086                const sidecar = spawn({}, {{args:['-c','exec sleep 30'],stdin:'null',stdout:'null',stderr:'null'}});
1087                console.log(sidecar.pid);
1088                export default {{async fetch() {{
1089                    console.log('request-started');
1090                    await new Promise(resolve=>setTimeout(resolve,10000));
1091                    return new Response('done');
1092                }}}};
1093            "#,
1094                serde_json::to_string(&shell()).unwrap()
1095            ),
1096        )
1097        .unwrap();
1098        let 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();
1108        let mut stderr = BufReader::new(child.stderr.take().unwrap());
1109        let mut line = String::new();
1110        stderr.read_line(&mut line).unwrap();
1111        assert!(line.starts_with("Listening on "), "{line}");
1112        child.stderr = Some(stderr.into_inner());
1113        let mut stdout = BufReader::new(child.stdout.take().unwrap());
1114        line.clear();
1115        stdout.read_line(&mut line).unwrap();
1116        let sidecar: i32 = line.trim().parse().unwrap();
1117        let 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();
1123        assert_eq!(line.trim(), "request-started");
1124        child.stdout = Some(stdout.into_inner());
1125        assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0);
1126        std::thread::sleep(Duration::from_millis(150));
1127        assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0);
1128        let started = Instant::now();
1129        while child.try_wait().unwrap().is_none() && started.elapsed() < Duration::from_secs(3) {
1130            std::thread::sleep(Duration::from_millis(20));
1131        }
1132        let hung = child.try_wait().unwrap().is_none();
1133        if hung {
1134            child.kill().unwrap();
1135        }
1136        let output = child.wait_with_output().unwrap();
1137        assert!(
1138            !hung,
1139            "signal {signal}: sidecar alive={} stderr={}",
1140            unsafe { libc::kill(sidecar, 0) } == 0,
1141            String::from_utf8_lossy(&output.stderr)
1142        );
1143        assert_eq!(
1144            unsafe { 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() {
1154    for error in [libc::ENOSYS, libc::EINVAL, libc::EPERM] {
1155        let 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"),
1165            format!(
1166                r#"
1167                const SH={};
1168                const assert=(value,message)=>{{if(!value)throw Error(message)}};
1169                import {{run,runSync,spawn}} from 'avril:std';
1170                for (const method of [run,runSync]) {{
1171                    const out=await method(SH,{{args:['-c','ls /proc/self/fd'],encoding:'utf-8'}});
1172                    for (const name of out.stdout.trim().split('\n')) {{
1173                        assert(Number(name)<=3,'foreign descriptor leaked: '+name);
1174                    }}
1175                    assert((await method('./script',{{encoding:'utf-8'}})).stdout==='pinned','shebang exec');
1176                    try {{ await method('./plain'); throw Error('exec accepted plain text'); }}
1177                    catch (error) {{assert(error.name==='IoError','missing exec failure: '+error)}}
1178                }}
1179                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'}});
1180                const writer=child.stdio[3].getWriter();
1181                await writer.write(new TextEncoder().encode('extra\n')); await writer.close();
1182                assert((await new Response(child.stdio[4]).text())==='extra','declared descriptor lost');
1183                assert((await child.output()).stdout==='standard','standard output');
1184                "#,
1185                serde_json::to_string(&shell()).unwrap()
1186            ),
1187        ).unwrap();
1188        let probe = fs::File::open("/dev/null").unwrap();
1189        let fd = probe.as_raw_fd();
1190        let 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());
1197        unsafe {
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.
1203                if libc::dup2(fd, 200) < 0 {
1204                    return Err(std::io::Error::last_os_error());
1205                }
1206                let mut limit: libc::rlimit = std::mem::zeroed();
1207                if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) < 0 {
1208                    return Err(std::io::Error::last_os_error());
1209                }
1210                limit.rlim_cur = 128;
1211                if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) < 0 {
1212                    return Err(std::io::Error::last_os_error());
1213                }
1214                let mut filter = [
1215                    libc::sock_filter {
1216                        code: (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16,
1217                        jt: 0,
1218                        jf: 0,
1219                        k: 0,
1220                    },
1221                    libc::sock_filter {
1222                        code: (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16,
1223                        jt: 0,
1224                        jf: 1,
1225                        k: libc::SYS_close_range as u32,
1226                    },
1227                    libc::sock_filter {
1228                        code: (libc::BPF_RET | libc::BPF_K) as u16,
1229                        jt: 0,
1230                        jf: 0,
1231                        k: libc::SECCOMP_RET_ERRNO | error as u32,
1232                    },
1233                    libc::sock_filter {
1234                        code: (libc::BPF_RET | libc::BPF_K) as u16,
1235                        jt: 0,
1236                        jf: 0,
1237                        k: libc::SECCOMP_RET_ALLOW,
1238                    },
1239                ];
1240                let program = libc::sock_fprog {
1241                    len: filter.len() as u16,
1242                    filter: filter.as_mut_ptr(),
1243                };
1244                if 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                {
1247                    return Err(std::io::Error::last_os_error());
1248                }
1249                Ok(())
1250            });
1251        }
1252        let output = common::wait(command.spawn().unwrap());
1253        assert!(
1254            output.status.success(),
1255            "errno {error}: {}",
1256            String::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 {
1263    let 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() {
1270    let fixture = Fixture::new();
1271    let mut child = fixture.interact(
1272        r#"
1273        import {addSignalListener, removeSignalListener} from 'avril:std';
1274        const seen = [];
1275        const first = () => { seen.push('usr1'); console.log('saw usr1'); };
1276        const second = () => seen.push('usr1b');
1277        const other = () => {
1278            seen.push('usr2');
1279            console.log('done ' + JSON.stringify(seen));
1280            removeSignalListener('SIGUSR1', first);
1281            removeSignalListener('SIGUSR1', second);
1282            removeSignalListener('SIGUSR2', other);
1283        };
1284        addSignalListener('SIGUSR1', first);
1285        addSignalListener('SIGUSR1', second);
1286        addSignalListener('SIGUSR2', other);
1287        console.log('ready');
1288    "#,
1289    );
1290    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1291    assert_eq!(line(&mut stdout), "ready");
1292    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1293    assert_eq!(line(&mut stdout), "saw usr1");
1294    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR2) }, 0);
1295    let done = line(&mut stdout);
1296    child.stdout = Some(stdout.into_inner());
1297    // Both USR1 listeners fire in binding order before the USR2 listener.
1298    assert_eq!(done, r#"done ["usr1","usr1b","usr2"]"#, "{done}");
1299    let output = common::wait(child);
1300    assert!(
1301        output.status.success(),
1302        "{}",
1303        String::from_utf8_lossy(&output.stderr)
1304    );
1305}
1306
1307#[test]
1308fn signal_listener_removal_stops_delivery() {
1309    let fixture = Fixture::new();
1310    let mut child = fixture.interact(
1311        r#"
1312        import {addSignalListener, removeSignalListener} from 'avril:std';
1313        let count = 0;
1314        const keep = () => {
1315            count++;
1316            console.log('kept ' + count);
1317            if (count === 2) {
1318                console.log('final');
1319                removeSignalListener('SIGUSR1', keep);
1320            }
1321        };
1322        const drop = () => { count += 10; };
1323        addSignalListener('SIGUSR1', keep);
1324        addSignalListener('SIGUSR1', keep);
1325        addSignalListener('SIGUSR1', drop);
1326        removeSignalListener('SIGUSR1', drop);
1327        removeSignalListener('SIGUSR1', () => {});
1328        console.log('ready');
1329    "#,
1330    );
1331    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1332    assert_eq!(line(&mut stdout), "ready");
1333    // The duplicate add fires once per delivery, and the removed listener
1334    // never fires: no jump by ten.
1335    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1336    assert_eq!(line(&mut stdout), "kept 1");
1337    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1338    assert_eq!(line(&mut stdout), "kept 2");
1339    assert_eq!(line(&mut stdout), "final");
1340    child.stdout = Some(stdout.into_inner());
1341    let output = common::wait(child);
1342    assert!(
1343        output.status.success(),
1344        "{}",
1345        String::from_utf8_lossy(&output.stderr)
1346    );
1347}
1348
1349#[test]
1350fn listened_sigterm_survives_then_kills_once_removed() {
1351    use std::os::unix::process::ExitStatusExt;
1352    let fixture = Fixture::new();
1353    let mut child = fixture.interact(
1354        r#"
1355        import {addSignalListener, removeSignalListener} from 'avril:std';
1356        const onTerm = () => {
1357            removeSignalListener('SIGTERM', onTerm);
1358            console.log('survived');
1359        };
1360        addSignalListener('SIGTERM', onTerm);
1361        console.log('ready');
1362        setInterval(() => {}, 1000);
1363    "#,
1364    );
1365    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1366    assert_eq!(line(&mut stdout), "ready");
1367    // A listened SIGTERM reaches JS instead of the supervisor's death path.
1368    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1369    assert_eq!(line(&mut stdout), "survived");
1370    child.stdout = Some(stdout.into_inner());
1371    // With no listeners left the prior fate returns.
1372    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1373    let output = common::wait(child);
1374    assert_eq!(
1375        output.status.signal(),
1376        Some(libc::SIGTERM),
1377        "{}",
1378        String::from_utf8_lossy(&output.stderr)
1379    );
1380}
1381
1382#[test]
1383fn unbound_signals_restore_default_death() {
1384    use std::os::unix::process::ExitStatusExt;
1385    // A signal nobody ever binds keeps its default disposition.
1386    let fixture = Fixture::new();
1387    let mut child = fixture.interact(r#"console.log('ready'); setInterval(() => {}, 1000);"#);
1388    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1389    assert_eq!(line(&mut stdout), "ready");
1390    child.stdout = Some(stdout.into_inner());
1391    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1392    let output = common::wait(child);
1393    assert_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.
1396    let fixture = Fixture::new();
1397    let mut child = fixture.interact(
1398        r#"
1399        import {addSignalListener, removeSignalListener} from 'avril:std';
1400        const onUsr = () => {
1401            removeSignalListener('SIGUSR1', onUsr);
1402            console.log('unbound');
1403        };
1404        addSignalListener('SIGUSR1', onUsr);
1405        console.log('ready');
1406        setInterval(() => {}, 1000);
1407    "#,
1408    );
1409    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1410    assert_eq!(line(&mut stdout), "ready");
1411    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1412    assert_eq!(line(&mut stdout), "unbound");
1413    child.stdout = Some(stdout.into_inner());
1414    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1415    let output = common::wait(child);
1416    assert_eq!(
1417        output.status.signal(),
1418        Some(libc::SIGUSR1),
1419        "{}",
1420        String::from_utf8_lossy(&output.stderr)
1421    );
1422}
1423
1424#[test]
1425fn signal_listener_validation_rejects_bad_signals() {
1426    pass(
1427        r#"
1428        import {addSignalListener, removeSignalListener} from 'avril:std';
1429        await rejects(() => addSignalListener('SIGFOO', () => {}), 'InvalidInput');
1430        await rejects(() => addSignalListener('', () => {}), 'InvalidInput');
1431        await rejects(() => addSignalListener('SIGKILL', () => {}), 'InvalidInput');
1432        await rejects(() => addSignalListener('SIGSTOP', () => {}), 'InvalidInput');
1433        await rejects(() => addSignalListener('SIGQUIT', () => {}), 'NotSupported');
1434        await rejects(() => addSignalListener('SIGPIPE', () => {}), 'NotSupported');
1435        await rejects(() => addSignalListener('SIGUSR1'), 'TypeError');
1436        await rejects(() => addSignalListener('SIGUSR1', 42), 'TypeError');
1437        await rejects(() => addSignalListener(Symbol('sig'), () => {}), 'TypeError');
1438        removeSignalListener('SIGUSR1', () => {});
1439        removeSignalListener('SIGFOO', () => {});
1440    "#,
1441    );
1442}
1443
1444#[test]
1445fn serve_sigterm_runs_listeners_while_draining() {
1446    use std::io::{BufRead, BufReader, Read, Write};
1447    use std::os::unix::net::UnixStream;
1448    let fixture = Fixture::new();
1449    let socket = fixture.dir.path().join("http.sock");
1450    fs::write(
1451        fixture.dir.path().join("main.js"),
1452        r#"
1453        import {addSignalListener} from 'avril:std';
1454        addSignalListener('SIGTERM', () => console.log('listener-ran'));
1455        export default {async fetch() {
1456            console.log('child-started');
1457            await new Promise(resolve => setTimeout(resolve, 300));
1458            return new Response('done');
1459        }};
1460    "#,
1461    )
1462    .unwrap();
1463    let 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();
1473    let mut stderr = BufReader::new(child.stderr.take().unwrap());
1474    let mut text = String::new();
1475    stderr.read_line(&mut text).unwrap();
1476    assert!(text.starts_with("Listening on "), "{text}");
1477    child.stderr = Some(stderr.into_inner());
1478    let 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();
1485    let mut stdout = BufReader::new(child.stdout.take().unwrap());
1486    text.clear();
1487    stdout.read_line(&mut text).unwrap();
1488    assert_eq!(text.trim(), "child-started");
1489    child.stdout = Some(stdout.into_inner());
1490    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1491    let mut response = String::new();
1492    connection.read_to_string(&mut response).unwrap();
1493    let output = common::wait(child);
1494    assert!(
1495        output.status.success(),
1496        "{}",
1497        String::from_utf8_lossy(&output.stderr)
1498    );
1499    assert!(response.starts_with("HTTP/1.1 200"), "{response}");
1500    assert!(response.ends_with("done"), "{response}");
1501    // The listener observes the owned shutdown rather than replacing it.
1502    let stdout = String::from_utf8(output.stdout).unwrap();
1503    assert!(stdout.contains("listener-ran"), "{stdout}");
1504    assert!(!socket.exists(), "socket left behind after shutdown");
1505}
1506
1507#[test]
1508fn serve_sigint_runs_listeners_while_draining() {
1509    use std::io::{BufRead, BufReader, Read, Write};
1510    use std::os::unix::net::UnixStream;
1511    let fixture = Fixture::new();
1512    let socket = fixture.dir.path().join("http.sock");
1513    fs::write(
1514        fixture.dir.path().join("main.js"),
1515        r#"
1516        import {addSignalListener} from 'avril:std';
1517        addSignalListener('SIGINT', () => console.log('int-listener-ran'));
1518        export default {async fetch() {
1519            console.log('child-started');
1520            await new Promise(resolve => setTimeout(resolve, 300));
1521            return new Response('done');
1522        }};
1523    "#,
1524    )
1525    .unwrap();
1526    let 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();
1536    let mut stderr = BufReader::new(child.stderr.take().unwrap());
1537    let mut text = String::new();
1538    stderr.read_line(&mut text).unwrap();
1539    assert!(text.starts_with("Listening on "), "{text}");
1540    child.stderr = Some(stderr.into_inner());
1541    let 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();
1548    let mut stdout = BufReader::new(child.stdout.take().unwrap());
1549    text.clear();
1550    stdout.read_line(&mut text).unwrap();
1551    assert_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.
1555    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0);
1556    let mut response = String::new();
1557    connection.read_to_string(&mut response).unwrap();
1558    let output = common::wait(child);
1559    assert!(
1560        output.status.success(),
1561        "{}",
1562        String::from_utf8_lossy(&output.stderr)
1563    );
1564    assert!(response.starts_with("HTTP/1.1 200"), "{response}");
1565    assert!(response.ends_with("done"), "{response}");
1566    let stdout = String::from_utf8(output.stdout).unwrap();
1567    assert!(stdout.contains("int-listener-ran"), "{stdout}");
1568    assert!(!socket.exists(), "socket left behind after shutdown");
1569}
1570
1571#[test]
1572fn listened_sigterm_suppresses_installed_supervisor() {
1573    use 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.
1576    let fixture = Fixture::new();
1577    let mut child = fixture.interact(
1578        r#"
1579        import {runSync, addSignalListener, removeSignalListener} from 'avril:std';
1580        runSync(SH, {args:['-c','exit 0']});
1581        const onTerm = () => {
1582            removeSignalListener('SIGTERM', onTerm);
1583            console.log('survived');
1584        };
1585        addSignalListener('SIGTERM', onTerm);
1586        console.log('ready');
1587        setInterval(() => {}, 1000);
1588    "#,
1589    );
1590    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1591    assert_eq!(line(&mut stdout), "ready");
1592    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1593    assert_eq!(line(&mut stdout), "survived");
1594    child.stdout = Some(stdout.into_inner());
1595    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1596    let output = common::wait(child);
1597    assert_eq!(
1598        output.status.signal(),
1599        Some(libc::SIGTERM),
1600        "{}",
1601        String::from_utf8_lossy(&output.stderr)
1602    );
1603}
1604
1605#[test]
1606fn jailed_worker_bindings_do_not_suppress_death() {
1607    use 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.
1611    let fixture = Fixture::new();
1612    fs::write(
1613        fixture.dir.path().join("worker.js"),
1614        r#"
1615        import {addSignalListener} from 'avril:std';
1616        addSignalListener('SIGINT', () => {});
1617        addSignalListener('SIGTERM', () => {});
1618        postMessage('worker-ready');
1619        setInterval(() => {}, 1000);
1620    "#,
1621    )
1622    .unwrap();
1623    let entry = r#"
1624        import {runSync, avril} from 'avril:std';
1625        runSync(SH, {args:['-c','exit 0']});
1626        const worker = new Worker('./worker.js', {type:'module', [avril]: {caps: {net: false, read: false, write: false, env: false, run: false}}});
1627        worker.onmessage = (event) => console.log(event.data);
1628        console.log('ready');
1629        setInterval(() => {}, 1000);
1630    "#;
1631    // SIGINT kills despite the worker's binding.
1632    let mut child = fixture.interact(entry);
1633    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1634    let mut first = [line(&mut stdout), line(&mut stdout)];
1635    first.sort();
1636    assert_eq!(first, ["ready", "worker-ready"]);
1637    child.stdout = Some(stdout.into_inner());
1638    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGINT) }, 0);
1639    let output = common::wait(child);
1640    assert_eq!(
1641        output.status.signal(),
1642        Some(libc::SIGINT),
1643        "{}",
1644        String::from_utf8_lossy(&output.stderr)
1645    );
1646    // So does SIGTERM.
1647    let mut child = fixture.interact(entry);
1648    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1649    let mut first = [line(&mut stdout), line(&mut stdout)];
1650    first.sort();
1651    assert_eq!(first, ["ready", "worker-ready"]);
1652    child.stdout = Some(stdout.into_inner());
1653    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1654    let output = common::wait(child);
1655    assert_eq!(
1656        output.status.signal(),
1657        Some(libc::SIGTERM),
1658        "{}",
1659        String::from_utf8_lossy(&output.stderr)
1660    );
1661}
1662
1663#[test]
1664fn worker_receives_while_entry_suppresses() {
1665    use std::os::unix::process::ExitStatusExt;
1666    // Broadcast is unchanged: the worker observes the USR1 and TERM the
1667    // entry listener survives.
1668    let fixture = Fixture::new();
1669    fs::write(
1670        fixture.dir.path().join("worker.js"),
1671        r#"
1672        import {addSignalListener} from 'avril:std';
1673        addSignalListener('SIGUSR1', () => postMessage('worker-got-usr1'));
1674        addSignalListener('SIGTERM', () => postMessage('worker-got-term'));
1675        postMessage('worker-ready');
1676    "#,
1677    )
1678    .unwrap();
1679    let mut child = fixture.interact(
1680        r#"
1681        import {addSignalListener} from 'avril:std';
1682        const worker = new Worker('./worker.js', {type:'module'});
1683        worker.onmessage = (event) => console.log(event.data);
1684        addSignalListener('SIGUSR1', () => console.log('entry-got-usr1'));
1685        addSignalListener('SIGTERM', () => console.log('entry-got-term'));
1686        console.log('ready');
1687    "#,
1688    );
1689    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1690    let mut first = [line(&mut stdout), line(&mut stdout)];
1691    first.sort();
1692    assert_eq!(first, ["ready", "worker-ready"]);
1693    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1694    let mut second = [line(&mut stdout), line(&mut stdout)];
1695    second.sort();
1696    assert_eq!(second, ["entry-got-usr1", "worker-got-usr1"]);
1697    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1698    let mut third = [line(&mut stdout), line(&mut stdout)];
1699    third.sort();
1700    assert_eq!(third, ["entry-got-term", "worker-got-term"]);
1701    child.stdout = Some(stdout.into_inner());
1702    child.kill().unwrap();
1703    let output = common::wait(child);
1704    assert_eq!(output.status.signal(), Some(libc::SIGKILL));
1705}
1706
1707#[test]
1708fn entry_unbind_while_worker_bound_restores_death() {
1709    use 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.
1712    let fixture = Fixture::new();
1713    fs::write(
1714        fixture.dir.path().join("worker.js"),
1715        r#"
1716        import {addSignalListener} from 'avril:std';
1717        addSignalListener('SIGUSR1', () => {});
1718        postMessage('worker-ready');
1719        setInterval(() => {}, 1000);
1720    "#,
1721    )
1722    .unwrap();
1723    let mut child = fixture.interact(
1724        r#"
1725        import {addSignalListener, removeSignalListener} from 'avril:std';
1726        const worker = new Worker('./worker.js', {type:'module'});
1727        worker.onmessage = (event) => console.log(event.data);
1728        const onUsr = () => {
1729            removeSignalListener('SIGUSR1', onUsr);
1730            console.log('unbound');
1731        };
1732        addSignalListener('SIGUSR1', onUsr);
1733        console.log('ready');
1734        setInterval(() => {}, 1000);
1735    "#,
1736    );
1737    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1738    let mut first = [line(&mut stdout), line(&mut stdout)];
1739    first.sort();
1740    assert_eq!(first, ["ready", "worker-ready"]);
1741    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1742    assert_eq!(line(&mut stdout), "unbound");
1743    child.stdout = Some(stdout.into_inner());
1744    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGUSR1) }, 0);
1745    let output = common::wait(child);
1746    assert_eq!(
1747        output.status.signal(),
1748        Some(libc::SIGUSR1),
1749        "{}",
1750        String::from_utf8_lossy(&output.stderr)
1751    );
1752}
1753
1754#[test]
1755fn worker_only_sigwinch_is_received() {
1756    use std::os::unix::process::ExitStatusExt;
1757    // SIGWINCH never kills, so a worker-only binding hears it with no
1758    // entry listener at all.
1759    let fixture = Fixture::new();
1760    fs::write(
1761        fixture.dir.path().join("worker.js"),
1762        r#"
1763        import {addSignalListener} from 'avril:std';
1764        addSignalListener('SIGWINCH', () => postMessage('worker-got-winch'));
1765        postMessage('worker-ready');
1766    "#,
1767    )
1768    .unwrap();
1769    let mut child = fixture.interact(
1770        r#"
1771        const worker = new Worker('./worker.js', {type:'module'});
1772        worker.onmessage = (event) => console.log(event.data);
1773        console.log('ready');
1774        setInterval(() => {}, 1000);
1775    "#,
1776    );
1777    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1778    let mut first = [line(&mut stdout), line(&mut stdout)];
1779    first.sort();
1780    assert_eq!(first, ["ready", "worker-ready"]);
1781    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGWINCH) }, 0);
1782    assert_eq!(line(&mut stdout), "worker-got-winch");
1783    child.stdout = Some(stdout.into_inner());
1784    child.kill().unwrap();
1785    let output = common::wait(child);
1786    assert_eq!(output.status.signal(), Some(libc::SIGKILL));
1787}
1788
1789#[test]
1790fn ignored_hup_reaches_worker_only_listener() {
1791    use std::os::unix::process::ExitStatusExt;
1792    // An inherited SIG_IGN keeps the process alive, and the worker-only
1793    // listener still observes the delivery.
1794    let fixture = Fixture::new();
1795    fs::write(
1796        fixture.dir.path().join("worker.js"),
1797        r#"
1798        import {addSignalListener} from 'avril:std';
1799        addSignalListener('SIGHUP', () => postMessage('worker-got-hup'));
1800        postMessage('worker-ready');
1801    "#,
1802    )
1803    .unwrap();
1804    fs::write(
1805        fixture.dir.path().join("main.js"),
1806        r#"
1807        const worker = new Worker('./worker.js', {type:'module'});
1808        worker.onmessage = (event) => console.log(event.data);
1809        console.log('ready');
1810        setInterval(() => {}, 1000);
1811    "#,
1812    )
1813    .unwrap();
1814    let 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());
1822    unsafe {
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);
1827            Ok(())
1828        });
1829    }
1830    let mut child = command.spawn().unwrap();
1831    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1832    let mut first = [line(&mut stdout), line(&mut stdout)];
1833    first.sort();
1834    assert_eq!(first, ["ready", "worker-ready"]);
1835    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGHUP) }, 0);
1836    assert_eq!(line(&mut stdout), "worker-got-hup");
1837    child.stdout = Some(stdout.into_inner());
1838    std::thread::sleep(Duration::from_millis(300));
1839    assert!(
1840        child.try_wait().unwrap().is_none(),
1841        "ignored SIGHUP killed the process"
1842    );
1843    child.kill().unwrap();
1844    let output = common::wait(child);
1845    assert_eq!(output.status.signal(), Some(libc::SIGKILL));
1846}
1847
1848#[test]
1849fn serve_term_drain_reaches_worker_listener() {
1850    use std::io::{BufRead, BufReader, Read, Write};
1851    use 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.
1854    let fixture = Fixture::new();
1855    fs::write(
1856        fixture.dir.path().join("worker.js"),
1857        r#"
1858        import {addSignalListener} from 'avril:std';
1859        addSignalListener('SIGTERM', () => postMessage('worker-got-term'));
1860        postMessage('worker-ready');
1861    "#,
1862    )
1863    .unwrap();
1864    fs::write(
1865        fixture.dir.path().join("main.js"),
1866        r#"
1867        const worker = new Worker('./worker.js', {type:'module'});
1868        worker.onmessage = (event) => console.log(event.data);
1869        console.log('ready');
1870        export default {async fetch() {
1871            console.log('child-started');
1872            await new Promise(resolve => setTimeout(resolve, 300));
1873            return new Response('done');
1874        }};
1875    "#,
1876    )
1877    .unwrap();
1878    let socket = fixture.dir.path().join("http.sock");
1879    let 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();
1889    let mut stderr = BufReader::new(child.stderr.take().unwrap());
1890    let mut text = String::new();
1891    stderr.read_line(&mut text).unwrap();
1892    assert!(text.starts_with("Listening on "), "{text}");
1893    child.stderr = Some(stderr.into_inner());
1894    let 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();
1901    let mut stdout = BufReader::new(child.stdout.take().unwrap());
1902    let mut started = [String::new(), String::new(), String::new()];
1903    for line in 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();
1910    assert_eq!(started, ["child-started", "ready", "worker-ready"]);
1911    child.stdout = Some(stdout.into_inner());
1912    assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0);
1913    let mut response = String::new();
1914    connection.read_to_string(&mut response).unwrap();
1915    let output = common::wait(child);
1916    assert!(
1917        output.status.success(),
1918        "{}",
1919        String::from_utf8_lossy(&output.stderr)
1920    );
1921    assert!(response.starts_with("HTTP/1.1 200"), "{response}");
1922    assert!(response.ends_with("done"), "{response}");
1923    let stdout = String::from_utf8(output.stdout).unwrap();
1924    assert!(stdout.contains("worker-got-term"), "{stdout}");
1925    assert!(!socket.exists(), "socket left behind after shutdown");
1926}