Skip to main content

shape_runtime/stdlib_io/
process_ops.rs

1//! Process operation implementations for the io module.
2//!
3//! Phase 2d migration: ported to the typed marshal layer.
4//! - Cluster #2 option γ for IoHandle-touching functions
5//!   (`spawn` / `exec` / `process_*` / `read_line`).
6//! - Phase 2d Array cluster (this commit, 2026-05-07): the optional
7//!   `args: Array<string>` parameter on `io.spawn` / `io.exec` flows
8//!   through `Vec<Arc<String>>` via the new `FromSlot` impl, which is
9//!   the leaf decision that unblocked this migration.
10//!
11//! Exports: spawn, exec, process_wait, process_kill, process_write,
12//!          process_read, process_read_err, process_read_line,
13//!          stdin, stdout, stderr, read_line.
14//!
15//! Tests deferred — ValueWord-based test fixtures can't compile and
16//! aren't reconstructed until the shape-vm cascade provides a typed
17//! test harness, mirroring the file_ops migration in commit d716482.
18
19use crate::marshal::{
20    register_typed_fn_0, register_typed_fn_1, register_typed_fn_2_full,
21};
22use crate::module_exports::{ModuleExports, ModuleParam};
23use crate::typed_module_exports::{ConcreteReturn, ConcreteType, TypedReturn};
24use shape_value::heap_value::{HeapValue, IoHandleData, IoResource};
25use std::io::{BufRead, BufReader, Read, Write};
26use std::process::{Command, Stdio};
27use std::sync::Arc;
28
29/// Helper: build a `Command` with the given executable and optional
30/// `Array<string>` argument list. The args parameter comes through the
31/// marshal layer as `Vec<Arc<HeapValue>>` because the body declared its
32/// signature as `args?: Array<string>` — but we want to read each element
33/// as a string. We pattern-match the inner `HeapValue::TypedArray
34/// (TypedArrayData::String(...))` here since that's the runtime shape
35/// produced by Shape array literals at the call site. If a caller passed
36/// a different array shape (e.g. an array of typed objects) the body
37/// returns an error rather than panicking — this is a user-facing
38/// type-mismatch surface, not the marshal-kind contract violation that
39/// the FromSlot impl panics for.
40fn build_command_with_args(
41    cmd: &str,
42    args: Option<Vec<Arc<HeapValue>>>,
43    fn_name: &str,
44) -> Result<Command, String> {
45    let mut command = Command::new(cmd);
46    if let Some(arg_list) = args {
47        for elem in arg_list {
48            match &*elem {
49                HeapValue::String(s) => {
50                    command.arg(&**s);
51                }
52                other => {
53                    return Err(format!(
54                        "{}: each element of args must be a string, got {}",
55                        fn_name,
56                        other.type_name()
57                    ));
58                }
59            }
60        }
61    }
62    Ok(command)
63}
64
65/// Register the 12 process IO functions on the io module.
66/// Cluster #2 option γ + Phase 2d Array cluster per
67/// `docs/defections.md` 2026-05-07.
68pub fn register_process_io(module: &mut ModuleExports) {
69    // io.spawn(cmd: string, args?: Array<string>) -> IoHandle
70    register_typed_fn_2_full::<_, Arc<String>, Vec<Arc<HeapValue>>>(
71        module,
72        "spawn",
73        "Spawn a subprocess with piped stdin/stdout/stderr",
74        [
75            ModuleParam {
76                name: "cmd".to_string(),
77                type_name: "string".to_string(),
78                required: true,
79                description: "Command to run".to_string(),
80                ..Default::default()
81            },
82            ModuleParam {
83                name: "args".to_string(),
84                type_name: "Array<string>".to_string(),
85                required: false,
86                description: "Optional command arguments".to_string(),
87                default_snippet: Some("[]".to_string()),
88                ..Default::default()
89            },
90        ],
91        ConcreteType::IoHandle,
92        |cmd, args, ctx| {
93            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
94            let cmd_s = cmd.as_str();
95            let mut command = build_command_with_args(cmd_s, Some(args), "io.spawn()")?;
96            command
97                .stdin(Stdio::piped())
98                .stdout(Stdio::piped())
99                .stderr(Stdio::piped());
100            let child = command
101                .spawn()
102                .map_err(|e| format!("io.spawn(\"{}\"): {}", cmd_s, e))?;
103            let handle = IoHandleData::new_child_process(child, cmd_s.to_string());
104            Ok(TypedReturn::Concrete(ConcreteReturn::IoHandle(Arc::new(
105                handle,
106            ))))
107        },
108    );
109
110    // io.exec(cmd: string, args?: Array<string>) -> object { status: int, stdout: string, stderr: string }
111    register_typed_fn_2_full::<_, Arc<String>, Vec<Arc<HeapValue>>>(
112        module,
113        "exec",
114        "Run a command to completion and capture its output",
115        [
116            ModuleParam {
117                name: "cmd".to_string(),
118                type_name: "string".to_string(),
119                required: true,
120                description: "Command to run".to_string(),
121                ..Default::default()
122            },
123            ModuleParam {
124                name: "args".to_string(),
125                type_name: "Array<string>".to_string(),
126                required: false,
127                description: "Optional command arguments".to_string(),
128                default_snippet: Some("[]".to_string()),
129                ..Default::default()
130            },
131        ],
132        ConcreteType::TypedObject,
133        |cmd, args, ctx| {
134            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
135            let cmd_s = cmd.as_str();
136            let mut command = build_command_with_args(cmd_s, Some(args), "io.exec()")?;
137            let output = command
138                .output()
139                .map_err(|e| format!("io.exec(\"{}\"): {}", cmd_s, e))?;
140            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
141            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
142            let status = output.status.code().unwrap_or(-1) as i64;
143            Ok(TypedReturn::TypedObject(vec![
144                ("status".to_string(), ConcreteReturn::I64(status)),
145                ("stdout".to_string(), ConcreteReturn::String(stdout)),
146                ("stderr".to_string(), ConcreteReturn::String(stderr)),
147            ]))
148        },
149    );
150
151    // io.process_wait(handle: IoHandle) -> int (exit code)
152    register_typed_fn_1::<_, Arc<IoHandleData>>(
153        module,
154        "process_wait",
155        "Wait for a child process to exit and return its exit code",
156        "handle",
157        "IoHandle",
158        ConcreteType::Int,
159        |handle, ctx| {
160            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
161            let mut guard = handle
162                .resource
163                .lock()
164                .map_err(|_| "io.process_wait(): lock poisoned".to_string())?;
165            let resource = guard
166                .as_mut()
167                .ok_or_else(|| "io.process_wait(): handle is closed".to_string())?;
168            match resource {
169                IoResource::ChildProcess(child) => {
170                    let status = child
171                        .wait()
172                        .map_err(|e| format!("io.process_wait(): {}", e))?;
173                    Ok(TypedReturn::Concrete(ConcreteReturn::I64(
174                        status.code().unwrap_or(-1) as i64,
175                    )))
176                }
177                _ => Err("io.process_wait(): handle is not a ChildProcess".to_string()),
178            }
179        },
180    );
181
182    // io.process_kill(handle: IoHandle) -> unit
183    register_typed_fn_1::<_, Arc<IoHandleData>>(
184        module,
185        "process_kill",
186        "Kill a running child process",
187        "handle",
188        "IoHandle",
189        ConcreteType::Unit,
190        |handle, ctx| {
191            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
192            let mut guard = handle
193                .resource
194                .lock()
195                .map_err(|_| "io.process_kill(): lock poisoned".to_string())?;
196            let resource = guard
197                .as_mut()
198                .ok_or_else(|| "io.process_kill(): handle is closed".to_string())?;
199            match resource {
200                IoResource::ChildProcess(child) => {
201                    child
202                        .kill()
203                        .map_err(|e| format!("io.process_kill(): {}", e))?;
204                    Ok(TypedReturn::Concrete(ConcreteReturn::Unit))
205                }
206                _ => Err("io.process_kill(): handle is not a ChildProcess".to_string()),
207            }
208        },
209    );
210
211    // io.process_write(handle: IoHandle, data: string) -> int
212    register_typed_fn_2_full::<_, Arc<IoHandleData>, Arc<String>>(
213        module,
214        "process_write",
215        "Write to a child process's stdin, returning bytes written",
216        [
217            ModuleParam {
218                name: "handle".to_string(),
219                type_name: "IoHandle".to_string(),
220                required: true,
221                description: "ChildProcess or PipeWriter handle".to_string(),
222                ..Default::default()
223            },
224            ModuleParam {
225                name: "data".to_string(),
226                type_name: "string".to_string(),
227                required: true,
228                description: "Data to write".to_string(),
229                ..Default::default()
230            },
231        ],
232        ConcreteType::Int,
233        |handle, data, ctx| {
234            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
235            let mut guard = handle
236                .resource
237                .lock()
238                .map_err(|_| "io.process_write(): lock poisoned".to_string())?;
239            let resource = guard
240                .as_mut()
241                .ok_or_else(|| "io.process_write(): handle is closed".to_string())?;
242            match resource {
243                IoResource::ChildProcess(child) => {
244                    let stdin = child
245                        .stdin
246                        .as_mut()
247                        .ok_or_else(|| "io.process_write(): stdin pipe not available".to_string())?;
248                    let written = stdin
249                        .write(data.as_bytes())
250                        .map_err(|e| format!("io.process_write(): {}", e))?;
251                    Ok(TypedReturn::Concrete(ConcreteReturn::I64(written as i64)))
252                }
253                IoResource::PipeWriter(stdin) => {
254                    let written = stdin
255                        .write(data.as_bytes())
256                        .map_err(|e| format!("io.process_write(): {}", e))?;
257                    Ok(TypedReturn::Concrete(ConcreteReturn::I64(written as i64)))
258                }
259                _ => Err(
260                    "io.process_write(): handle is not a ChildProcess or PipeWriter".to_string(),
261                ),
262            }
263        },
264    );
265
266    // io.process_read(handle: IoHandle, n?: int) -> string
267    register_typed_fn_2_full::<_, Arc<IoHandleData>, i64>(
268        module,
269        "process_read",
270        "Read up to n bytes from a child process's stdout",
271        [
272            ModuleParam {
273                name: "handle".to_string(),
274                type_name: "IoHandle".to_string(),
275                required: true,
276                description: "ChildProcess or PipeReader handle".to_string(),
277                ..Default::default()
278            },
279            ModuleParam {
280                name: "n".to_string(),
281                type_name: "int".to_string(),
282                required: false,
283                description: "Max bytes to read (default: 65536)".to_string(),
284                default_snippet: Some("65536".to_string()),
285                ..Default::default()
286            },
287        ],
288        ConcreteType::String,
289        |handle, n, ctx| {
290            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
291            let buf_size = if n > 0 { n as usize } else { 65536 };
292            let mut guard = handle
293                .resource
294                .lock()
295                .map_err(|_| "io.process_read(): lock poisoned".to_string())?;
296            let resource = guard
297                .as_mut()
298                .ok_or_else(|| "io.process_read(): handle is closed".to_string())?;
299            let s = match resource {
300                IoResource::ChildProcess(child) => {
301                    let stdout = child
302                        .stdout
303                        .as_mut()
304                        .ok_or_else(|| "io.process_read(): stdout pipe not available".to_string())?;
305                    let mut buf = vec![0u8; buf_size];
306                    let bytes_read = stdout
307                        .read(&mut buf)
308                        .map_err(|e| format!("io.process_read(): {}", e))?;
309                    buf.truncate(bytes_read);
310                    String::from_utf8(buf)
311                        .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?
312                }
313                IoResource::PipeReader(stdout) => {
314                    let mut buf = vec![0u8; buf_size];
315                    let bytes_read = stdout
316                        .read(&mut buf)
317                        .map_err(|e| format!("io.process_read(): {}", e))?;
318                    buf.truncate(bytes_read);
319                    String::from_utf8(buf)
320                        .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?
321                }
322                _ => {
323                    return Err(
324                        "io.process_read(): handle is not a ChildProcess or PipeReader"
325                            .to_string(),
326                    );
327                }
328            };
329            Ok(TypedReturn::Concrete(ConcreteReturn::String(s)))
330        },
331    );
332
333    // io.process_read_err(handle: IoHandle, n?: int) -> string
334    register_typed_fn_2_full::<_, Arc<IoHandleData>, i64>(
335        module,
336        "process_read_err",
337        "Read up to n bytes from a child process's stderr",
338        [
339            ModuleParam {
340                name: "handle".to_string(),
341                type_name: "IoHandle".to_string(),
342                required: true,
343                description: "ChildProcess or PipeReaderErr handle".to_string(),
344                ..Default::default()
345            },
346            ModuleParam {
347                name: "n".to_string(),
348                type_name: "int".to_string(),
349                required: false,
350                description: "Max bytes to read (default: 65536)".to_string(),
351                default_snippet: Some("65536".to_string()),
352                ..Default::default()
353            },
354        ],
355        ConcreteType::String,
356        |handle, n, ctx| {
357            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
358            let buf_size = if n > 0 { n as usize } else { 65536 };
359            let mut guard = handle
360                .resource
361                .lock()
362                .map_err(|_| "io.process_read_err(): lock poisoned".to_string())?;
363            let resource = guard
364                .as_mut()
365                .ok_or_else(|| "io.process_read_err(): handle is closed".to_string())?;
366            let s = match resource {
367                IoResource::ChildProcess(child) => {
368                    let stderr = child.stderr.as_mut().ok_or_else(|| {
369                        "io.process_read_err(): stderr pipe not available".to_string()
370                    })?;
371                    let mut buf = vec![0u8; buf_size];
372                    let bytes_read = stderr
373                        .read(&mut buf)
374                        .map_err(|e| format!("io.process_read_err(): {}", e))?;
375                    buf.truncate(bytes_read);
376                    String::from_utf8(buf)
377                        .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?
378                }
379                IoResource::PipeReaderErr(stderr) => {
380                    let mut buf = vec![0u8; buf_size];
381                    let bytes_read = stderr
382                        .read(&mut buf)
383                        .map_err(|e| format!("io.process_read_err(): {}", e))?;
384                    buf.truncate(bytes_read);
385                    String::from_utf8(buf)
386                        .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?
387                }
388                _ => {
389                    return Err(
390                        "io.process_read_err(): handle is not a ChildProcess or PipeReaderErr"
391                            .to_string(),
392                    );
393                }
394            };
395            Ok(TypedReturn::Concrete(ConcreteReturn::String(s)))
396        },
397    );
398
399    // io.process_read_line(handle: IoHandle) -> string
400    register_typed_fn_1::<_, Arc<IoHandleData>>(
401        module,
402        "process_read_line",
403        "Read a single line from a child process's stdout (including newline)",
404        "handle",
405        "IoHandle",
406        ConcreteType::String,
407        |handle, ctx| {
408            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
409            let mut guard = handle
410                .resource
411                .lock()
412                .map_err(|_| "io.process_read_line(): lock poisoned".to_string())?;
413            let resource = guard
414                .as_mut()
415                .ok_or_else(|| "io.process_read_line(): handle is closed".to_string())?;
416            let line = match resource {
417                IoResource::ChildProcess(child) => {
418                    let stdout = child.stdout.as_mut().ok_or_else(|| {
419                        "io.process_read_line(): stdout pipe not available".to_string()
420                    })?;
421                    let mut line = String::new();
422                    BufReader::new(stdout)
423                        .read_line(&mut line)
424                        .map_err(|e| format!("io.process_read_line(): {}", e))?;
425                    line
426                }
427                IoResource::PipeReader(stdout) => {
428                    let mut line = String::new();
429                    BufReader::new(stdout)
430                        .read_line(&mut line)
431                        .map_err(|e| format!("io.process_read_line(): {}", e))?;
432                    line
433                }
434                _ => {
435                    return Err(
436                        "io.process_read_line(): handle is not a ChildProcess or PipeReader"
437                            .to_string(),
438                    );
439                }
440            };
441            Ok(TypedReturn::Concrete(ConcreteReturn::String(line)))
442        },
443    );
444
445    // io.stdin() -> IoHandle
446    register_typed_fn_0(
447        module,
448        "stdin",
449        "Return an IoHandle for the current process's standard input",
450        ConcreteType::IoHandle,
451        |ctx| {
452            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
453            let file = std::fs::OpenOptions::new()
454                .read(true)
455                .open("/dev/stdin")
456                .map_err(|e| format!("io.stdin(): {}", e))?;
457            let handle =
458                IoHandleData::new_file(file, "/dev/stdin".to_string(), "r".to_string());
459            Ok(TypedReturn::Concrete(ConcreteReturn::IoHandle(Arc::new(
460                handle,
461            ))))
462        },
463    );
464
465    // io.stdout() -> IoHandle
466    register_typed_fn_0(
467        module,
468        "stdout",
469        "Return an IoHandle for the current process's standard output",
470        ConcreteType::IoHandle,
471        |ctx| {
472            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
473            let file = std::fs::OpenOptions::new()
474                .write(true)
475                .open("/dev/stdout")
476                .map_err(|e| format!("io.stdout(): {}", e))?;
477            let handle =
478                IoHandleData::new_file(file, "/dev/stdout".to_string(), "w".to_string());
479            Ok(TypedReturn::Concrete(ConcreteReturn::IoHandle(Arc::new(
480                handle,
481            ))))
482        },
483    );
484
485    // io.stderr() -> IoHandle
486    register_typed_fn_0(
487        module,
488        "stderr",
489        "Return an IoHandle for the current process's standard error",
490        ConcreteType::IoHandle,
491        |ctx| {
492            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
493            let file = std::fs::OpenOptions::new()
494                .write(true)
495                .open("/dev/stderr")
496                .map_err(|e| format!("io.stderr(): {}", e))?;
497            let handle =
498                IoHandleData::new_file(file, "/dev/stderr".to_string(), "w".to_string());
499            Ok(TypedReturn::Concrete(ConcreteReturn::IoHandle(Arc::new(
500                handle,
501            ))))
502        },
503    );
504
505    // io.read_line(handle: IoHandle) -> string
506    //
507    // Reads a line from a file/pipe handle. The original optional-handle
508    // form (read from process stdin when no handle is provided) is left
509    // for a follow-up — varargs/no-arg fallback semantics interlock with
510    // the marshal-optional-args sub-cluster's "first-position optional"
511    // shape, which the Phase 2c entry surfaced as deferred. Callers who
512    // want process-stdin reading should call `io.stdin()` first and pass
513    // the result.
514    register_typed_fn_1::<_, Arc<IoHandleData>>(
515        module,
516        "read_line",
517        "Read a single line from an IoHandle (file or pipe)",
518        "handle",
519        "IoHandle",
520        ConcreteType::String,
521        |handle, ctx| {
522            crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
523            let mut guard = handle
524                .resource
525                .lock()
526                .map_err(|_| "io.read_line(): lock poisoned".to_string())?;
527            let resource = guard
528                .as_mut()
529                .ok_or_else(|| "io.read_line(): handle is closed".to_string())?;
530            let line = match resource {
531                IoResource::File(file) => {
532                    let mut line = String::new();
533                    BufReader::new(file)
534                        .read_line(&mut line)
535                        .map_err(|e| format!("io.read_line(): {}", e))?;
536                    line
537                }
538                IoResource::ChildProcess(child) => {
539                    let stdout = child.stdout.as_mut().ok_or_else(|| {
540                        "io.read_line(): stdout pipe not available".to_string()
541                    })?;
542                    let mut line = String::new();
543                    BufReader::new(stdout)
544                        .read_line(&mut line)
545                        .map_err(|e| format!("io.read_line(): {}", e))?;
546                    line
547                }
548                IoResource::PipeReader(stdout) => {
549                    let mut line = String::new();
550                    BufReader::new(stdout)
551                        .read_line(&mut line)
552                        .map_err(|e| format!("io.read_line(): {}", e))?;
553                    line
554                }
555                _ => {
556                    return Err("io.read_line(): handle does not support reading".to_string());
557                }
558            };
559            Ok(TypedReturn::Concrete(ConcreteReturn::String(line)))
560        },
561    );
562}