-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local testimony = require("testimony") local lev = require("lev") local testify = testimony.new("== lev.subprocess ==") -- ========================================================================= -- exec + read_all from echo -- ========================================================================= testify:that("exec: capture stdout from echo", function() local output lev.run(function() local proc, err = lev.exec("/bin/echo", { "hello", "world" }) testimony.assert_not_nil(proc, "exec failed: " .. tostring(err)) output = proc:read_all(5) testimony.assert_not_nil(output, "read_all returned nil") local code = proc:wait(5) testimony.assert_equal(0, code) proc:close() end) testimony.assert_equal("hello world\n", output) end) -- ========================================================================= -- Non-zero exit code -- ========================================================================= testify:that("exec: non-zero exit code", function() local exit_code lev.run(function() local proc = lev.exec("/bin/sh", { "-c", "exit 42" }) testimony.assert_not_nil(proc) exit_code = proc:wait(5) proc:close() end) testimony.assert_equal(42, exit_code) end) -- ========================================================================= -- Non-existent command (exec failure detection) -- ========================================================================= testify:that("exec: non-existent command returns error", function() local proc, err lev.run(function() proc, err = lev.exec("/nonexistent/command/that/does/not/exist") end) testimony.assert_nil(proc) testimony.assert_not_nil(err, "expected error message") end) -- ========================================================================= -- Signal death (SIGKILL) -- ========================================================================= testify:that("exec: kill with SIGKILL", function() local exit_code, exit_signal lev.run(function() local proc = lev.exec("/bin/sleep", { "60" }) testimony.assert_not_nil(proc) proc:kill(lev.SIGKILL) exit_code, exit_signal = proc:wait(5) proc:close() end) testimony.assert_equal(-1, exit_code) testimony.assert_equal(9, exit_signal) end) -- ========================================================================= -- Read after child exits (buffered pipe data survives) -- ========================================================================= testify:that("exec: read after child exits", function() local output lev.run(function() local proc = lev.exec("/bin/sh", { "-c", "echo buffered; exit 0" }) testimony.assert_not_nil(proc) -- Wait for child to exit first local code = proc:wait(5) testimony.assert_equal(0, code) -- Data should still be in the pipe buffer output = proc:read_all(5) proc:close() end) testimony.assert_equal("buffered\n", output) end) -- ========================================================================= -- Wait timeout -- ========================================================================= testify:that("exec: wait times out on long-running process", function() local result, wait_err lev.run(function() local proc = lev.exec("/bin/sleep", { "60" }) testimony.assert_not_nil(proc) result, wait_err = proc:wait(0.1) proc:kill(lev.SIGKILL) proc:wait(5) proc:close() end) testimony.assert_nil(result) testimony.assert_equal("timeout", wait_err) end) -- ========================================================================= -- Cancel token interrupts wait -- ========================================================================= testify:that("exec: cancel token interrupts wait", function() local result, wait_err lev.run(function() local proc = lev.exec("/bin/sleep", { "60" }) testimony.assert_not_nil(proc) local token = lev.cancel_token() lev.spawn(function() lev.sleep(0.05) token:cancel() end) result, wait_err = proc:wait({ cancel = token }) proc:kill(lev.SIGKILL) proc:wait(5) proc:close() end) testimony.assert_nil(result) testimony.assert_equal("cancelled", wait_err) end) -- ========================================================================= -- Cancel token interrupts recv -- ========================================================================= testify:that("exec: cancel token interrupts recv", function() local result, recv_err lev.run(function() -- cat with no input will block on read forever local proc = lev.exec("/bin/cat") testimony.assert_not_nil(proc) local token = lev.cancel_token() lev.spawn(function() lev.sleep(0.05) token:cancel() end) result, recv_err = proc:recv(4096, { cancel = token }) proc:kill(lev.SIGKILL) proc:wait(5) proc:close() end) testimony.assert_nil(result) testimony.assert_equal("cancelled", recv_err) end) -- ========================================================================= -- Large output (>pipe buffer) -- ========================================================================= testify:that("exec: large output (128KB via dd)", function() local output lev.run(function() local proc = lev.exec("/bin/dd", { "if=/dev/zero", "bs=1024", "count=128", "status=none" }) testimony.assert_not_nil(proc) output = proc:read_all(10) testimony.assert_not_nil(output, "read_all returned nil") local code = proc:wait(10) testimony.assert_equal(0, code) proc:close() end) testimony.assert_equal(128 * 1024, #output) end) -- ========================================================================= -- Bidirectional I/O (100KB cat) -- ========================================================================= testify:that("exec: bidirectional I/O with cat (100KB)", function() local output lev.run(function() local proc = lev.exec("/bin/cat") testimony.assert_not_nil(proc) local input = string.rep("x", 100 * 1024) local writer = lev.spawn(function() proc:send(input) proc:close_stdin() end) local reader = lev.spawn(function() return proc:read_all(10) end) lev.await(writer) output = lev.await(reader) proc:wait(5) proc:close() end) testimony.assert_not_nil(output) testimony.assert_equal(100 * 1024, #output) end) -- ========================================================================= -- Separate stderr capture -- ========================================================================= testify:that("exec: separate stderr capture", function() local stdout_data, stderr_data lev.run(function() local proc = lev.exec("/bin/sh", { "-c", "echo out; echo err >&2" }) testimony.assert_not_nil(proc) -- Read both streams concurrently local stdout_task = lev.spawn(function() return proc:read_all(5) end) local stderr_task = lev.spawn(function() local chunks = {} while true do local data, err = proc:recv_stderr(4096, 5) if data then chunks[#chunks + 1] = data else break end end return table.concat(chunks) end) stdout_data = lev.await(stdout_task) stderr_data = lev.await(stderr_task) proc:wait(5) proc:close() end) testimony.assert_equal("out\n", stdout_data) testimony.assert_equal("err\n", stderr_data) end) -- ========================================================================= -- stderr_to_stdout merge -- ========================================================================= testify:that("exec: stderr_to_stdout merges streams", function() local output lev.run(function() local proc = lev.exec("/bin/sh", { "-c", "echo out; echo err >&2" }, { stderr_to_stdout = true }) testimony.assert_not_nil(proc) output = proc:read_all(5) local code = proc:wait(5) testimony.assert_equal(0, code) proc:close() end) testimony.assert_not_nil(output) -- Both lines should appear in stdout (order may vary) testimony.assert_match("out\n", output) testimony.assert_match("err\n", output) end) -- ========================================================================= -- close_stdin signals EOF -- ========================================================================= testify:that("exec: close_stdin signals EOF to child", function() local output lev.run(function() local proc = lev.exec("/usr/bin/head", { "-1" }) testimony.assert_not_nil(proc) proc:send("first line\nsecond line\n") proc:close_stdin() output = proc:read_all(5) local code = proc:wait(5) testimony.assert_equal(0, code) proc:close() end) testimony.assert_equal("first line\n", output) end) -- ========================================================================= -- cwd option -- ========================================================================= testify:that("exec: cwd option changes working directory", function() local output lev.run(function() local proc = lev.exec("/bin/pwd", {}, { cwd = "/tmp" }) testimony.assert_not_nil(proc) output = proc:read_all(5) local code = proc:wait(5) testimony.assert_equal(0, code) proc:close() end) -- Remove trailing newline for comparison testimony.assert_equal("/tmp\n", output) end) -- ========================================================================= -- Review regression tests -- ========================================================================= testify:that("close: records exit so kill() cannot signal a recycled pid", function() lev.run(function() local proc = lev.exec("/bin/echo", { "x" }) testimony.assert_not_nil(proc) proc:read_all(5) -- EOF implies the child has exited lev.sleep(0.05) proc:close() -- reaps and records the exit local ok, err = proc:kill(9) testimony.assert_true(ok == true, "kill after close should be a no-op, got: " .. tostring(err)) end) end) testify:that("exec: rejects non-string args", function() lev.run(function() local proc, err = lev.exec("/bin/echo", { {} }) testimony.assert_nil(proc) testimony.assert_match("strings", tostring(err)) end) end) testify:that("exec: converts numeric args to strings", function() lev.run(function() local proc = lev.exec("/bin/echo", { 42 }) testimony.assert_not_nil(proc) local out = proc:read_all(5) proc:wait(5) proc:close() testimony.assert_equal("42\n", out) end) end) -- ========================================================================= -- Conclude -- ========================================================================= testify:conclude()