aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLexi Winter <lexi@le-fay.org>2025-06-21 15:23:05 +0100
committerLexi Winter <lexi@le-fay.org>2025-06-21 15:23:05 +0100
commit75c6b5fee029ec95e7e45e18525e3e78b9616f48 (patch)
tree7ac4e4b8e2e09eb9151e0d756e0173c47bc64484
parentca90e97a9da4457790262c53515cb5ab58a2ce4f (diff)
downloadnihil-75c6b5fee029ec95e7e45e18525e3e78b9616f48.tar.gz
nihil-75c6b5fee029ec95e7e45e18525e3e78b9616f48.tar.bz2
add spawn
-rw-r--r--nihil/CMakeLists.txt5
-rw-r--r--nihil/argv.ccm107
-rw-r--r--nihil/exec.ccm154
-rw-r--r--nihil/find_in_path.ccm60
-rw-r--r--nihil/nihil.ccm7
-rw-r--r--nihil/process.ccm137
-rw-r--r--nihil/spawn.ccm207
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/spawn.cc65
9 files changed, 742 insertions, 1 deletions
diff --git a/nihil/CMakeLists.txt b/nihil/CMakeLists.txt
index 1e9962b..81f11c6 100644
--- a/nihil/CMakeLists.txt
+++ b/nihil/CMakeLists.txt
@@ -4,14 +4,19 @@ add_library(nihil STATIC)
target_sources(nihil PUBLIC
FILE_SET modules TYPE CXX_MODULES FILES
nihil.ccm
+ argv.ccm
command_map.ccm
ctype.ccm
+ exec.ccm
fd.ccm
+ find_in_path.ccm
generator.ccm
generic_error.ccm
getenv.ccm
guard.ccm
next_word.ccm
+ process.ccm
skipws.ccm
+ spawn.ccm
tabulate.ccm
usage_error.ccm)
diff --git a/nihil/argv.ccm b/nihil/argv.ccm
new file mode 100644
index 0000000..a9c254e
--- /dev/null
+++ b/nihil/argv.ccm
@@ -0,0 +1,107 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <memory>
+#include <ranges>
+#include <string>
+#include <vector>
+
+export module nihil:argv;
+
+namespace nihil {
+
+/*
+ * argv: stores a null-terminated array of nul-terminated C strings.
+ * argv::data() is suitable for passing to ::execv().
+ *
+ * Create an argv using argv::from_range(), which takes a range of
+ * string-like objects.
+ */
+
+export struct argv {
+ /*
+ * Create a new argv from a range.
+ */
+ static auto from_range(std::ranges::range auto &&args) -> argv
+ {
+ auto ret = argv{};
+
+ for (auto &&arg : args)
+ ret._add_arg(std::string_view(arg));
+
+ ret._args.push_back(nullptr);
+ return ret;
+ }
+
+ template<typename T>
+ static auto from_args(std::initializer_list<T> &&args)
+ {
+ return from_range(std::move(args));
+ }
+
+ argv(argv &&) noexcept = default;
+ auto operator=(this argv &, argv &&other) -> argv& = default;
+
+ // Not copyable. TODO: for completeness, it probably should be.
+ argv(argv const &) = delete;
+ auto operator=(this argv &, argv const &other) -> argv& = delete;
+
+ ~argv()
+ {
+ for (auto *arg : _args)
+ delete[] arg;
+ }
+
+ // Access the stored arguments.
+ auto data(this argv const &self) -> char const * const *
+ {
+ return self._args.data();
+ }
+
+ auto data(this argv &self) -> char * const *
+ {
+ return self._args.data();
+ }
+
+ auto size(this argv const &self)
+ {
+ return self._args.size();
+ }
+
+ // Range access
+ auto begin(this argv const &self)
+ {
+ return self._args.begin();
+ }
+
+ auto end(this argv const &self)
+ {
+ return self._args.end();
+ }
+
+private:
+ // Use the from_range() factory method to create new instances.
+ argv() = default;
+
+ // The argument pointers, including the null terminator.
+ std::vector<char *> _args;
+
+ // Add a new argument to the array.
+ auto _add_arg(this argv &self, std::string_view arg) -> void
+ {
+ // Create a nul-terminated C string.
+ auto ptr = std::make_unique<char[]>(arg.size() + 1);
+ std::ranges::copy(arg, ptr.get());
+ ptr[arg.size()] = '\0';
+
+ // Ensure we won't throw when emplacing the pointer.
+ self._args.reserve(self._args.size() + 1);
+ self._args.emplace_back(ptr.release());
+ }
+};
+
+} // namespace nihil
+
diff --git a/nihil/exec.ccm b/nihil/exec.ccm
new file mode 100644
index 0000000..5718a04
--- /dev/null
+++ b/nihil/exec.ccm
@@ -0,0 +1,154 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+/*
+ * Exec providers, mostly used for spawn().
+ */
+
+#include <format>
+#include <string>
+#include <utility>
+
+#include <err.h>
+#include <fcntl.h>
+#include <unistd.h>
+
+extern char **environ;
+
+export module nihil:exec;
+
+import :argv;
+import :fd;
+import :find_in_path;
+import :generic_error;
+
+namespace nihil {
+
+/*
+ * Generic error, what() should be descriptive.
+ */
+export struct exec_error : generic_error {
+ template<typename... Args>
+ exec_error(std::format_string<Args...> fmt, Args &&...args)
+ : generic_error(fmt, std::forward<Args>(args)...)
+ {}
+};
+
+/*
+ * We tried to execute a path or filename and the file was not found.
+ */
+export struct executable_not_found : exec_error {
+ std::string executable;
+
+ executable_not_found(std::string_view executable_)
+ : exec_error("{}: command not found", executable_)
+ , executable(executable_)
+ {}
+};
+
+/*
+ * A concept to mark spawn executors.
+ */
+
+export struct exec_tag{};
+
+export template<typename T>
+concept executor =
+ requires (T e) {
+ std::same_as<exec_tag, typename T::tag>;
+ { e.exec() };
+ };
+
+/*
+ * fexecv: use a file descriptor and an argument vector to call ::fexecve().
+ * This is the lowest-level executor which all others are implemented
+ * in terms of.
+ *
+ * TODO: Should have a way to pass the environment (envp).
+ */
+export struct fexecv final {
+ using tag = exec_tag;
+
+ fexecv(fd &&execfd, argv &&args) noexcept
+ : _execfd(std::move(execfd))
+ , _args(std::move(args))
+ {}
+
+ [[noreturn]] auto exec(this fexecv &self) noexcept -> void
+ {
+ ::fexecve(self._execfd.get(), self._args.data(), environ);
+ ::err(1, "fexecve");
+ }
+
+ // Movable
+ fexecv(fexecv &&) noexcept = default;
+ auto operator=(this fexecv &, fexecv &&) noexcept -> fexecv& = default;
+
+ // Not copyable (because we hold the open fd object)
+ fexecv(fexecv const &) = delete;
+ auto operator=(this fexecv &, fexecv const &) -> fexecv& = delete;
+
+private:
+ fd _execfd;
+ argv _args;
+};
+
+static_assert(executor<fexecv>);
+
+/*
+ * execv: equivalent to fexecv(), except the command is passed as
+ * a pathname instead of a file descriptor. Does not search $PATH.
+ */
+export auto execv(std::string_view path, argv &&argv) -> fexecv
+{
+ auto cpath = std::string(path);
+ auto const ret = ::open(cpath.c_str(), O_EXEC);
+ if (ret == -1)
+ throw executable_not_found(path);
+ return {fd(ret), std::move(argv)};
+}
+
+/*
+ * execvp: equivalent to fexecv(), except the command is passed as
+ * a filename instead of a file descriptor. If the filename is not
+ * an absolute path, it will be searched for in $PATH.
+ */
+export auto execvp(std::string_view file, argv &&argv) -> fexecv
+{
+ auto execfd = find_in_path(file);
+ if (!execfd)
+ throw executable_not_found(file);
+ return {std::move(*execfd), std::move(argv)};
+}
+
+/*
+ * execl: equivalent to execv, except the arguments are passed as a
+ * variadic pack of string-like objects.
+ */
+export auto execl(std::string_view path, auto &&...args) -> fexecv
+{
+ return execv(path, argv::from_args({std::string_view(args)...}));
+}
+
+/*
+ * execlp: equivalent to execvp, except the arguments are passed as a
+ * variadic pack of string-like objects.
+ */
+export auto execlp(std::string_view file, auto &&...args) -> fexecv
+{
+ return execvp(file, argv::from_args({std::string_view(args)...}));
+}
+
+/*
+ * shell: run the process by invoking /bin/sh -c with the single argument,
+ * equivalent to system(3).
+ */
+export auto shell(std::string_view const &command) -> fexecv
+{
+ return execl("/bin/sh", "sh", "-c", command);
+}
+
+} // namespace nihil
diff --git a/nihil/find_in_path.ccm b/nihil/find_in_path.ccm
new file mode 100644
index 0000000..1e72d0b
--- /dev/null
+++ b/nihil/find_in_path.ccm
@@ -0,0 +1,60 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <format>
+#include <ranges>
+#include <string>
+
+#include <fcntl.h>
+#include <paths.h>
+#include <unistd.h>
+
+export module nihil:find_in_path;
+
+import :fd;
+import :getenv;
+
+namespace nihil {
+
+/*
+ * Find an executable in $PATH, open it with O_EXEC and return the fd.
+ * If $PATH is not set, uses _PATH_DEFPATH.
+ */
+auto find_in_path(std::string_view file) -> std::optional<fd> {
+ using namespace std::literals;
+
+ auto try_open = [](std::string_view path) -> std::optional<fd> {
+ auto cpath = std::string(path);
+ auto const ret = ::open(cpath.c_str(), O_EXEC);
+ if (ret != -1)
+ return fd(ret);
+ return {};
+ };
+
+ if (file.empty())
+ return {};
+
+ // Absolute pathname skips the search.
+ if (file[0] == '/')
+ return try_open(file);
+
+ auto path = getenv("PATH").value_or(_PATH_DEFPATH);
+
+ for (auto &&dir : std::views::split(path, ':')) {
+ // An empty $PATH element means cwd.
+ auto sdir = std::string_view(dir);
+ if (sdir.empty())
+ sdir = ".";
+
+ auto const path = std::format("{}/{}", sdir, file);
+ if (auto ret = try_open(path); ret)
+ return ret;
+ }
+
+ return {};
+}
+
+} // namespace lfjail
diff --git a/nihil/nihil.ccm b/nihil/nihil.ccm
index 0daf931..21790c8 100644
--- a/nihil/nihil.ccm
+++ b/nihil/nihil.ccm
@@ -6,14 +6,19 @@ module;
export module nihil;
+export import :argv;
export import :command_map;
export import :ctype;
+export import :exec;
+export import :fd;
+export import :find_in_path;
export import :generator;
export import :generic_error;
export import :getenv;
export import :guard;
-export import :fd;
export import :next_word;
+export import :process;
export import :skipws;
+export import :spawn;
export import :tabulate;
export import :usage_error;
diff --git a/nihil/process.ccm b/nihil/process.ccm
new file mode 100644
index 0000000..7a4f1f0
--- /dev/null
+++ b/nihil/process.ccm
@@ -0,0 +1,137 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+#include <cerrno>
+#include <cstring>
+#include <optional>
+#include <utility>
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+export module nihil:process;
+
+import :generic_error;
+
+namespace nihil {
+
+/*
+ * wait_result: the exit status of a process.
+ */
+struct wait_result final {
+ // Return true if the process exited normally with an exit code of
+ // zero, otherwise false.
+ auto okay(this wait_result const &self) -> bool
+ {
+ return self.status() == 0;
+ }
+
+ explicit operator bool(this wait_result const &self)
+ {
+ return self.okay();
+ }
+
+ // Return the exit status, if any.
+ auto status(this wait_result const &self) -> std::optional<int>
+ {
+ if (WIFEXITED(self._status))
+ return WEXITSTATUS(self._status);
+ return {};
+ }
+
+ // Return the exit signal, if any.
+ auto signal(this wait_result const &self) -> std::optional<int>
+ {
+ if (WIFSIGNALED(self._status))
+ return WTERMSIG(self._status);
+ return {};
+ }
+
+private:
+ friend struct process;
+
+ int _status;
+
+ // Construct a new wait_result from the output of waitpid().
+ wait_result(int status)
+ : _status(status)
+ {}
+};
+
+/*
+ * process: represents a process we created, which can be waited for.
+ */
+struct process final {
+ process() = delete;
+
+ /*
+ * Create a new process from a pid, which must be a child of the
+ * current process.
+ */
+ process(::pid_t pid)
+ : _pid(pid)
+ {}
+
+ // When destroyed, we automatically wait for the process to
+ // avoid creating zombie processes.
+ ~process() {
+ if (_pid == -1)
+ return;
+
+ auto status = int{};
+ std::ignore = waitpid(_pid, &status, WEXITED);
+ }
+
+ // Movable.
+ process(process &&) noexcept = default;
+ auto operator=(process &&) noexcept -> process& = default;
+
+ // Not copyable.
+ process(process const &) = delete;
+ auto operator=(process const &) -> process& = delete;
+
+ // Get the child's process id.
+ auto pid(this process const &self) noexcept -> ::pid_t;
+
+ /*
+ * Wait for this process to exit (by calling waitpid()) and return
+ * its exit status. This destroys the process state, leaving this
+ * object in a moved-from state.
+ */
+ auto wait(this process &&self) -> wait_result
+ {
+ auto status = int{};
+ auto ret = waitpid(self._pid, &status, WEXITED);
+
+ self._pid = -1;
+
+ switch (ret) {
+ case -1:
+ throw generic_error("waitpid({}): failed: {}",
+ self._pid, strerror(errno));
+ case 0:
+ throw generic_error("waitpid({}): no child to wait",
+ self._pid);
+ }
+
+ return wait_result(status);
+ }
+
+ /*
+ * Release this process so we won't try to wait for it when
+ * destroying this object.
+ */
+ auto release(this process &&self) -> ::pid_t {
+ auto const ret = self._pid;
+ self._pid = -1;
+ return ret;
+ }
+
+private:
+ ::pid_t _pid;
+};
+
+} // namespace nihil
diff --git a/nihil/spawn.ccm b/nihil/spawn.ccm
new file mode 100644
index 0000000..f33eaa0
--- /dev/null
+++ b/nihil/spawn.ccm
@@ -0,0 +1,207 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+module;
+
+/*
+ * spawn(): fork and execute a child process.
+ */
+
+#include <algorithm>
+#include <cerrno>
+#include <iterator>
+#include <string>
+#include <utility>
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <fcntl.h>
+#include <unistd.h>
+
+export module nihil:spawn;
+
+import :argv;
+import :exec;
+import :fd;
+import :process;
+
+namespace nihil {
+
+// Useful constants
+export inline int constexpr stdin_fileno = STDIN_FILENO;
+export inline int constexpr stdout_fileno = STDOUT_FILENO;
+export inline int constexpr stderr_fileno = STDERR_FILENO;
+
+/*
+ * fd_pipe: create a pipe for the given fd. The first end of the pipe will
+ * be returned in the given fd object, and the second will be set to the
+ * given fd number in the child.
+ */
+export struct fd_pipe final {
+ fd_pipe(int fdno, fd &ret) : _fdno(fdno), _fd(&ret) {
+ auto fds = pipe();
+ if (!fds)
+ throw exec_error("pipe: {}", fds.error().message());
+ std::tie(_parent_fd, _child_fd) = std::move(*fds);
+ }
+
+ auto run_in_child(process &) -> void {
+ _parent_fd.close();
+
+ ::dup2(_child_fd.get(), _fdno);
+ _child_fd.close();
+ }
+
+ auto run_in_parent(process &) -> void {
+ _child_fd.close();
+ *_fd = std::move(_parent_fd);
+ }
+
+private:
+ int _fdno;
+ fd *_fd;
+ fd _parent_fd;
+ fd _child_fd;
+};
+
+/*
+ * fd_file: open the given file in the child as the given fd.
+ * open_flags and open_mode are as for ::open().
+ */
+export struct fd_file final {
+ fd_file(int fdno,
+ std::string path,
+ int open_flags,
+ int open_mode = 0777)
+ : _fdno(fdno)
+ , _path(std::move(path))
+ , _open_flags(open_flags)
+ , _open_mode(open_mode)
+ { }
+
+ auto run_in_parent(process &) -> void {}
+
+ auto run_in_child(process &) -> void {
+ int newfd = ::open(_path.c_str(), _open_flags, _open_mode);
+ if (newfd != -1) {
+ ::dup2(newfd, _fdno);
+ ::close(newfd);
+ }
+ }
+
+private:
+ int _fdno;
+ std::string _path;
+ int _open_flags;
+ int _open_mode;
+};
+
+/*
+ * Shorthand for fd_file with /dev/null as the file.
+ */
+
+export inline auto stdin_devnull() -> fd_file
+{
+ return {STDIN_FILENO, "/dev/null", O_RDONLY, 0777};
+}
+
+export inline auto stdout_devnull() -> fd_file
+{
+ return {STDOUT_FILENO, "/dev/null", O_WRONLY, 0777};
+}
+
+export inline auto stderr_devnull() -> fd_file
+{
+ return {STDERR_FILENO, "/dev/null", O_WRONLY, 0777};
+}
+
+/*
+ * Capture the output of a given file descriptor and write it to the given
+ * output iterator.
+ */
+export template<std::output_iterator<char> Iterator>
+struct capture final {
+ capture(int fdno, Iterator it)
+ : _fdno(fdno)
+ , _iterator(std::move(it))
+ {
+ auto fds = pipe();
+ if (!fds)
+ throw exec_error("pipe: {}", fds.error().message());
+
+ std::tie(_parent_fd, _child_fd) = std::move(*fds);
+ }
+
+ capture(int fdno, std::string &str)
+ : capture(fdno, std::back_inserter(str))
+ {}
+
+ auto run_in_child(process &) -> void
+ {
+ _parent_fd.close();
+
+ ::dup2(_child_fd.get(), _fdno);
+ _child_fd.close();
+ }
+
+ auto run_in_parent(process &) -> void
+ {
+ _child_fd.close();
+
+ constexpr std::size_t bufsize = 1024;
+ std::array<char, bufsize> buffer;
+ auto ret = ssize_t{};
+
+ while ((ret = ::read(_parent_fd.get(),
+ buffer.data(),
+ buffer.size())) > 0) {
+ auto data = std::span(buffer).subspan(0, ret);
+ std::ranges::copy(data, _iterator);
+ }
+
+ // We probably want to handle errors here somehow,
+ // but it's not clear what would be useful behaviour.
+ }
+
+private:
+ int _fdno;
+ fd *_fd;
+ fd _parent_fd;
+ fd _child_fd;
+ Iterator _iterator;
+};
+
+capture(int, std::string) -> capture<std::back_insert_iterator<std::string>>;
+
+/*
+ * Spawn a new process with the given arguments and return a struct process.
+ * Throws exec_error() on failure.
+ */
+export auto spawn(executor auto &&executor, auto &&...actions) -> process
+{
+ auto const pid = ::fork();
+ if (pid == -1)
+ throw exec_error("fork: {}", std::strerror(errno));
+
+ auto proc = process(pid);
+
+ if (pid == 0) {
+ // We are in the child. Release the process so we don't
+ // try to wait for ourselves, then run child handlers and
+ // exec the process.
+
+ std::move(proc).release();
+ (actions.run_in_child(proc), ...);
+
+ executor.exec();
+ _exit(1);
+ }
+
+ (actions.run_in_parent(proc), ...);
+
+ return proc;
+}
+
+} // namespace nihil
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 13974aa..abeab88 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -10,6 +10,7 @@ add_executable(nihil.test
guard.cc
next_word.cc
skipws.cc
+ spawn.cc
tabulate.cc)
target_link_libraries(nihil.test PRIVATE
diff --git a/tests/spawn.cc b/tests/spawn.cc
new file mode 100644
index 0000000..455223e
--- /dev/null
+++ b/tests/spawn.cc
@@ -0,0 +1,65 @@
+/*
+ * This source code is released into the public domain.
+ */
+
+#include <catch2/catch_test_macros.hpp>
+
+import nihil;
+
+TEST_CASE("spawn: system", "[spawn]") {
+ using namespace nihil;
+ auto output = std::string();
+ auto result = spawn(shell("x=1; echo $x"),
+ capture(stdout_fileno, output)).wait();
+
+ REQUIRE(result.okay());
+ REQUIRE(output == "1\n");
+}
+
+TEST_CASE("spawn: execv", "[spawn]") {
+ using namespace nihil;
+ auto output = std::string();
+ auto args = argv::from_args({"sh", "-c", "x=1; echo $x"});
+ auto result = spawn(execv("/bin/sh", std::move(args)),
+ capture(stdout_fileno, output)).wait();
+
+ REQUIRE(result.okay());
+ REQUIRE(output == "1\n");
+}
+
+TEST_CASE("spawn: execvp", "[spawn]") {
+ using namespace nihil;
+ auto output = std::string();
+ auto args = argv::from_args({"sh", "-c", "x=1; echo $x"});
+ auto result = spawn(execvp("sh", std::move(args)),
+ capture(stdout_fileno, output)).wait();
+
+ REQUIRE(result.okay());
+ REQUIRE(output == "1\n");
+}
+
+TEST_CASE("spawn: execl", "[spawn]") {
+ using namespace nihil;
+ auto output = std::string();
+ auto result = spawn(execl("/bin/sh", "sh", "-c", "x=1; echo $x"),
+ capture(stdout_fileno, output)).wait();
+
+ REQUIRE(result.okay());
+ REQUIRE(output == "1\n");
+}
+
+TEST_CASE("spawn: execlp", "[spawn]") {
+ using namespace nihil;
+ auto output = std::string();
+ auto result = spawn(execlp("sh", "sh", "-c", "x=1; echo $x"),
+ capture(stdout_fileno, output)).wait();
+
+ REQUIRE(result.okay());
+ REQUIRE(output == "1\n");
+}
+
+TEST_CASE("spawn: execlp failure", "[spawn]") {
+ using namespace nihil;
+ REQUIRE_THROWS_AS(execlp("lfjail_nonesuch_executable", "x"),
+ executable_not_found);
+}