the Compartmented Robust Posix C++ Unit Test system | hosted by |
---|
You can safely roll your own versions of functions like
fork
() and clock_gettime
().
crpcut make all library calls through dlopen
() and
dlsym
(), and will not be affected by your
versions[1][2]
Sometimes, however, replacing a library function is not what you want,
but rather to enclose it inside a wrapper that does filtering and
error injection. You can do that with the help of the macros
CRPCUT_WRAP_FUNC(lib, func, rv, param_list, param_call)
and
CRPCUT_WRAP_V_FUNC(lib, func, rv, param_list, param_call)
. Here's
classic difficult situation, with a process that spawns a child and
interacts with it:
extern "C" { #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <stdlib.h> } #include <stdexcept> #include <string> #include <cassert> class work { public: class fork_exception : public std::exception { }; class pipe_exception : public std::exception { }; work() :pid(-1) { int rv = pipe(fd); if (rv == -1) throw pipe_exception(); rv = ::fork(); if (rv == -1) throw fork_exception(); if (rv == 0) { close(fd[0]); fd[0] = -1; do_work(); return; } // child close(fd[1]); fd[1] = -1; pid = rv; } ~work() { if (fd[0] != -1) close(fd[0]); if (fd[1] != -1) close(fd[1]); } std::string get_data() // empty data signals that child is done { size_t len; ::read(fd[0], &len, sizeof(len)); std::string data(len > 0 ? len : 0, '_'); if (len > 0) { ::read(fd[0], &data[0], len); } return data; } void wait() { int status; int rv = ::wait(&status); assert(rv != 0 || status == pid); pid = -1; } private: void do_work() { /* very hard stuff */ } int fd[2]; int pid; };
In this case, simple stubs are used to provide preprogrammed behavior, and fall back to the normal library function when nothing preprogrammed exists.
The test program is a bit long, but take some time to read
it through. For each wrapped library function, there is a
configuration in the shape of a list. If the list is
empty, the normal function applies, otherwise the behavior mandated
by the list-item is carried out. pipe
()
calls the library function, unless explicitly instructed to fail,
but stores the allocated file descriptors for later use.
close
() always calls the library function,
but if the file descriptors are allocated with pipe
,
they are verified to be in valid state.
![]() | Caution |
---|---|
Your versions of the library functions are in effect for all tests in the entire program. Make sure they have a decent default behavior to save yourself from debugging headaches. |
A short walk-through of the tests:
Only one configuration which ensures that
fork
() fails with
ENOMEM
.
Verify that work::fork_exception is thrown.
Only one configuration which ensures that
pipe
() fails with
EMFILE
.
Verify that the constructor throws
work::pipe_exception
The complex one, where all library function wrappers
are strictly controlled. fork
() will
return a pid, thus testing the parent process
behavior.
The pipe
() wrapper lets the calls
to the library function through, but it stores the
allocated file descriptors for use by the
read
(), write
()
and close
stubs.
The read
() and
write
() functions are stubbed
completely to verify the correct file descriptor, provide
predefined data from the faked child process, and
verify that the data written back is correct.
Similar to read_one_string above, but
the second read is set to return -1 and set
errno
to EINTR
,
to indicate that the call was interrupted by a signal.
It doesn't set up any configurations for any of the wrapped library functions, so for this test, the normal libc implementations are run unconstrained. It will, in other words, behave as in a real program.
#include "process-example.hpp" #include <crpcut.hpp> #include <cerrno> #include <list> #include <map> namespace original { CRPCUT_WRAP_FUNC(libc, fork, pid_t, (void), ()) CRPCUT_WRAP_FUNC(libc, close, int, (int fd), (fd)) CRPCUT_WRAP_FUNC(libc, pipe, int, (int fd[2]), (fd)) CRPCUT_WRAP_FUNC(libc, wait, pid_t, (void *status), (status)) CRPCUT_WRAP_FUNC(libc, read, ssize_t, (int fd, void *p, size_t n), (fd, p, n)) CRPCUT_WRAP_FUNC(libc, write, ssize_t, (int fd, const void *p, size_t n), (fd, p, n)) } // // data structures used to control how the wrappers behave // struct pipe_data { pipe_data(int retval_, int *rfd_, int *wfd_, int err_) : retval(retval_), rfd(rfd_), wfd(wfd_), err(err_) {} int retval; int *rfd; int *wfd; int err; }; std::list<pipe_data> pipe_actions; struct fork_data { fork_data(pid_t retval_, int err_) : retval(retval_), err(err_) {} pid_t retval; int err; }; std::list<fork_data> fork_actions; struct read_data { read_data(ssize_t retval_, int *efd, const void *data_, int err_) : retval(retval_), expected_fd(efd), data(data_), err(err_) {} ssize_t retval; int *expected_fd; const void *data; int err; }; std::list<read_data> read_actions; struct write_data { write_data(ssize_t retval_, int *efd, const void *data, size_t len, int err_) : retval(retval_), expected_fd(efd), expected_data(data), expected_len(len), err(err_) {} ssize_t retval; int *expected_fd; const void *expected_data; size_t expected_len; int err; }; std::list<write_data> write_actions; struct wait_data { wait_data(int retval_, pid_t pid_, int err_) : retval(retval_), pid(pid_), err(err_) {} int retval; pid_t pid; int err; }; std::list<wait_data> wait_actions; typedef enum { unknown, is_pipe, is_closed } fdstatus; std::map<int, fdstatus> file_descriptors; // // wrapper implementations // extern "C" pid_t fork() throw () { if (fork_actions.empty()) return original::fork(); const fork_data &a = fork_actions.front(); errno = a.err; int rv = a.retval; fork_actions.pop_front(); return rv; } extern "C" ssize_t read(int fd, void *p, size_t n) { if (read_actions.empty()) return original::read(fd, p, n); const read_data &a = read_actions.front(); if (a.expected_fd) { ASSERT_EQ(fd, *a.expected_fd); } ssize_t rv = a.retval; if (rv != -1) { ASSERT_GE(n, size_t(rv)); memcpy(p, a.data, rv); } errno = a.err; read_actions.pop_front(); return rv; } extern "C" ssize_t write(int fd, const void *p, size_t n) { if (write_actions.empty()) return original::write(fd, p, n); const write_data &a = write_actions.front(); if (a.expected_fd) { ASSERT_EQ(fd, *a.expected_fd); } ssize_t rv = a.retval; if (rv >= 0) { ASSERT_EQ(n, size_t(rv)); if (::memcmp(a.expected_data, p, n) != 0) { FAIL << "write() data differs from expected"; } } errno = a.err; write_actions.pop_front(); return rv; } extern "C" int pipe(int fd[2]) throw () { int rv; if (pipe_actions.empty() || pipe_actions.front().retval == 0) { rv = original::pipe(fd); if (rv == 0) { file_descriptors[fd[0]] = is_pipe; file_descriptors[fd[1]] = is_pipe; if (!pipe_actions.empty()) { *pipe_actions.front().rfd = fd[0]; *pipe_actions.front().wfd = fd[1]; } } } else { rv = pipe_actions.front().retval; errno = pipe_actions.front().err; } if (!pipe_actions.empty()) pipe_actions.pop_front(); return rv; } extern "C" int close(int fd) { int rv = original::close(fd); ASSERT_NE(file_descriptors[fd], is_closed); file_descriptors[fd] = is_closed; return rv; } extern "C" pid_t wait(void *p) { if (wait_actions.empty()) return original::wait(p); const wait_data &a = wait_actions.front(); int rv = a.retval; errno = a.err; *static_cast<int*>(p) = a.pid; wait_actions.pop_front(); return rv; } TEST(fork_fails, EXPECT_EXCEPTION(work::fork_exception)) { fork_actions.push_back(fork_data(-1, ENOMEM)); work obj; } TEST(pipe_fails, EXPECT_EXCEPTION(work::pipe_exception)) { pipe_actions.push_back(pipe_data(-1, 0, 0, EMFILE)); work obj; } static const char parent_str[] = "hello parent!"; struct read_fixture { read_fixture() : len(sizeof(parent_str) - 1), zero(0) { pipe_actions.push_back(pipe_data(0, &rfd, &wfd, 0)); write_actions.push_back(write_data(sizeof(len), &wfd, &len, sizeof(len), 0)); fork_actions.push_back(fork_data(0x3ffff3, 0)); wait_actions.push_back(wait_data(0, 0x3ffff3, 0)); read_actions.push_back(read_data(sizeof(len), &rfd, &len, 0)); read_actions.push_back(read_data(len, &rfd, parent_str, 0)); read_actions.push_back(read_data(sizeof(zero), &rfd, &zero, 0)); } const size_t len; const size_t zero; int rfd; int wfd; }; TEST(read_one_string, read_fixture) { { work obj; ASSERT_EQ(file_descriptors[wfd], is_closed); std::string s = obj.get_data(); ASSERT_EQ(s, parent_str); s = obj.get_data(); ASSERT_EQ(s, ""); obj.wait(); } ASSERT_EQ(file_descriptors[rfd], is_closed); } TEST(signal_on_string_read, read_fixture) { read_actions.insert(++read_actions.begin(), read_data(-1, &rfd, 0, EINTR)); { work obj; ASSERT_EQ(file_descriptors[wfd], is_closed); std::string s = obj.get_data(); ASSERT_EQ(s, parent_str); s = obj.get_data(); ASSERT_EQ(s, ""); obj.wait(); } ASSERT_EQ(file_descriptors[rfd], is_closed); } TEST(normal_work) { work obj; std::string s = obj.get_data(); INFO << "s=\"" << s << "\""; ASSERT_EQ(s, ""); obj.wait(); } int main(int argc, char *argv[]) { return crpcut::run(argc, argv); }
The result of running the test program is:
FAILED: signal_on_string_read phase="running" -------------------------------------------------------------- /home/bjorn/devel/crpcut/doc-src/samples/process-test.cpp:271 ASSERT_EQ(s, parent_str) where s = _____________ parent_str = hello parent! ------------------------------------------------------------------------------- =============================================================================== FAILED: normal_work phase="child" ---------------------------------------------------------------- A child process spawned from the test has misbehaved. Process group killed ------------------------------------------------------------------------------- =============================================================================== Total 5 test cases selected UNTESTED : 0 PASSED : 3 FAILED : 2
The stubbed tests worked out well, except for signal_on_read, but that was pretty obvious from reading the code. But why did the test with the real library functions fail?
There are so many things wrong with this case, it's actually a race for several failures:
The parent process expects to read, but the child process doesn't produce any data, so the parent hangs.
The constructor returns also in the child process, making the
child process continue in its copy of the test, calling
obj
.get_data
()
and thus read
(), which fails
because the read file descriptor is closed. When
INFO
is called, crpcut discovers that
it's a runaway child and kills the whole process group. That's
what the error message is all about.
Had the child exited instead of returned from
the constructor, the parent would've received a
SIGCHLD
. Since it ignores that signal,
nothing would have been gained. Depending on luck, the
uninitialized variable len
might
contain a value large enough for the string allocation to
throw std::bad_alloc, but it might also contain
zero, which would've made the function return an empty string
as expected by the test, causing a false pass.
Fortunately crpcut is robust against this kind of abuse, so no harm is done. Unfortunately the details of the report aren't too impressive, but they at least hint about the cause of the error.
[1] You can't easily provide your own
dlopen
(), dlsym
() or
dlclose
()
however.
[2] Rolling
your own malloc
(), calloc
(),
realloc
(), free
(),
operator new
(),
operator delete
(),
operator new
[]() and
operator delete
[]() is not only hard work,
but largely unnecessary since crpcut provides functions you
can control. See the chapter on
“Heap Management”.