Skip to main content

shape_runtime/stdlib_io/
process_ops.rs

1//! Process operation implementations for the io module.
2//!
3//! Exports: spawn, exec, process_wait, process_kill, process_write,
4//!          process_read, process_read_err, process_read_line
5
6use shape_value::ValueWord;
7use shape_value::heap_value::{IoHandleData, IoResource};
8use std::io::{BufRead, BufReader, Read, Write};
9use std::process::{Command, Stdio};
10use std::sync::Arc;
11
12/// io.spawn(cmd, args?) -> IoHandle (ChildProcess)
13///
14/// Spawn a subprocess with piped stdin/stdout/stderr.
15/// Returns a process handle. Use process_read/process_write/process_wait on it.
16pub fn io_spawn(
17    args: &[ValueWord],
18    ctx: &crate::module_exports::ModuleContext,
19) -> Result<ValueWord, String> {
20    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
21    let cmd = args
22        .first()
23        .and_then(|a| a.as_str())
24        .ok_or_else(|| "io.spawn() requires a command string".to_string())?;
25
26    let mut command = Command::new(cmd);
27
28    // Optional args array
29    if let Some(view) = args.get(1).and_then(|a| a.as_any_array()) {
30        let arr = view.to_generic();
31        for arg in arr.iter() {
32            if let Some(s) = arg.as_str() {
33                command.arg(s);
34            }
35        }
36    }
37
38    command
39        .stdin(Stdio::piped())
40        .stdout(Stdio::piped())
41        .stderr(Stdio::piped());
42
43    let child = command
44        .spawn()
45        .map_err(|e| format!("io.spawn(\"{}\"): {}", cmd, e))?;
46
47    let handle = IoHandleData::new_child_process(child, cmd.to_string());
48    Ok(ValueWord::from_io_handle(handle))
49}
50
51/// io.exec(cmd, args?) -> object { status: int, stdout: string, stderr: string }
52///
53/// Run a command to completion and capture its output.
54pub fn io_exec(
55    args: &[ValueWord],
56    ctx: &crate::module_exports::ModuleContext,
57) -> Result<ValueWord, String> {
58    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
59    let cmd = args
60        .first()
61        .and_then(|a| a.as_str())
62        .ok_or_else(|| "io.exec() requires a command string".to_string())?;
63
64    let mut command = Command::new(cmd);
65
66    if let Some(view) = args.get(1).and_then(|a| a.as_any_array()) {
67        let arr = view.to_generic();
68        for arg in arr.iter() {
69            if let Some(s) = arg.as_str() {
70                command.arg(s);
71            }
72        }
73    }
74
75    let output = command
76        .output()
77        .map_err(|e| format!("io.exec(\"{}\"): {}", cmd, e))?;
78
79    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
80    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
81    let status = output.status.code().unwrap_or(-1) as i64;
82
83    let pairs: Vec<(&str, ValueWord)> = vec![
84        ("status", ValueWord::from_i64(status)),
85        ("stdout", ValueWord::from_string(Arc::new(stdout))),
86        ("stderr", ValueWord::from_string(Arc::new(stderr))),
87    ];
88    Ok(crate::type_schema::typed_object_from_pairs(&pairs))
89}
90
91/// io.process_wait(handle) -> int (exit code)
92///
93/// Wait for a child process to exit and return its exit code.
94pub fn io_process_wait(
95    args: &[ValueWord],
96    ctx: &crate::module_exports::ModuleContext,
97) -> Result<ValueWord, String> {
98    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
99    let handle = args
100        .first()
101        .and_then(|a| a.as_io_handle())
102        .ok_or_else(|| "io.process_wait() requires a ChildProcess IoHandle".to_string())?;
103
104    let mut guard = handle
105        .resource
106        .lock()
107        .map_err(|_| "io.process_wait(): lock poisoned".to_string())?;
108    let resource = guard
109        .as_mut()
110        .ok_or_else(|| "io.process_wait(): handle is closed".to_string())?;
111
112    match resource {
113        IoResource::ChildProcess(child) => {
114            let status = child
115                .wait()
116                .map_err(|e| format!("io.process_wait(): {}", e))?;
117            Ok(ValueWord::from_i64(status.code().unwrap_or(-1) as i64))
118        }
119        _ => Err("io.process_wait(): handle is not a ChildProcess".to_string()),
120    }
121}
122
123/// io.process_kill(handle) -> unit
124///
125/// Kill a running child process.
126pub fn io_process_kill(
127    args: &[ValueWord],
128    ctx: &crate::module_exports::ModuleContext,
129) -> Result<ValueWord, String> {
130    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
131    let handle = args
132        .first()
133        .and_then(|a| a.as_io_handle())
134        .ok_or_else(|| "io.process_kill() requires a ChildProcess IoHandle".to_string())?;
135
136    let mut guard = handle
137        .resource
138        .lock()
139        .map_err(|_| "io.process_kill(): lock poisoned".to_string())?;
140    let resource = guard
141        .as_mut()
142        .ok_or_else(|| "io.process_kill(): handle is closed".to_string())?;
143
144    match resource {
145        IoResource::ChildProcess(child) => {
146            child
147                .kill()
148                .map_err(|e| format!("io.process_kill(): {}", e))?;
149            Ok(ValueWord::unit())
150        }
151        _ => Err("io.process_kill(): handle is not a ChildProcess".to_string()),
152    }
153}
154
155/// io.process_write(handle, data) -> int (bytes written)
156///
157/// Write to a child process's stdin. Takes the process handle directly;
158/// extracts the stdin pipe internally.
159pub fn io_process_write(
160    args: &[ValueWord],
161    ctx: &crate::module_exports::ModuleContext,
162) -> Result<ValueWord, String> {
163    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
164    let handle = args
165        .first()
166        .and_then(|a| a.as_io_handle())
167        .ok_or_else(|| "io.process_write() requires a ChildProcess IoHandle".to_string())?;
168
169    let data = args
170        .get(1)
171        .and_then(|a| a.as_str())
172        .ok_or_else(|| "io.process_write() requires a string as second argument".to_string())?;
173
174    let mut guard = handle
175        .resource
176        .lock()
177        .map_err(|_| "io.process_write(): lock poisoned".to_string())?;
178    let resource = guard
179        .as_mut()
180        .ok_or_else(|| "io.process_write(): handle is closed".to_string())?;
181
182    match resource {
183        IoResource::ChildProcess(child) => {
184            let stdin = child
185                .stdin
186                .as_mut()
187                .ok_or_else(|| "io.process_write(): stdin pipe not available".to_string())?;
188            let written = stdin
189                .write(data.as_bytes())
190                .map_err(|e| format!("io.process_write(): {}", e))?;
191            Ok(ValueWord::from_i64(written as i64))
192        }
193        IoResource::PipeWriter(stdin) => {
194            let written = stdin
195                .write(data.as_bytes())
196                .map_err(|e| format!("io.process_write(): {}", e))?;
197            Ok(ValueWord::from_i64(written as i64))
198        }
199        _ => Err("io.process_write(): handle is not a ChildProcess or PipeWriter".to_string()),
200    }
201}
202
203/// io.process_read(handle, n?) -> string
204///
205/// Read from a child process's stdout. If n is given, read up to n bytes.
206pub fn io_process_read(
207    args: &[ValueWord],
208    ctx: &crate::module_exports::ModuleContext,
209) -> Result<ValueWord, String> {
210    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
211    let handle = args
212        .first()
213        .and_then(|a| a.as_io_handle())
214        .ok_or_else(|| "io.process_read() requires a ChildProcess IoHandle".to_string())?;
215
216    let n = args
217        .get(1)
218        .and_then(|a| a.as_number_coerce())
219        .unwrap_or(65536.0) as usize;
220
221    let mut guard = handle
222        .resource
223        .lock()
224        .map_err(|_| "io.process_read(): lock poisoned".to_string())?;
225    let resource = guard
226        .as_mut()
227        .ok_or_else(|| "io.process_read(): handle is closed".to_string())?;
228
229    match resource {
230        IoResource::ChildProcess(child) => {
231            let stdout = child
232                .stdout
233                .as_mut()
234                .ok_or_else(|| "io.process_read(): stdout pipe not available".to_string())?;
235            let mut buf = vec![0u8; n];
236            let bytes_read = stdout
237                .read(&mut buf)
238                .map_err(|e| format!("io.process_read(): {}", e))?;
239            buf.truncate(bytes_read);
240            let s = String::from_utf8(buf)
241                .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?;
242            Ok(ValueWord::from_string(Arc::new(s)))
243        }
244        IoResource::PipeReader(stdout) => {
245            let mut buf = vec![0u8; n];
246            let bytes_read = stdout
247                .read(&mut buf)
248                .map_err(|e| format!("io.process_read(): {}", e))?;
249            buf.truncate(bytes_read);
250            let s = String::from_utf8(buf)
251                .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?;
252            Ok(ValueWord::from_string(Arc::new(s)))
253        }
254        _ => Err("io.process_read(): handle is not a ChildProcess or PipeReader".to_string()),
255    }
256}
257
258/// io.process_read_err(handle, n?) -> string
259///
260/// Read from a child process's stderr.
261pub fn io_process_read_err(
262    args: &[ValueWord],
263    ctx: &crate::module_exports::ModuleContext,
264) -> Result<ValueWord, String> {
265    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
266    let handle = args
267        .first()
268        .and_then(|a| a.as_io_handle())
269        .ok_or_else(|| "io.process_read_err() requires a ChildProcess IoHandle".to_string())?;
270
271    let n = args
272        .get(1)
273        .and_then(|a| a.as_number_coerce())
274        .unwrap_or(65536.0) as usize;
275
276    let mut guard = handle
277        .resource
278        .lock()
279        .map_err(|_| "io.process_read_err(): lock poisoned".to_string())?;
280    let resource = guard
281        .as_mut()
282        .ok_or_else(|| "io.process_read_err(): handle is closed".to_string())?;
283
284    match resource {
285        IoResource::ChildProcess(child) => {
286            let stderr = child
287                .stderr
288                .as_mut()
289                .ok_or_else(|| "io.process_read_err(): stderr pipe not available".to_string())?;
290            let mut buf = vec![0u8; n];
291            let bytes_read = stderr
292                .read(&mut buf)
293                .map_err(|e| format!("io.process_read_err(): {}", e))?;
294            buf.truncate(bytes_read);
295            let s = String::from_utf8(buf)
296                .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?;
297            Ok(ValueWord::from_string(Arc::new(s)))
298        }
299        IoResource::PipeReaderErr(stderr) => {
300            let mut buf = vec![0u8; n];
301            let bytes_read = stderr
302                .read(&mut buf)
303                .map_err(|e| format!("io.process_read_err(): {}", e))?;
304            buf.truncate(bytes_read);
305            let s = String::from_utf8(buf)
306                .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?;
307            Ok(ValueWord::from_string(Arc::new(s)))
308        }
309        _ => {
310            Err("io.process_read_err(): handle is not a ChildProcess or PipeReaderErr".to_string())
311        }
312    }
313}
314
315/// io.process_read_line(handle) -> string
316///
317/// Read a single line from a child process's stdout (including newline).
318pub fn io_process_read_line(
319    args: &[ValueWord],
320    ctx: &crate::module_exports::ModuleContext,
321) -> Result<ValueWord, String> {
322    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
323    let handle = args
324        .first()
325        .and_then(|a| a.as_io_handle())
326        .ok_or_else(|| "io.process_read_line() requires a ChildProcess IoHandle".to_string())?;
327
328    let mut guard = handle
329        .resource
330        .lock()
331        .map_err(|_| "io.process_read_line(): lock poisoned".to_string())?;
332    let resource = guard
333        .as_mut()
334        .ok_or_else(|| "io.process_read_line(): handle is closed".to_string())?;
335
336    match resource {
337        IoResource::ChildProcess(child) => {
338            let stdout = child
339                .stdout
340                .as_mut()
341                .ok_or_else(|| "io.process_read_line(): stdout pipe not available".to_string())?;
342            let mut line = String::new();
343            BufReader::new(stdout)
344                .read_line(&mut line)
345                .map_err(|e| format!("io.process_read_line(): {}", e))?;
346            Ok(ValueWord::from_string(Arc::new(line)))
347        }
348        IoResource::PipeReader(stdout) => {
349            let mut line = String::new();
350            BufReader::new(stdout)
351                .read_line(&mut line)
352                .map_err(|e| format!("io.process_read_line(): {}", e))?;
353            Ok(ValueWord::from_string(Arc::new(line)))
354        }
355        _ => Err("io.process_read_line(): handle is not a ChildProcess or PipeReader".to_string()),
356    }
357}
358
359/// io.stdin() -> IoHandle
360///
361/// Return an IoHandle for the current process's standard input.
362pub fn io_stdin(
363    _args: &[ValueWord],
364    ctx: &crate::module_exports::ModuleContext,
365) -> Result<ValueWord, String> {
366    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
367    let file = std::fs::OpenOptions::new()
368        .read(true)
369        .open("/dev/stdin")
370        .map_err(|e| format!("io.stdin(): {}", e))?;
371    let handle = IoHandleData::new_file(file, "/dev/stdin".to_string(), "r".to_string());
372    Ok(ValueWord::from_io_handle(handle))
373}
374
375/// io.stdout() -> IoHandle
376///
377/// Return an IoHandle for the current process's standard output.
378pub fn io_stdout(
379    _args: &[ValueWord],
380    ctx: &crate::module_exports::ModuleContext,
381) -> Result<ValueWord, String> {
382    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
383    let file = std::fs::OpenOptions::new()
384        .write(true)
385        .open("/dev/stdout")
386        .map_err(|e| format!("io.stdout(): {}", e))?;
387    let handle = IoHandleData::new_file(file, "/dev/stdout".to_string(), "w".to_string());
388    Ok(ValueWord::from_io_handle(handle))
389}
390
391/// io.stderr() -> IoHandle
392///
393/// Return an IoHandle for the current process's standard error.
394pub fn io_stderr(
395    _args: &[ValueWord],
396    ctx: &crate::module_exports::ModuleContext,
397) -> Result<ValueWord, String> {
398    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
399    let file = std::fs::OpenOptions::new()
400        .write(true)
401        .open("/dev/stderr")
402        .map_err(|e| format!("io.stderr(): {}", e))?;
403    let handle = IoHandleData::new_file(file, "/dev/stderr".to_string(), "w".to_string());
404    Ok(ValueWord::from_io_handle(handle))
405}
406
407/// io.read_line(handle?) -> string
408///
409/// Read a line from an IoHandle (file or pipe). If no handle is given,
410/// reads from the current process's stdin.
411pub fn io_read_line(
412    args: &[ValueWord],
413    ctx: &crate::module_exports::ModuleContext,
414) -> Result<ValueWord, String> {
415    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
416    // If a handle argument is provided, read from it
417    if let Some(handle) = args.first().and_then(|a| a.as_io_handle()) {
418        let mut guard = handle
419            .resource
420            .lock()
421            .map_err(|_| "io.read_line(): lock poisoned".to_string())?;
422        let resource = guard
423            .as_mut()
424            .ok_or_else(|| "io.read_line(): handle is closed".to_string())?;
425
426        match resource {
427            IoResource::File(file) => {
428                let mut line = String::new();
429                BufReader::new(file)
430                    .read_line(&mut line)
431                    .map_err(|e| format!("io.read_line(): {}", e))?;
432                Ok(ValueWord::from_string(Arc::new(line)))
433            }
434            IoResource::ChildProcess(child) => {
435                let stdout = child
436                    .stdout
437                    .as_mut()
438                    .ok_or_else(|| "io.read_line(): stdout pipe not available".to_string())?;
439                let mut line = String::new();
440                BufReader::new(stdout)
441                    .read_line(&mut line)
442                    .map_err(|e| format!("io.read_line(): {}", e))?;
443                Ok(ValueWord::from_string(Arc::new(line)))
444            }
445            IoResource::PipeReader(stdout) => {
446                let mut line = String::new();
447                BufReader::new(stdout)
448                    .read_line(&mut line)
449                    .map_err(|e| format!("io.read_line(): {}", e))?;
450                Ok(ValueWord::from_string(Arc::new(line)))
451            }
452            _ => Err("io.read_line(): handle does not support reading".to_string()),
453        }
454    } else {
455        // No handle: read from current process stdin
456        let mut line = String::new();
457        std::io::stdin()
458            .read_line(&mut line)
459            .map_err(|e| format!("io.read_line(): {}", e))?;
460        Ok(ValueWord::from_string(Arc::new(line)))
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
469        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
470        crate::module_exports::ModuleContext {
471            schemas: registry,
472            invoke_callable: None,
473            raw_invoker: None,
474            function_hashes: None,
475            vm_state: None,
476            granted_permissions: None,
477            scope_constraints: None,
478            set_pending_resume: None,
479            set_pending_frame_resume: None,
480        }
481    }
482
483    #[test]
484    fn test_exec_echo() {
485        let ctx = test_ctx();
486        let result = io_exec(
487            &[
488                ValueWord::from_string(Arc::new("echo".to_string())),
489                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
490                    "hello world".to_string(),
491                ))])),
492            ],
493            &ctx,
494        )
495        .unwrap();
496        assert_eq!(result.type_name(), "object");
497    }
498
499    #[test]
500    fn test_exec_false() {
501        let ctx = test_ctx();
502        let result = io_exec(
503            &[ValueWord::from_string(Arc::new("false".to_string()))],
504            &ctx,
505        )
506        .unwrap();
507        assert_eq!(result.type_name(), "object");
508    }
509
510    #[test]
511    fn test_exec_nonexistent() {
512        let ctx = test_ctx();
513        let result = io_exec(
514            &[ValueWord::from_string(Arc::new(
515                "__nonexistent_command_xyz__".to_string(),
516            ))],
517            &ctx,
518        );
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn test_spawn_and_wait() {
524        let ctx = test_ctx();
525        let handle = io_spawn(
526            &[
527                ValueWord::from_string(Arc::new("echo".to_string())),
528                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
529                    "test".to_string(),
530                ))])),
531            ],
532            &ctx,
533        )
534        .unwrap();
535        assert_eq!(handle.type_name(), "io_handle");
536
537        let code = io_process_wait(&[handle.clone()], &ctx).unwrap();
538        assert_eq!(code.as_number_coerce(), Some(0.0));
539    }
540
541    #[test]
542    fn test_spawn_read_stdout() {
543        let ctx = test_ctx();
544        let handle = io_spawn(
545            &[
546                ValueWord::from_string(Arc::new("echo".to_string())),
547                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
548                    "hello from process".to_string(),
549                ))])),
550            ],
551            &ctx,
552        )
553        .unwrap();
554
555        // Wait for process to finish first
556        io_process_wait(&[handle.clone()], &ctx).unwrap();
557
558        let output = io_process_read(&[handle.clone()], &ctx).unwrap();
559        let text = output.as_str().unwrap();
560        assert!(text.contains("hello from process"));
561
562        handle.as_io_handle().unwrap().close();
563    }
564
565    #[test]
566    fn test_spawn_write_stdin() {
567        let ctx = test_ctx();
568        // Use `cat` which echoes stdin to stdout
569        let handle =
570            io_spawn(&[ValueWord::from_string(Arc::new("cat".to_string()))], &ctx).unwrap();
571
572        // Write to stdin
573        let written = io_process_write(
574            &[
575                handle.clone(),
576                ValueWord::from_string(Arc::new("input data".to_string())),
577            ],
578            &ctx,
579        )
580        .unwrap();
581        assert!(written.as_number_coerce().unwrap() > 0.0);
582
583        // Close stdin to signal EOF to cat (drop the stdin pipe)
584        {
585            let h = handle.as_io_handle().unwrap();
586            let mut guard = h.resource.lock().unwrap();
587            if let Some(IoResource::ChildProcess(child)) = guard.as_mut() {
588                drop(child.stdin.take());
589            }
590        }
591
592        // Wait for process to finish
593        io_process_wait(&[handle.clone()], &ctx).unwrap();
594
595        // Read stdout
596        let output = io_process_read(&[handle.clone()], &ctx).unwrap();
597        assert_eq!(output.as_str().unwrap(), "input data");
598
599        handle.as_io_handle().unwrap().close();
600    }
601
602    #[test]
603    fn test_spawn_kill() {
604        let ctx = test_ctx();
605        // Start a long-running process
606        let handle = io_spawn(
607            &[
608                ValueWord::from_string(Arc::new("sleep".to_string())),
609                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
610                    "60".to_string(),
611                ))])),
612            ],
613            &ctx,
614        )
615        .unwrap();
616
617        // Kill it
618        let result = io_process_kill(&[handle.clone()], &ctx);
619        assert!(result.is_ok());
620
621        // Wait should return non-zero (signal)
622        let code = io_process_wait(&[handle.clone()], &ctx).unwrap();
623        // Killed processes typically have non-zero exit
624        let exit_code = code.as_number_coerce().unwrap() as i64;
625        assert!(exit_code != 0 || exit_code == -1);
626
627        handle.as_io_handle().unwrap().close();
628    }
629
630    #[test]
631    fn test_spawn_read_stderr() {
632        let ctx = test_ctx();
633        // Use a command that writes to stderr
634        let handle = io_spawn(
635            &[
636                ValueWord::from_string(Arc::new("sh".to_string())),
637                ValueWord::from_array(Arc::new(vec![
638                    ValueWord::from_string(Arc::new("-c".to_string())),
639                    ValueWord::from_string(Arc::new("echo error_msg >&2".to_string())),
640                ])),
641            ],
642            &ctx,
643        )
644        .unwrap();
645
646        io_process_wait(&[handle.clone()], &ctx).unwrap();
647
648        let err_output = io_process_read_err(&[handle.clone()], &ctx).unwrap();
649        let text = err_output.as_str().unwrap();
650        assert!(text.contains("error_msg"));
651
652        handle.as_io_handle().unwrap().close();
653    }
654
655    #[test]
656    fn test_stdout_handle() {
657        let ctx = test_ctx();
658        let handle = io_stdout(&[], &ctx).unwrap();
659        assert_eq!(handle.type_name(), "io_handle");
660        let data = handle.as_io_handle().unwrap();
661        assert_eq!(data.path, "/dev/stdout");
662        assert_eq!(data.mode, "w");
663        data.close();
664    }
665
666    #[test]
667    fn test_stderr_handle() {
668        let ctx = test_ctx();
669        let handle = io_stderr(&[], &ctx).unwrap();
670        assert_eq!(handle.type_name(), "io_handle");
671        let data = handle.as_io_handle().unwrap();
672        assert_eq!(data.path, "/dev/stderr");
673        assert_eq!(data.mode, "w");
674        data.close();
675    }
676
677    #[test]
678    fn test_read_line_from_pipe() {
679        let ctx = test_ctx();
680        // Spawn echo to produce a line, then read_line from the process handle
681        let handle = io_spawn(
682            &[
683                ValueWord::from_string(Arc::new("echo".to_string())),
684                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
685                    "line output".to_string(),
686                ))])),
687            ],
688            &ctx,
689        )
690        .unwrap();
691
692        io_process_wait(&[handle.clone()], &ctx).unwrap();
693
694        let line = io_process_read_line(&[handle.clone()], &ctx).unwrap();
695        assert!(line.as_str().unwrap().contains("line output"));
696
697        handle.as_io_handle().unwrap().close();
698    }
699}