Something went wrong. Try again.
The Monad language. Dependent types, functional programming compiled with LLVM. Hobby project. monad-lang.org
dependent-types language compiler programming-language functional-programming
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398/// 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)}