/// Tests for `std/src/process.mo`. /// /// A separate file because `process.mo` is re-exported by `std/src/lib.mo` /// and therefore seeded ambiently into every module closure, which makes /// a `#[test]` inside it undiscoverable -- see that file's note. Same /// reason `path_tests.mo` and `sha256_tests.mo` are separate. /// /// Same mote, so `Proc.first_quote` stays package-private and is still /// reachable here -- no API had to be widened to test it. use std::stream {StreamError, StreamErrorKind} // ─── Tests: shell_quote ─── #[test] def test_shell_quote_wraps_in_single_quotes : Bool := String.beq (Proc.shell_quote "abc") "'abc'" #[test] def test_shell_quote_keeps_a_space_in_one_word : Bool := String.beq (Proc.shell_quote "a b") "'a b'" #[test] def test_shell_quote_empty_is_an_empty_word : Bool := String.beq (Proc.shell_quote "") "''" /// The whole point: an embedded quote closes, escapes, and reopens. #[test] def test_shell_quote_escapes_an_embedded_quote : Bool := String.beq (Proc.shell_quote "it's") "'it'\\''s'" #[test] def test_shell_quote_escapes_every_quote : Bool := String.beq (Proc.shell_quote "'a'") "''\\''a'\\'''" #[test] def test_shell_quote_leaves_a_dollar_sign_alone : Bool := String.beq (Proc.shell_quote "$HOME") "'$HOME'" #[test] def test_first_quote_finds_nothing_in_a_plain_word : Bool := match Proc.first_quote "abc" { Option.none => true, Option.some _ => false } #[test] def test_first_quote_finds_the_first_of_two : Bool := match Proc.first_quote "ab'c'd" { Option.some i => i == 2, Option.none => false } // ─── Tests: capture ─── #[test] def test_capture_returns_status_and_stdout : IO Bool := do { let r <- Proc.capture "echo" ["hi"]; match r { Pair.pair code out => return (code == 0 && String.beq out "hi\n") } } /// Exit status and stderr in one assertion, because both are things /// `exec_cmd` alone cannot give a caller. #[test] def test_capture_merges_stderr_and_keeps_the_exit_status : IO Bool := do { let r <- Proc.capture "sh" ["-c", "echo e >&2; exit 3"]; match r { Pair.pair code out => return (code == 3 && String.beq out "e\n") } } /// The injection test. `$HOME` must arrive at `echo` as four literal /// characters; if `Proc.shell_quote` is ever dropped from the argv /// construction, the shell expands it here and this fails. #[test] def test_capture_does_not_expand_a_dollar_sign : IO Bool := do { let r <- Proc.capture "echo" ["$HOME"]; match r { Pair.pair _code out => return (String.beq out "$HOME\n") } } /// A word with a quote in it survives the round trip through `sh -c`. #[test] def test_capture_passes_an_embedded_quote_through : IO Bool := do { let r <- Proc.capture "echo" ["it's"]; match r { Pair.pair _code out => return (String.beq out "it's\n") } } /// A command that does not exist is a nonzero status and a message, not a /// crash and not an empty string. #[test] def test_capture_of_a_missing_command_is_nonzero : IO Bool := do { let r <- Proc.capture "monad_no_such_command_exists" []; match r { Pair.pair code _out => return (Bool.not (code == 0)) } } // ─── Tests: spawn ────────────────────────────────────────────────────────── // // The piped-subprocess natives. These spawn real children (`echo`, `cat`, // `sh`) rather than mocking the pipe, so each one exercises the actual // fork/exec path on whichever backend runs it. Read assertions route // through the `pr_*` helpers below for the same reason toolkit's framing // tests use `fr_*`: one match per helper, so no `#[test]` def nests two // differently-typed matches -- the recorded self-hosted codegen hazard -- // and a prefixed name that cannot collide with another mote's test // helpers in the shared whole-program scope. /// The payload says `expected` -- `Result.ok (Option.some expected)`. def pr_some_is (o : Option String) (expected : String) : Bool := match o { Option.some s => String.beq s expected, Option.none => false } def pr_line_is (l : Result StreamError (Option String)) (expected : String) : Bool := match l { Result.ok o => pr_some_is o expected, Result.err _ => false } /// End of stream: `Result.ok Option.none`, which is a clean end and not /// an error -- the distinction a protocol loop's termination hangs on. def pr_none_is (o : Option String) : Bool := match o { Option.none => true, Option.some _s => false } def pr_is_eof (l : Result StreamError (Option String)) : Bool := match l { Result.ok o => pr_none_is o, Result.err _ => false } /// A write reports the byte count it accepted. def pr_wrote (w : Result StreamError I64) (expected : I64) : Bool := match w { Result.ok n => I64.beq n expected, Result.err _ => false } /// The whole read loop in miniature: one line, then end of stream, then a /// clean exit. Everything a newline-framed protocol peer does, in order. #[test] def test_spawn_echo_reads_a_line_then_eof_then_waits_zero : IO Bool := do { let r <- Proc.spawn "echo" ["hi"]; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let l1 <- Proc.read_line (Shared.get sh); let l2 <- Proc.read_line (Shared.get sh); let code <- Proc.wait c; return (pr_line_is l1 "hi" && pr_is_eof l2 && code == 0) } } } /// `cat` is the honest roundtrip: what we wrote must come back, and the /// byte count must be the string's length. `wait` closing stdin first is /// what lets the child exit at all, so this also pins that ordering. #[test] def test_spawn_cat_roundtrips_a_written_line : IO Bool := do { let r <- Proc.spawn "cat" []; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let w <- Proc.write (Shared.get sh) "hello\n"; let l <- Proc.read_line (Shared.get sh); let code <- Proc.wait c; return (pr_wrote w 6 && pr_line_is l "hello" && code == 0) } } } def pr_kind_is_broken_pipe (k : StreamErrorKind) : Bool := match k { StreamErrorKind.broken_pipe => true, _ => false } def pr_kind_is_invalid_input (k : StreamErrorKind) : Bool := match k { StreamErrorKind.invalid_input => true, _ => false } /// A write failed with `broken_pipe` -- the dead peer's EPIPE, the /// reason a `Proc.write` reports rather than killing this process. def pr_write_is_broken_pipe (w : Result StreamError I64) : Bool := match w { Result.ok _ => false, Result.err e => pr_kind_is_broken_pipe e.kind } /// A line read failed at the one-mebibyte bound. def pr_read_is_invalid_input (l : Result StreamError (Option String)) : Bool := match l { Result.ok _ => false, Result.err e => pr_kind_is_invalid_input e.kind } /// Writing to a child that has already exited is `broken_pipe`, not the /// death of THIS process by SIGPIPE -- the compiled runtime ignores /// SIGPIPE in `main` for exactly this. Reading stdout to its end first is /// what makes "already exited" deterministic: `true` closes it only by /// exiting. #[test] def test_spawn_write_to_an_exited_child_is_an_error_not_a_signal : IO Bool := do { let r <- Proc.spawn "true" []; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let l <- Proc.read_line (Shared.get sh); let w <- Proc.write (Shared.get sh) "hello\n"; let _code <- Proc.wait c; return (pr_is_eof l && pr_write_is_broken_pipe w) } } } /// The other half of ignoring SIGPIPE: a child must get the default /// back. With SIGPIPE still ignored, `yes` would see EPIPE and print /// "Broken pipe" onto the merged stream instead of dying quietly. #[test] def test_capture_child_gets_default_sigpipe : IO Bool := do { let r <- Proc.capture "sh" ["-c", "yes | head -n 1"]; match r { Pair.pair code out => return (code == 0 && String.beq out "y\n") } } /// A server's stderr is its log stream, not its protocol stream: "err" /// must come out of `read_stderr_line` while "out" stays on stdout. If /// the pipes were merged (as `capture` merges them) this fails -- which /// is exactly why `capture` cannot back an MCP client. #[test] def test_spawn_keeps_stderr_out_of_the_protocol_stream : IO Bool := do { let r <- Proc.spawn "sh" ["-c", "echo err >&2; echo out"]; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let e <- Proc.read_stderr_line (Shared.get sh); let l <- Proc.read_line (Shared.get sh); let code <- Proc.wait c; return (pr_line_is e "err" && pr_line_is l "out" && code == 0) } } } /// `exec_cmd`'s status convention, reached through the piped path: the /// child's own exit status, not a signal death. #[test] def test_spawn_wait_reports_the_exit_status : IO Bool := do { let r <- Proc.spawn "sh" ["-c", "exit 3"]; match r { Result.err _ => return false, Result.ok c => do { let code <- Proc.wait c; return (code == 3) } } } /// `kill` is the whole teardown: it SIGKILLs, reaps, and frees the /// slot. A `wait` AFTER `kill` therefore does not see a child at all /// -- its -1 is the freed-slot answer, not a signal-death report (the /// signal death was already reaped inside `kill` itself). This pins /// exactly that residual `wait`, because a disposer that calls both is /// the order old callers used and must keep working. #[test] def test_spawn_wait_after_kill_reports_a_freed_slot : IO Bool := do { let r <- Proc.spawn "sh" ["-c", "sleep 30"]; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; Proc.kill (Shared.get sh); let code <- Proc.wait c; return (code == (0 - 1)) } } } /// A frame without its newline is a truncated message, not a message: /// `printf abc` (no newline) must read as end of stream, not "abc". Both /// backends agree on discarding, and this is the pin that keeps them /// agreeing. #[test] def test_spawn_discards_a_partial_line_at_eof : IO Bool := do { let r <- Proc.spawn "sh" ["-c", "printf abc"]; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let l <- Proc.read_line (Shared.get sh); let code <- Proc.wait c; return (pr_is_eof l && code == 0) } } } /// A spawn that cannot happen is `not_found`, not a native failure -- the /// caller composes it like every other error. #[test] def test_spawn_of_a_missing_command_is_an_error : IO Bool := do { let r <- Proc.spawn "monad_no_such_command_exists" []; match r { Result.err e => return (StreamError.is_not_found e), Result.ok c => do { let _code <- Proc.wait c; return false } } } /// A line is BOUNDED: 2 MB with no newline at all must fail as /// `invalid_input` at the one-mebibyte bound, not arrive as a 2 MB /// "line" -- a peer that withholds its newline is a flood, and the flood /// must fail, not grow. Both backends carry the same number, and this is /// the pin that keeps them agreeing. #[test] def test_spawn_read_line_is_bounded : IO Bool := do { let r <- Proc.spawn "sh" ["-c", "head -c 2000000 /dev/zero | tr '\\0' 'a'"]; match r { Result.err _ => return false, Result.ok c => do { let sh := Shared.of c; let l <- Proc.read_line (Shared.get sh); // The child exits on its own once the pipe closes; the // verdict is the read's, so `wait` is cleanup either way. let _code <- Proc.wait c; return (pr_read_is_invalid_input l) } } } // ─── Tests: kill_group ─── /// A detached `sleep` in its own process group (`set -m`), so the group /// id is the printed pid. def detached_sleep : IO I64 := do { let r <- Proc.capture "sh" ["-c", "set -m; sleep 30 > /dev/null 2>&1 & echo $!"]; match r { Pair.pair _ out => return (digits_value (String.to_list (String.trim out)) 0) } } /// A decimal digit string's value (the test needs no error path). #[partial] def digits_value (bs : List U8) (acc : I64) : I64 := match bs { List.empty => acc, List.cons b rest => digits_value rest (acc * 10 + U8.to_i64 b - 48), } #[test] def test_kill_group_ends_a_detached_group : IO Bool := do { let pid <- detached_sleep; let alive <- Proc.kill_group pid 0; let sent <- Proc.kill_group pid Proc.sigterm; let _ <- exec_cmd "sleep" ["0.3"]; let after <- Proc.kill_group pid 0; return (pid > 1 && alive && sent && Bool.not after) } #[test] def test_kill_group_refuses_init_and_the_broadcast_ids : IO Bool := do { let a <- Proc.kill_group 0 0; let b <- Proc.kill_group (0 - 1) 0; let c <- Proc.kill_group 1 0; return (Bool.not a && Bool.not b && Bool.not c) } /// With `quiet_terminal` set, this process's own stderr and an `exec_cmd` /// child's stdout and stderr land in the file (appended) instead of the /// terminal; "" restores both, after which nothing more reaches the file. #[test] def test_quiet_terminal_takes_stdout_and_stderr : IO Bool := do { let path : String := String.concat "/tmp/monad_child_output_" (String.concat (I64.to_string process_id) ".log"); let _ <- exec_cmd "rm" ["-f", path]; let set <- Process.quiet_terminal path; let rc <- exec_cmd "sh" ["-c", "echo to-out; echo to-err >&2"]; IO.write_stderr "own-stderr\n"; let reset <- Process.quiet_terminal ""; let _ <- exec_cmd "sh" ["-c", "echo after-reset >&2"]; let got <- IO.read_file (Path.path path); let _ <- exec_cmd "rm" ["-f", path]; let text : String := match got { Result.ok t => t, Result.err _ => "" }; return (set == 0 && reset == 0 && rc == 0 && String.contains text "to-out" && String.contains text "to-err" && String.contains text "own-stderr" && Bool.not (String.contains text "after-reset")) } /// An unopenable path is reported as -errno and changes nothing. #[test] def test_quiet_terminal_reports_an_unopenable_path : IO Bool := do { let rc <- Process.quiet_terminal "/nonexistent-dir-monad/x.log"; let reset <- Process.quiet_terminal ""; return (I64.lt rc 0 && reset == 0) }