diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index de9680bcc4ccb2a0183f10d77c8abe6249e2a93b..2b5ea1b87b1e36552c838822121055265628c41f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -114,6 +114,7 @@ set (server_sources
      lua/socket.c
      lua/pickle.c
      lua/fio.c
+     lua/popen.c
      lua/httpc.c
      lua/utf8.c
      lua/info.c
diff --git a/src/lua/init.c b/src/lua/init.c
index 28b6b2d62a4b5a3e651d9ebb37654c4b6eb64fae..5b4b5b4631f363662a4f7beccb348d077555c4eb 100644
--- a/src/lua/init.c
+++ b/src/lua/init.c
@@ -56,6 +56,7 @@
 #include "lua/msgpack.h"
 #include "lua/pickle.h"
 #include "lua/fio.h"
+#include "lua/popen.h"
 #include "lua/httpc.h"
 #include "lua/utf8.h"
 #include "lua/swim.h"
@@ -455,6 +456,7 @@ tarantool_lua_init(const char *tarantool_bin, int argc, char **argv)
 	tarantool_lua_errno_init(L);
 	tarantool_lua_error_init(L);
 	tarantool_lua_fio_init(L);
+	tarantool_lua_popen_init(L);
 	tarantool_lua_socket_init(L);
 	tarantool_lua_pickle_init(L);
 	tarantool_lua_digest_init(L);
diff --git a/src/lua/popen.c b/src/lua/popen.c
new file mode 100644
index 0000000000000000000000000000000000000000..da28b51719f26da37107e53cd415d937bcf7037c
--- /dev/null
+++ b/src/lua/popen.c
@@ -0,0 +1,2542 @@
+/*
+ * Copyright 2010-2020, Tarantool AUTHORS, please see AUTHORS file.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above
+ *    copyright notice, this list of conditions and the
+ *    following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above
+ *    copyright notice, this list of conditions and the following
+ *    disclaimer in the documentation and/or other materials
+ *    provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+ * <COPYRIGHT HOLDER> OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ * THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#include <sys/types.h>
+
+#include <lua.h>
+#include <lauxlib.h>
+#include <lualib.h>
+
+#include <small/region.h>
+
+#include "diag.h"
+#include "core/popen.h"
+#include "core/fiber.h"
+#include "core/exception.h"
+#include "tarantool_ev.h"
+
+#include "lua/utils.h"
+#include "lua/fiber.h"
+#include "lua/popen.h"
+
+/* {{{ Constants */
+
+static const char *popen_handle_uname = "popen_handle";
+static const char *popen_handle_closed_uname = "popen_handle_closed";
+
+#define POPEN_LUA_READ_BUF_SIZE        4096
+#define POPEN_LUA_WAIT_DELAY           0.1
+#define POPEN_LUA_ENV_CAPACITY_DEFAULT 256
+
+/**
+ * Helper map for transformation between std* popen.new() options
+ * and popen backend engine flags.
+ */
+static const struct {
+	/* Name for error messages. */
+	const char *option_name;
+
+	unsigned int mask_devnull;
+	unsigned int mask_close;
+	unsigned int mask_pipe;
+} pfd_map[POPEN_FLAG_FD_STDEND_BIT] = {
+	{
+		.option_name	= "opts.stdin",
+		.mask_devnull	= POPEN_FLAG_FD_STDIN_DEVNULL,
+		.mask_close	= POPEN_FLAG_FD_STDIN_CLOSE,
+		.mask_pipe	= POPEN_FLAG_FD_STDIN,
+	}, {
+		.option_name	= "opts.stdout",
+		.mask_devnull	= POPEN_FLAG_FD_STDOUT_DEVNULL,
+		.mask_close	= POPEN_FLAG_FD_STDOUT_CLOSE,
+		.mask_pipe	= POPEN_FLAG_FD_STDOUT,
+	}, {
+		.option_name	= "opts.stderr",
+		.mask_devnull	= POPEN_FLAG_FD_STDERR_DEVNULL,
+		.mask_close	= POPEN_FLAG_FD_STDERR_CLOSE,
+		.mask_pipe	= POPEN_FLAG_FD_STDERR,
+	},
+};
+
+/* }}} */
+
+/* {{{ Signals */
+
+static const struct {
+	const char *signame;
+	int signo;
+} popen_lua_signals[] = {
+#ifdef SIGHUP
+	{"SIGHUP", SIGHUP},
+#endif
+#ifdef SIGINT
+	{"SIGINT", SIGINT},
+#endif
+#ifdef SIGQUIT
+	{"SIGQUIT", SIGQUIT},
+#endif
+#ifdef SIGILL
+	{"SIGILL", SIGILL},
+#endif
+#ifdef SIGTRAP
+	{"SIGTRAP", SIGTRAP},
+#endif
+#ifdef SIGABRT
+	{"SIGABRT", SIGABRT},
+#endif
+#ifdef SIGIOT
+	{"SIGIOT", SIGIOT},
+#endif
+#ifdef SIGBUS
+	{"SIGBUS", SIGBUS},
+#endif
+#ifdef SIGFPE
+	{"SIGFPE", SIGFPE},
+#endif
+#ifdef SIGKILL
+	{"SIGKILL", SIGKILL},
+#endif
+#ifdef SIGUSR1
+	{"SIGUSR1", SIGUSR1},
+#endif
+#ifdef SIGSEGV
+	{"SIGSEGV", SIGSEGV},
+#endif
+#ifdef SIGUSR2
+	{"SIGUSR2", SIGUSR2},
+#endif
+#ifdef SIGPIPE
+	{"SIGPIPE", SIGPIPE},
+#endif
+#ifdef SIGALRM
+	{"SIGALRM", SIGALRM},
+#endif
+#ifdef SIGTERM
+	{"SIGTERM", SIGTERM},
+#endif
+#ifdef SIGSTKFLT
+	{"SIGSTKFLT", SIGSTKFLT},
+#endif
+#ifdef SIGCHLD
+	{"SIGCHLD", SIGCHLD},
+#endif
+#ifdef SIGCONT
+	{"SIGCONT", SIGCONT},
+#endif
+#ifdef SIGSTOP
+	{"SIGSTOP", SIGSTOP},
+#endif
+#ifdef SIGTSTP
+	{"SIGTSTP", SIGTSTP},
+#endif
+#ifdef SIGTTIN
+	{"SIGTTIN", SIGTTIN},
+#endif
+#ifdef SIGTTOU
+	{"SIGTTOU", SIGTTOU},
+#endif
+#ifdef SIGURG
+	{"SIGURG", SIGURG},
+#endif
+#ifdef SIGXCPU
+	{"SIGXCPU", SIGXCPU},
+#endif
+#ifdef SIGXFSZ
+	{"SIGXFSZ", SIGXFSZ},
+#endif
+#ifdef SIGVTALRM
+	{"SIGVTALRM", SIGVTALRM},
+#endif
+#ifdef SIGPROF
+	{"SIGPROF", SIGPROF},
+#endif
+#ifdef SIGWINCH
+	{"SIGWINCH", SIGWINCH},
+#endif
+#ifdef SIGIO
+	{"SIGIO", SIGIO},
+#endif
+#ifdef SIGPOLL
+	{"SIGPOLL", SIGPOLL},
+#endif
+#ifdef SIGPWR
+	{"SIGPWR", SIGPWR},
+#endif
+#ifdef SIGSYS
+	{"SIGSYS", SIGSYS},
+#endif
+	{NULL, 0},
+};
+
+/* }}} */
+
+/* {{{ Stream actions */
+
+#define POPEN_LUA_STREAM_INHERIT	"inherit"
+#define POPEN_LUA_STREAM_DEVNULL	"devnull"
+#define POPEN_LUA_STREAM_CLOSE		"close"
+#define POPEN_LUA_STREAM_PIPE		"pipe"
+
+static const struct {
+	const char *name;
+	const char *value;
+	bool devnull;
+	bool close;
+	bool pipe;
+} popen_lua_actions[] = {
+	{
+		.name		= "INHERIT",
+		.value		= POPEN_LUA_STREAM_INHERIT,
+		.devnull	= false,
+		.close		= false,
+		.pipe		= false,
+	},
+	{
+		.name		= "DEVNULL",
+		.value		= POPEN_LUA_STREAM_DEVNULL,
+		.devnull	= true,
+		.close		= false,
+		.pipe		= false,
+	},
+	{
+		.name		= "CLOSE",
+		.value		= POPEN_LUA_STREAM_CLOSE,
+		.devnull	= false,
+		.close		= true,
+		.pipe		= false,
+	},
+	{
+		.name		= "PIPE",
+		.value		= POPEN_LUA_STREAM_PIPE,
+		.devnull	= false,
+		.close		= false,
+		.pipe		= true,
+	},
+	{NULL, NULL, false, false, false},
+};
+
+/* }}} */
+
+/* {{{ Stream status */
+
+#define POPEN_LUA_STREAM_STATUS_OPEN	"open"
+#define POPEN_LUA_STREAM_STATUS_CLOSED	"closed"
+
+static const struct {
+	const char *name;
+	const char *value;
+} popen_lua_stream_status[] = {
+	{"OPEN",	POPEN_LUA_STREAM_STATUS_OPEN},
+	{"CLOSED",	POPEN_LUA_STREAM_STATUS_CLOSED},
+	{NULL, NULL},
+};
+
+/* }}} */
+
+/* {{{ Process states */
+
+#define POPEN_LUA_STATE_ALIVE "alive"
+#define POPEN_LUA_STATE_EXITED "exited"
+#define POPEN_LUA_STATE_SIGNALED "signaled"
+
+static const struct {
+	const char *name;
+	const char *value;
+} popen_lua_states[] = {
+	{"ALIVE",	POPEN_LUA_STATE_ALIVE},
+	{"EXITED",	POPEN_LUA_STATE_EXITED},
+	{"SIGNALED",	POPEN_LUA_STATE_SIGNALED},
+	{NULL, NULL},
+};
+
+/* }}} */
+
+/* {{{ General-purpose Lua helpers */
+
+/**
+ * Extract a string from the Lua stack.
+ *
+ * Return (const char *) for a string, otherwise return NULL.
+ *
+ * Unlike luaL_tolstring() it accepts only a string and does not
+ * accept a number.
+ */
+static const char *
+luaL_tolstring_strict(struct lua_State *L, int idx, size_t *len_ptr)
+{
+	if (lua_type(L, idx) != LUA_TSTRING)
+		return NULL;
+
+	const char *res = lua_tolstring(L, idx, len_ptr);
+	assert(res != NULL);
+	return res;
+}
+
+/**
+ * Extract a timeout value from the Lua stack.
+ *
+ * Return -1.0 when error occurs.
+ */
+static ev_tstamp
+luaT_check_timeout(struct lua_State *L, int idx)
+{
+	if (lua_type(L, idx) == LUA_TNUMBER)
+		return lua_tonumber(L, idx);
+	/* FIXME: Support cdata<int64_t> and cdata<uint64_t>. */
+	return -1.0;
+}
+
+/**
+ * Helper for luaT_push_string_noxc().
+ */
+static int
+luaT_push_string_noxc_wrapper(struct lua_State *L)
+{
+	char *str = (char *)lua_topointer(L, 1);
+	size_t len = lua_tointeger(L, 2);
+	lua_pushlstring(L, str, len);
+	return 1;
+}
+
+/**
+ * Push a string to the Lua stack.
+ *
+ * Return 0 at success, -1 at failure and set a diag.
+ *
+ * Possible errors:
+ *
+ * - LuajitError ("not enough memory"): no memory space for the
+ *   Lua string.
+ */
+static int
+luaT_push_string_noxc(struct lua_State *L, char *str, size_t len)
+{
+	lua_pushcfunction(L, luaT_push_string_noxc_wrapper);
+	lua_pushlightuserdata(L, str);
+	lua_pushinteger(L, len);
+	return luaT_call(L, 2, 1);
+}
+
+/* }}} */
+
+/* {{{ Popen handle userdata manipulations */
+
+/**
+ * Extract popen handle from the Lua stack.
+ *
+ * Return NULL in case of unexpected type.
+ */
+static struct popen_handle *
+luaT_check_popen_handle(struct lua_State *L, int idx, bool *is_closed_ptr)
+{
+	struct popen_handle **handle_ptr =
+		luaL_testudata(L, idx, popen_handle_uname);
+	bool is_closed = false;
+
+	if (handle_ptr == NULL) {
+		handle_ptr = luaL_testudata(L, idx, popen_handle_closed_uname);
+		is_closed = true;
+	}
+
+	if (handle_ptr == NULL)
+		return NULL;
+	assert(*handle_ptr != NULL);
+
+	if (is_closed_ptr != NULL)
+		*is_closed_ptr = is_closed;
+	return *handle_ptr;
+}
+
+/**
+ * Push popen handle into the Lua stack.
+ *
+ * Return 1 -- amount of pushed values.
+ */
+static int
+luaT_push_popen_handle(struct lua_State *L, struct popen_handle *handle)
+{
+	*(struct popen_handle **)lua_newuserdata(L, sizeof(handle)) = handle;
+	luaL_getmetatable(L, popen_handle_uname);
+	lua_setmetatable(L, -2);
+	return 1;
+}
+
+/**
+ * Mark popen handle as closed.
+ *
+ * Does not perform any checks whether @a idx points
+ * to a popen handle.
+ *
+ * The closed state is needed primarily to protect a
+ * handle from double freeing.
+ */
+static void
+luaT_mark_popen_handle_closed(struct lua_State *L, int idx)
+{
+	luaL_getmetatable(L, popen_handle_closed_uname);
+	lua_setmetatable(L, idx);
+}
+
+/* }}} */
+
+/* {{{ Push popen handle info to the Lua stack */
+
+/**
+ * Convert ...FD_STD* flags to a popen.opts.<...> constant.
+ *
+ * If flags are invalid, push 'invalid' string.
+ *
+ * Push the result onto the Lua stack.
+ */
+static int
+luaT_push_popen_stdX_action(struct lua_State *L, int fd, unsigned int flags)
+{
+	for (size_t i = 0; popen_lua_actions[i].name != NULL; ++i) {
+		bool devnull	= (flags & pfd_map[fd].mask_devnull) != 0;
+		bool close	= (flags & pfd_map[fd].mask_close) != 0;
+		bool pipe	= (flags & pfd_map[fd].mask_pipe) != 0;
+
+		if (devnull == popen_lua_actions[i].devnull &&
+		    close   == popen_lua_actions[i].close &&
+		    pipe    == popen_lua_actions[i].pipe) {
+			lua_pushstring(L, popen_lua_actions[i].value);
+			return 1;
+		}
+	}
+
+	lua_pushliteral(L, "invalid");
+	return 1;
+}
+
+/**
+ * Push a piped stream status (open or closed) to the Lua stack.
+ */
+static int
+luaT_push_popen_stdX_status(struct lua_State *L, struct popen_handle *handle,
+			    int idx)
+{
+	if ((handle->flags & pfd_map[idx].mask_pipe) == 0) {
+		/* Stream action: INHERIT, DEVNULL or CLOSE. */
+		lua_pushnil(L);
+		return 1;
+	}
+
+	/* Stream action: PIPE. */
+	if (handle->ios[idx].fd < 0)
+		lua_pushliteral(L, POPEN_LUA_STREAM_STATUS_CLOSED);
+	else
+		lua_pushliteral(L, POPEN_LUA_STREAM_STATUS_OPEN);
+
+	return 1;
+}
+
+/**
+ * Push popen options as a Lua table.
+ *
+ * Environment variables are not stored in a popen handle and so
+ * missed here.
+ */
+static int
+luaT_push_popen_opts(struct lua_State *L, unsigned int flags)
+{
+	lua_createtable(L, 0, 8);
+
+	/*
+	 * FIXME: Loop over a static array of stdX options.
+	 *
+	 * static const struct {
+	 *	const char *option_name;
+	 *	int fd;
+	 * } popen_lua_stdX_options = {
+	 *	{"stdin",	STDIN_FILENO	},
+	 *	{"stdout",	STDOUT_FILENO	},
+	 *	{"stderr",	STDERR_FILENO	},
+	 *	{NULL,		-1		},
+	 * };
+	 */
+
+	luaT_push_popen_stdX_action(L, STDIN_FILENO, flags);
+	lua_setfield(L, -2, "stdin");
+
+	luaT_push_popen_stdX_action(L, STDOUT_FILENO, flags);
+	lua_setfield(L, -2, "stdout");
+
+	luaT_push_popen_stdX_action(L, STDERR_FILENO, flags);
+	lua_setfield(L, -2, "stderr");
+
+	/* env is skipped */
+
+	/* FIXME: Loop over a static array of boolean options. */
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_SHELL) != 0);
+	lua_setfield(L, -2, "shell");
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_SETSID) != 0);
+	lua_setfield(L, -2, "setsid");
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_CLOSE_FDS) != 0);
+	lua_setfield(L, -2, "close_fds");
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_RESTORE_SIGNALS) != 0);
+	lua_setfield(L, -2, "restore_signals");
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_GROUP_SIGNAL) != 0);
+	lua_setfield(L, -2, "group_signal");
+
+	lua_pushboolean(L, (flags & POPEN_FLAG_KEEP_CHILD) != 0);
+	lua_setfield(L, -2, "keep_child");
+
+	return 1;
+}
+
+/**
+ * Push a process status to the Lua stack as a table.
+ *
+ * The format of the resulting table:
+ *
+ *     {
+ *         state = one-of(
+ *             popen.state.ALIVE    (== 'alive'),
+ *             popen.state.EXITED   (== 'exited'),
+ *             popen.state.SIGNALED (== 'signaled'),
+ *         )
+ *
+ *         -- Present when `state` is 'exited'.
+ *         exit_code = <number>,
+ *
+ *         -- Present when `state` is 'signaled'.
+ *         signo = <number>,
+ *         signame = <string>,
+ *     }
+ *
+ * @param state POPEN_STATE_{ALIVE,EXITED,SIGNALED}
+ *
+ * @param exit_code is exit code when the process is exited and a
+ * signal number when a process is signaled.
+ *
+ * @see enum popen_states
+ * @see popen_state()
+ */
+static int
+luaT_push_popen_process_status(struct lua_State *L, int state, int exit_code)
+{
+	lua_createtable(L, 0, 3);
+
+	switch (state) {
+	case POPEN_STATE_ALIVE:
+		lua_pushliteral(L, POPEN_LUA_STATE_ALIVE);
+		lua_setfield(L, -2, "state");
+		break;
+	case POPEN_STATE_EXITED:
+		lua_pushliteral(L, POPEN_LUA_STATE_EXITED);
+		lua_setfield(L, -2, "state");
+		lua_pushinteger(L, exit_code);
+		lua_setfield(L, -2, "exit_code");
+		break;
+	case POPEN_STATE_SIGNALED:
+		lua_pushliteral(L, POPEN_LUA_STATE_SIGNALED);
+		lua_setfield(L, -2, "state");
+		lua_pushinteger(L, exit_code);
+		lua_setfield(L, -2, "signo");
+
+		/*
+		 * FIXME: Preallocate signo -> signal name
+		 * mapping.
+		 */
+		const char *signame = "unknown";
+		for (int i = 0; popen_lua_signals[i].signame != NULL; ++i) {
+			if (popen_lua_signals[i].signo == exit_code)
+				signame = popen_lua_signals[i].signame;
+		}
+		lua_pushstring(L, signame);
+		lua_setfield(L, -2, "signame");
+
+		break;
+	default:
+		unreachable();
+	}
+
+	return 1;
+}
+
+/* }}} */
+
+/* {{{ Errors */
+
+/**
+ * Raise IllegalParams error re closed popen handle.
+ */
+static int
+luaT_popen_handle_closed_error(struct lua_State *L)
+{
+	diag_set(IllegalParams, "popen: attempt to operate on a closed handle");
+	return luaT_error(L);
+}
+
+/**
+ * Raise IllegalParams error re wrong parameter.
+ */
+static int
+luaT_popen_param_value_error(struct lua_State *L, const char *got,
+			     const char *func_name, const char *param,
+			     const char *exp)
+{
+	static const char *fmt =
+		"%s: wrong parameter \"%s\": expected %s, got %s";
+	diag_set(IllegalParams, fmt, func_name, param, exp, got);
+	return luaT_error(L);
+}
+
+/**
+ * Raise IllegalParams error re wrong parameter type.
+ */
+static int
+luaT_popen_param_type_error(struct lua_State *L, int idx, const char *func_name,
+			    const char *param, const char *exp)
+{
+	const char *typename = idx == 0 ?
+		"<unknown>" : lua_typename(L, lua_type(L, idx));
+	static const char *fmt =
+		"%s: wrong parameter \"%s\": expected %s, got %s";
+	diag_set(IllegalParams, fmt, func_name, param, exp, typename);
+	return luaT_error(L);
+}
+
+/**
+ * Raise IllegalParams error re wrong parameter type in an array.
+ */
+static int
+luaT_popen_array_elem_type_error(struct lua_State *L, int idx,
+				 const char *func_name, const char *param,
+				 int num, const char *exp)
+{
+	const char *typename = idx == 0 ?
+		"<unknown>" : lua_typename(L, lua_type(L, idx));
+	static const char *fmt =
+		"%s: wrong parameter \"%s[%d]\": expected %s, got %s";
+	diag_set(IllegalParams, fmt, func_name, param, num, exp, typename);
+	return luaT_error(L);
+}
+
+/* }}} */
+
+/* {{{ Parameter parsing */
+
+/**
+ * Parse popen.new() "opts.{stdin,stdout,stderr}" parameter.
+ *
+ * Result: @a flags_p is updated.
+ *
+ * Raise an error in case of the incorrect parameter.
+ */
+static void
+luaT_popen_parse_stdX(struct lua_State *L, int idx, int fd,
+		      unsigned int *flags_p)
+{
+	const char *action;
+	size_t action_len;
+	if ((action = luaL_tolstring_strict(L, idx, &action_len)) == NULL)
+		luaT_popen_param_type_error(L, idx, "popen.new",
+					    pfd_map[fd].option_name,
+					    "string or nil");
+
+	unsigned int flags = *flags_p;
+
+	/* See popen_lua_actions. */
+	if (strncmp(action, POPEN_LUA_STREAM_INHERIT, action_len) == 0) {
+		flags &= ~pfd_map[fd].mask_devnull;
+		flags &= ~pfd_map[fd].mask_close;
+		flags &= ~pfd_map[fd].mask_pipe;
+	} else if (strncmp(action, POPEN_LUA_STREAM_DEVNULL, action_len) == 0) {
+		flags |= pfd_map[fd].mask_devnull;
+		flags &= ~pfd_map[fd].mask_close;
+		flags &= ~pfd_map[fd].mask_pipe;
+	} else if (strncmp(action, POPEN_LUA_STREAM_CLOSE, action_len) == 0) {
+		flags &= ~pfd_map[fd].mask_devnull;
+		flags |= pfd_map[fd].mask_close;
+		flags &= ~pfd_map[fd].mask_pipe;
+	} else if (strncmp(action, POPEN_LUA_STREAM_PIPE, action_len) == 0) {
+		flags &= ~pfd_map[fd].mask_devnull;
+		flags &= ~pfd_map[fd].mask_close;
+		flags |= pfd_map[fd].mask_pipe;
+	} else {
+		luaT_popen_param_value_error(L, action, "popen.new",
+					     pfd_map[fd].option_name,
+					     "popen.opts.<...> constant");
+		unreachable();
+	}
+
+	*flags_p = flags;
+}
+
+/**
+ * Glue key and value on the Lua stack into "key=value" entry.
+ *
+ * Raise an error in case of the incorrect parameter.
+ *
+ * Return NULL in case of an allocation error and set a diag
+ * (OutOfMemory).
+ */
+static char *
+luaT_popen_parse_env_entry(struct lua_State *L, int key_idx, int value_idx,
+			   struct region *region)
+{
+	size_t key_len;
+	size_t value_len;
+	const char *key = luaL_tolstring_strict(L, key_idx, &key_len);
+	const char *value = luaL_tolstring_strict(L, value_idx, &value_len);
+	if (key == NULL || value == NULL) {
+		luaT_popen_param_value_error(L, "a non-string key or value",
+					     "popen.new", "opts.env",
+					     "{[<string>] = <string>, ...}");
+		unreachable();
+		return NULL;
+	}
+
+	/*
+	 * FIXME: Don't sure, but maybe it would be right to
+	 * validate key and value here against '=', '\0' and
+	 * maybe other symbols.
+	 */
+
+	/* entry = "${key}=${value}" */
+	size_t entry_size = key_len + value_len + 2;
+	char *entry = region_alloc(region, entry_size);
+	if (entry == NULL) {
+		diag_set(OutOfMemory, entry_size, "region_alloc", "env entry");
+		return NULL;
+	}
+	memcpy(entry, key, key_len);
+	size_t pos = key_len;
+	entry[pos++] = '=';
+	memcpy(entry + pos, value, value_len);
+	pos += value_len;
+	entry[pos++] = '\0';
+	assert(pos == entry_size);
+
+	return entry;
+}
+
+/**
+ * Parse popen.new() "opts.env" parameter.
+ *
+ * Return a new array in the `extern char **environ` format
+ * (NULL terminated array of "foo=bar" strings).
+ *
+ * Strings and array of them are allocated on the provided
+ * region. A caller should call region_used() before invoking
+ * this function and call region_truncate() when the result
+ * is not needed anymore. Alternatively a caller may assume
+ * that fiber_gc() will collect this memory eventually, but
+ * it is recommended to do so only for rare paths.
+ *
+ * Raise an error in case of the incorrect parameter.
+ *
+ * Return NULL in case of an allocation error and set a diag
+ * (OutOfMemory).
+ */
+static char **
+luaT_popen_parse_env(struct lua_State *L, int idx, struct region *region)
+{
+	if (lua_type(L, idx) != LUA_TTABLE) {
+		luaT_popen_param_type_error(L, idx, "popen.new", "opts.env",
+					    "table or nil");
+		unreachable();
+		return NULL;
+	}
+
+	/* Calculate absolute value in the stack. */
+	if (idx < 0)
+		idx = lua_gettop(L) + idx + 1;
+
+	size_t capacity = POPEN_LUA_ENV_CAPACITY_DEFAULT;
+	size_t size = capacity * sizeof(char *);
+	size_t region_svp = region_used(region);
+	char **env = region_alloc(region, size);
+	if (env == NULL) {
+		diag_set(OutOfMemory, size, "region_alloc", "env array");
+		return NULL;
+	}
+	size_t nr_env = 0;
+
+	bool only_count = false;
+
+	/*
+	 * Traverse over the table and fill `env` array. If
+	 * default `env` capacity is not enough, discard
+	 * everything, but continue iterating to count entries.
+	 */
+	lua_pushnil(L);
+	while (lua_next(L, idx) != 0) {
+		/*
+		 * Can we store the next entry and trailing NULL?
+		 */
+		if (nr_env >= capacity - 1) {
+			env = NULL;
+			region_truncate(region, region_svp);
+			only_count = true;
+		}
+		if (only_count) {
+			++nr_env;
+			lua_pop(L, 1);
+			continue;
+		}
+		char *entry = luaT_popen_parse_env_entry(L, -2, -1, region);
+		if (entry == NULL) {
+			region_truncate(region, region_svp);
+			return NULL;
+		}
+		env[nr_env++] = entry;
+		lua_pop(L, 1);
+	}
+
+	if (! only_count) {
+		assert(nr_env < capacity);
+		env[nr_env] = NULL;
+		return env;
+	}
+
+	/*
+	 * Now we know exact amount of elements. Run
+	 * the traverse again and fill `env` array.
+	 */
+	capacity = nr_env + 1;
+	size = capacity * sizeof(char *);
+	if ((env = region_alloc(region, size)) == NULL) {
+		region_truncate(region, region_svp);
+		diag_set(OutOfMemory, size, "region_alloc", "env array");
+		return NULL;
+	}
+	nr_env = 0;
+	lua_pushnil(L);
+	while (lua_next(L, idx) != 0) {
+		char *entry = luaT_popen_parse_env_entry(L, -2, -1, region);
+		if (entry == NULL) {
+			region_truncate(region, region_svp);
+			return NULL;
+		}
+		assert(nr_env < capacity - 1);
+		env[nr_env++] = entry;
+		lua_pop(L, 1);
+	}
+	assert(nr_env == capacity - 1);
+	env[nr_env] = NULL;
+	return env;
+}
+
+/**
+ * Parse popen.new() "opts" parameter.
+ *
+ * Prerequisite: @a opts should be zero filled.
+ *
+ * Result: @a opts structure is filled.
+ *
+ * Raise an error in case of the incorrect parameter.
+ *
+ * Return 0 at success. Allocates opts->env on @a region if
+ * needed. @see luaT_popen_parse_env() for details how to
+ * free it.
+ *
+ * Return -1 in case of an allocation error and set a diag
+ * (OutOfMemory).
+ */
+static int
+luaT_popen_parse_opts(struct lua_State *L, int idx, struct popen_opts *opts,
+		      struct region *region)
+{
+	/*
+	 * Default flags: inherit std*, close other fds,
+	 * restore signals.
+	 */
+	opts->flags = POPEN_FLAG_NONE		|
+		POPEN_FLAG_CLOSE_FDS		|
+		POPEN_FLAG_RESTORE_SIGNALS;
+
+	/* Parse options. */
+	if (lua_type(L, idx) == LUA_TTABLE) {
+		/*
+		 * FIXME: Loop over a static array of stdX
+		 * options.
+		 */
+
+		lua_getfield(L, idx, "stdin");
+		if (! lua_isnil(L, -1)) {
+			luaT_popen_parse_stdX(L, -1, STDIN_FILENO,
+					      &opts->flags);
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "stdout");
+		if (! lua_isnil(L, -1))
+			luaT_popen_parse_stdX(L, -1, STDOUT_FILENO,
+					      &opts->flags);
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "stderr");
+		if (! lua_isnil(L, -1))
+			luaT_popen_parse_stdX(L, -1, STDERR_FILENO,
+					      &opts->flags);
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "env");
+		if (! lua_isnil(L, -1)) {
+			opts->env = luaT_popen_parse_env(L, -1, region);
+			if (opts->env == NULL)
+				return -1;
+		}
+		lua_pop(L, 1);
+
+		/*
+		 * FIXME: Loop over a static array of boolean
+		 * options.
+		 */
+
+		lua_getfield(L, idx, "shell");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(L, -1, "popen.new",
+							    "opts.shell",
+							    "boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_SHELL;
+			else
+				opts->flags |= POPEN_FLAG_SHELL;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "setsid");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(L, -1, "popen.new",
+							    "opts.setsid",
+							    "boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_SETSID;
+			else
+				opts->flags |= POPEN_FLAG_SETSID;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "close_fds");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(L, -1, "popen.new",
+							    "opts.close_fds",
+							    "boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_CLOSE_FDS;
+			else
+				opts->flags |= POPEN_FLAG_CLOSE_FDS;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "restore_signals");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(
+					L, -1, "popen.new",
+					"opts.restore_signals",
+					"boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_RESTORE_SIGNALS;
+			else
+				opts->flags |= POPEN_FLAG_RESTORE_SIGNALS;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "group_signal");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(L, -1, "popen.new",
+							    "opts.group_signal",
+							    "boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_GROUP_SIGNAL;
+			else
+				opts->flags |= POPEN_FLAG_GROUP_SIGNAL;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, idx, "keep_child");
+		if (! lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				luaT_popen_param_type_error(L, -1, "popen.new",
+							    "opts.keep_child",
+							    "boolean or nil");
+			if (lua_toboolean(L, -1) == 0)
+				opts->flags &= ~POPEN_FLAG_KEEP_CHILD;
+			else
+				opts->flags |= POPEN_FLAG_KEEP_CHILD;
+		}
+		lua_pop(L, 1);
+	}
+
+	return 0;
+}
+
+/**
+ * Parse popen.new() "argv" parameter.
+ *
+ * Prerequisite: opts->flags & POPEN_FLAG_SHELL should be
+ * the same in a call of this function and when paired
+ * popen_new() is invoked.
+ *
+ * Raise an error in case of the incorrect parameter.
+ *
+ * Return 0 at success. Sets opts->argv and opts->nr_argv.
+ * Allocates opts->argv on @a region (@see
+ * luaT_popen_parse_env() for details how to free it).
+ *
+ * Return -1 in case of an allocation error and set a diag
+ * (OutOfMemory).
+ */
+static int
+luaT_popen_parse_argv(struct lua_State *L, int idx, struct popen_opts *opts,
+		      struct region *region)
+{
+	size_t region_svp = region_used(region);
+
+	/*
+	 * We need to know exact size of the array to allocate
+	 * a memory for opts->argv without reallocations.
+	 *
+	 * lua_objlen() does not guarantee that there are no
+	 * holes in the array, but we check it within a loop
+	 * later.
+	 */
+	size_t argv_len = lua_objlen(L, idx);
+
+	/* ["sh", "-c", ]..., NULL. */
+	opts->nr_argv = argv_len + 1;
+	if (opts->flags & POPEN_FLAG_SHELL)
+		opts->nr_argv += 2;
+
+	size_t size = sizeof(char *) * opts->nr_argv;
+	opts->argv = region_alloc(region, size);
+	if (opts->argv == NULL) {
+		diag_set(OutOfMemory, size, "region_alloc", "argv");
+		return -1;
+	}
+
+	/* Keep place for "sh", "-c" as popen_new() expects. */
+	const char **to = (const char **)opts->argv;
+	if (opts->flags & POPEN_FLAG_SHELL) {
+		opts->argv[0] = NULL;
+		opts->argv[1] = NULL;
+		to += 2;
+	}
+
+	for (size_t i = 0; i < argv_len; ++i) {
+		lua_rawgeti(L, idx, i + 1);
+		const char *arg = luaL_tolstring_strict(L, -1, NULL);
+		if (arg == NULL) {
+			region_truncate(region, region_svp);
+			return luaT_popen_array_elem_type_error(
+				L, -1, "popen.new", "argv", i + 1, "string");
+		}
+		*to++ = arg;
+		lua_pop(L, 1);
+	}
+	*to++ = NULL;
+	assert((const char **)opts->argv + opts->nr_argv == to);
+
+	return 0;
+}
+
+/**
+ * Parse popen.shell() "mode" parameter.
+ *
+ * Convert "mode" parameter into options table for popen.new().
+ * Push the table to the Lua stack.
+ *
+ * Raise an error in case of the incorrect parameter.
+ */
+static void
+luaT_popen_parse_mode(struct lua_State *L, int idx)
+{
+	if (lua_type(L, idx) != LUA_TSTRING &&
+	    lua_type(L, idx) != LUA_TNONE &&
+	    lua_type(L, idx) != LUA_TNIL)
+		luaT_popen_param_type_error(L, idx, "popen.shell", "mode",
+					    "string or nil");
+
+	/*
+	 * Create options table for popen.new().
+	 *
+	 * Preallocate space for shell, setsid, group_signal and
+	 * std{in,out,err} options.
+	 */
+	lua_createtable(L, 0, 5);
+
+	lua_pushboolean(L, true);
+	lua_setfield(L, -2, "shell");
+
+	lua_pushboolean(L, true);
+	lua_setfield(L, -2, "setsid");
+
+	lua_pushboolean(L, true);
+	lua_setfield(L, -2, "group_signal");
+
+	/*
+	 * When mode is nil, left std* params default, which means
+	 * to inherit parent's file descriptors in a child
+	 * process.
+	 */
+	if (lua_isnoneornil(L, idx))
+		return;
+
+	size_t mode_len;
+	const char *mode = lua_tolstring(L, idx, &mode_len);
+	for (size_t i = 0; i < mode_len; ++i) {
+		switch (mode[i]) {
+		case 'r':
+			lua_pushstring(L, POPEN_LUA_STREAM_PIPE);
+			lua_setfield(L, -2, "stdout");
+			break;
+		case 'R':
+			lua_pushstring(L, POPEN_LUA_STREAM_PIPE);
+			lua_setfield(L, -2, "stderr");
+			break;
+		case 'w':
+			lua_pushstring(L, POPEN_LUA_STREAM_PIPE);
+			lua_setfield(L, -2, "stdin");
+			break;
+		default:
+			luaT_popen_param_value_error(
+				L, mode, "popen.shell", "mode",
+				"'r', 'w', 'R' or its combination");
+		}
+	}
+}
+
+/* }}} */
+
+/* {{{ Lua API functions and methods */
+
+/**
+ * Execute a child program in a new process.
+ *
+ * @param argv  an array of a program to run with
+ *              command line options, mandatory;
+ *              absolute path to the program is required
+ *              when @a opts.shell is false (default)
+ *
+ * @param opts  table of options
+ *
+ * @param opts.stdin   action on STDIN_FILENO
+ * @param opts.stdout  action on STDOUT_FILENO
+ * @param opts.stderr  action on STDERR_FILENO
+ *
+ * File descriptor actions:
+ *
+ *     popen.opts.INHERIT  (== 'inherit') [default]
+ *                         inherit the fd from the parent
+ *     popen.opts.DEVNULL  (== 'devnull')
+ *                         open /dev/null on the fd
+ *     popen.opts.CLOSE    (== 'close')
+ *                         close the fd
+ *     popen.opts.PIPE     (== 'pipe')
+ *                         feed data from/to the fd to parent
+ *                         using a pipe
+ *
+ * @param opts.env  a table of environment variables to
+ *                  be used inside a process; key is a
+ *                  variable name, value is a variable
+ *                  value.
+ *                  - when is not set then the current
+ *                    environment is inherited;
+ *                  - if set to an empty table then the
+ *                    environment will be dropped
+ *                  - if set then the environment will be
+ *                    replaced
+ *
+ * @param opts.shell            (boolean, default: false)
+ *        true                  run a child process via
+ *                              'sh -c "${opts.argv}"'
+ *        false                 call the executable directly
+ *
+ * @param opts.setsid           (boolean, default: false)
+ *        true                  run the program in a new
+ *                              session
+ *        false                 run the program in the
+ *                              tarantool instance's
+ *                              session and process group
+ *
+ * @param opts.close_fds        (boolean, default: true)
+ *        true                  close all inherited fds from a
+ *                              parent
+ *        false                 don't do that
+ *
+ * @param opts.restore_signals  (boolean, default: true)
+ *        true                  reset all signal actions
+ *                              modified in parent's process
+ *        false                 inherit changed actions
+ *
+ * @param opts.group_signal     (boolean, default: false)
+ *        true                  send signal to a child process
+ *                              group (only when opts.setsid is
+ *                              enabled)
+ *        false                 send signal to a child process
+ *                              only
+ *
+ * @param opts.keep_child       (boolean, default: false)
+ *        true                  don't send SIGKILL to a child
+ *                              process at freeing (by :close()
+ *                              or Lua GC)
+ *        false                 send SIGKILL to a child process
+ *                              (or a process group if
+ *                              opts.group_signal is enabled) at
+ *                              :close() or collecting of the
+ *                              handle by Lua GC
+ *
+ * The returned handle provides :close() method to explicitly
+ * release all occupied resources (including the child process
+ * itself if @a opts.keep_child is not set). However if the
+ * method is not called for a handle during its lifetime, the
+ * same freeing actions will be triggered by Lua GC.
+ *
+ * It is recommended to use opts.setsid + opts.group_signal
+ * if a child process may spawn its own childs and they all
+ * should be killed together.
+ *
+ * Note: A signal will not be sent if the child process is
+ * already dead: otherwise we might kill another process that
+ * occupies the same PID later. This means that if the child
+ * process dies before its own childs, the function will not
+ * send a signal to the process group even when opts.setsid and
+ * opts.group_signal are set.
+ *
+ * Use os.environ() to pass copy of current environment with
+ * several replacements (see example 2 below).
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams: incorrect type or value of a parameter.
+ * - IllegalParams: group signal is set, while setsid is not.
+ *
+ * Return a popen handle on success.
+ *
+ * Return `nil, err` on a failure. Possible reasons:
+ *
+ * - SystemError: dup(), fcntl(), pipe(), vfork() or close()
+ *                fails in the parent process.
+ * - SystemError: (temporary restriction) the parent process
+ *                has closed stdin, stdout or stderr.
+ * - OutOfMemory: unable to allocate the handle or a temporary
+ *                buffer.
+ *
+ * Example 1:
+ *
+ *  | local popen = require('popen')
+ *  |
+ *  | local ph = popen.new({'/bin/date'}, {
+ *  |     stdout = popen.opts.PIPE,
+ *  | })
+ *  | local date = ph:read():rstrip()
+ *  | ph:close()
+ *  | print(date) -- Thu 16 Apr 2020 01:40:56 AM MSK
+ *
+ * Execute 'date' command, read the result and close the
+ * popen object.
+ *
+ * Example 2:
+ *
+ *  | local popen = require('popen')
+ *  |
+ *  | local env = os.environ()
+ *  | env['FOO'] = 'bar'
+ *  |
+ *  | local ph = popen.new({'echo "${FOO}"'}, {
+ *  |     stdout = popen.opts.PIPE,
+ *  |     shell = true,
+ *  |     env = env,
+ *  | })
+ *  | local res = ph:read():rstrip()
+ *  | ph:close()
+ *  | print(res) -- bar
+ *
+ * It is quite similar to the previous one, but sets the
+ * environment variable and uses shell builtin 'echo' to
+ * show it.
+ *
+ * Example 3:
+ *
+ *  | local popen = require('popen')
+ *  |
+ *  | local ph = popen.new({'echo hello >&2'}, { -- !!
+ *  |     stderr = popen.opts.PIPE,              -- !!
+ *  |     shell = true,
+ *  | })
+ *  | local res = ph:read({stderr = true}):rstrip()
+ *  | ph:close()
+ *  | print(res) -- hello
+ *
+ * This example demonstrates how to capture child's stderr.
+ *
+ * Example 4:
+ *
+ *  | local function call_jq(input, filter)
+ *  |     -- Start jq process, connect to stdin, stdout and stderr.
+ *  |     local jq_argv = {'/usr/bin/jq', '-M', '--unbuffered', filter}
+ *  |     local ph, err = popen.new(jq_argv, {
+ *  |         stdin = popen.opts.PIPE,
+ *  |         stdout = popen.opts.PIPE,
+ *  |         stderr = popen.opts.PIPE,
+ *  |     })
+ *  |     if ph == nil then return nil, err end
+ *  |
+ *  |     -- Write input data to child's stdin and send EOF.
+ *  |     local ok, err = ph:write(input)
+ *  |     if not ok then return nil, err end
+ *  |     ph:shutdown({stdin = true})
+ *  |
+ *  |     -- Read everything until EOF.
+ *  |     local chunks = {}
+ *  |     while true do
+ *  |         local chunk, err = ph:read()
+ *  |         if chunk == nil then
+ *  |             ph:close()
+ *  |             return nil, err
+ *  |         end
+ *  |         if chunk == '' then break end -- EOF
+ *  |         table.insert(chunks, chunk)
+ *  |     end
+ *  |
+ *  |     -- Read diagnostics from stderr if any.
+ *  |     local err = ph:read({stderr = true})
+ *  |     if err ~= '' then
+ *  |         ph:close()
+ *  |         return nil, err
+ *  |     end
+ *  |
+ *  |     -- Glue all chunks, strip trailing newline.
+ *  |     return table.concat(chunks):rstrip()
+ *  | end
+ *
+ * Demonstrates how to run a stream program (like `grep`, `sed`
+ * and so), write to its stdin and read from its stdout.
+ *
+ * The example assumes that input data are small enough to fit
+ * a pipe buffer (typically 64 KiB, but depends on a platform
+ * and its configuration). It will stuck in :write() for large
+ * data. How to handle this case: call :read() in a loop in
+ * another fiber (start it before a first :write()).
+ *
+ * If a process writes large text to stderr, it may fill out
+ * stderr pipe buffer and stuck in write(2, ...). So we need
+ * to read stderr in a separate fiber to handle this case.
+ */
+static int
+lbox_popen_new(struct lua_State *L)
+{
+	struct region *region = &fiber()->gc;
+	size_t region_svp = region_used(region);
+
+	if (lua_type(L, 1) != LUA_TTABLE)
+		return luaT_popen_param_type_error(L, 1, "popen.new", "argv",
+						   "table");
+	else if (lua_type(L, 2) != LUA_TTABLE &&
+		 lua_type(L, 2) != LUA_TNONE &&
+		 lua_type(L, 2) != LUA_TNIL)
+		return luaT_popen_param_type_error(L, 2, "popen.new", "opts",
+						   "table or nil");
+
+	/* Parse opts and argv. */
+	struct popen_opts opts = {};
+	int rc = luaT_popen_parse_opts(L, 2, &opts, region);
+	if (rc != 0)
+		goto err;
+	rc = luaT_popen_parse_argv(L, 1, &opts, region);
+	if (rc != 0)
+		goto err;
+
+	struct popen_handle *handle = popen_new(&opts);
+
+	if (handle == NULL)
+		goto err;
+
+	region_truncate(region, region_svp);
+	luaT_push_popen_handle(L, handle);
+	return 1;
+
+err:
+	region_truncate(region, region_svp);
+	struct error *e = diag_last_error(diag_get());
+	if (e->type == &type_IllegalParams)
+		return luaT_error(L);
+	return luaT_push_nil_and_error(L);
+}
+
+/**
+ * Execute a shell command.
+ *
+ * @param command  a command to run, mandatory
+ * @param mode     communication mode, optional
+ *                 'w'    to use ph:write()
+ *                 'r'    to use ph:read()
+ *                 'R'    to use ph:read({stderr = true})
+ *                 nil    inherit parent's std* file descriptors
+ *
+ * Several mode characters can be set together: 'rw', 'rRw', etc.
+ *
+ * This function is just shortcut for popen.new({command}, opts)
+ * with opts.{shell,setsid,group_signal} set to `true` and
+ * and opts.{stdin,stdout,stderr} set based on `mode` parameter.
+ *
+ * All std* streams are inherited from parent by default if it is
+ * not changed using mode: 'r' for stdout, 'R' for stderr, 'w' for
+ * stdin.
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams: incorrect type or value of a parameter.
+ *
+ * Return a popen handle on success.
+ *
+ * Return `nil, err` on a failure.
+ * @see lbox_popen_new() for possible reasons.
+ *
+ * Example:
+ *
+ *  | local popen = require('popen')
+ *  |
+ *  | -- Run the program and save its handle.
+ *  | local ph = popen.shell('date', 'r')
+ *  |
+ *  | -- Read program's output, strip trailing newline.
+ *  | local date = ph:read():rstrip()
+ *  |
+ *  | -- Free resources. The process is killed (but 'date'
+ *  | -- exits itself anyway).
+ *  | ph:close()
+ *  |
+ *  | print(date)
+ *
+ * Execute 'sh -c date' command, read the output and close the
+ * popen object.
+ *
+ * Unix defines a text file as a sequence of lines, each ends
+ * with the newline symbol. The same convention is usually
+ * applied for a text output of a command (so when it is
+ * redirected to a file, the file will be correct).
+ *
+ * However internally an application usually operates on
+ * strings, which are NOT newline terminated (e.g. literals
+ * for error messages). The newline is usually added right
+ * before a string is written to the outside world (stdout,
+ * console or log). :rstrip() in the example above is shown
+ * for this sake.
+ */
+static int
+lbox_popen_shell(struct lua_State *L)
+{
+	if (lua_type(L, 1) != LUA_TSTRING)
+		return luaT_popen_param_type_error(L, 1, "popen.shell",
+						   "command", "string");
+
+	/*
+	 * Ensure that at least two stack slots are occupied.
+	 *
+	 * Otherwise we can pass `top` as `idx` to lua_replace().
+	 * lua_replace() on `top` index copies a value to itself
+	 * first and then pops it from the stack.
+	 */
+	if (lua_gettop(L) == 1)
+		lua_pushnil(L);
+
+	/* Create argv table for popen.new(). */
+	lua_createtable(L, 1, 0);
+	/* argv[1] = command */
+	lua_pushvalue(L, 1);
+	lua_rawseti(L, -2, 1);
+	/* {...}[1] == argv */
+	lua_replace(L, 1);
+
+	/* opts = parse_mode(mode) */
+	luaT_popen_parse_mode(L, 2);
+	/* {...}[2] == opts */
+	lua_replace(L, 2);
+
+	return lbox_popen_new(L);
+}
+
+/**
+ * Send signal to a child process.
+ *
+ * @param handle  a handle carries child process to be signaled
+ * @param signo   signal number to send
+ *
+ * When opts.setsid and opts.group_signal are set on the handle
+ * the signal is sent to the process group rather than to the
+ * process. @see lbox_popen_new() for details about group
+ * signaling.
+ *
+ * Note: The module offers popen.signal.SIG* constants, because
+ * some signals have different numbers on different platforms.
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams:    an incorrect handle parameter.
+ * - IllegalParams:    called on a closed handle.
+ *
+ * Return `true` if signal is sent.
+ *
+ * Return `nil, err` on a failure. Possible reasons:
+ *
+ * - SystemError: a process does not exists anymore
+ *
+ *                Aside of a non-exist process it is also
+ *                returned for a zombie process or when all
+ *                processes in a group are zombies (but
+ *                see note re Mac OS below).
+ *
+ * - SystemError: invalid signal number
+ *
+ * - SystemError: no permission to send a signal to
+ *                a process or a process group
+ *
+ *                It is returned on Mac OS when a signal is
+ *                sent to a process group, where a group leader
+ *                is zombie (or when all processes in it
+ *                are zombies, don't sure).
+ *
+ *                Whether it may appear due to other
+ *                reasons is unclear.
+ */
+static int
+lbox_popen_signal(struct lua_State *L)
+{
+	/*
+	 * FIXME: Extracting a handle and raising an error when
+	 * it is closed is repeating pattern within the file. It
+	 * worth to extract it to a function.
+	 */
+	struct popen_handle *handle;
+	bool is_closed;
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL ||
+	    !lua_isnumber(L, 2)) {
+		diag_set(IllegalParams, "Bad params, use: ph:signal(signo)");
+		return luaT_error(L);
+	}
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	int signo = lua_tonumber(L, 2);
+
+	if (popen_send_signal(handle, signo) != 0)
+		return luaT_push_nil_and_error(L);
+
+	lua_pushboolean(L, true);
+	return 1;
+}
+
+/**
+ * Send SIGTERM signal to a child process.
+ *
+ * @param handle  a handle carries child process to terminate
+ *
+ * The function only sends SIGTERM signal and does NOT
+ * free any resources (popen handle memory and file
+ * descriptors).
+ *
+ * @see lbox_popen_signal() for errors and return values.
+ */
+static int
+lbox_popen_terminate(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: ph:terminate()");
+		return luaT_error(L);
+	}
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	int signo = SIGTERM;
+
+	if (popen_send_signal(handle, signo) != 0)
+		return luaT_push_nil_and_error(L);
+
+	lua_pushboolean(L, true);
+	return 1;
+}
+
+/**
+ * Send SIGKILL signal to a child process.
+ *
+ * @param handle  a handle carries child process to kill
+ *
+ * The function only sends SIGKILL signal and does NOT
+ * free any resources (popen handle memory and file
+ * descriptors).
+ *
+ * @see lbox_popen_signal() for errors and return values.
+ */
+static int
+lbox_popen_kill(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: ph:kill()");
+		return luaT_error(L);
+	}
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	int signo = SIGKILL;
+
+	if (popen_send_signal(handle, signo) != 0)
+		return luaT_push_nil_and_error(L);
+
+	lua_pushboolean(L, true);
+	return 1;
+}
+
+/**
+ * Wait until a child process get exited or signaled.
+ *
+ * @param handle  a handle of process to wait
+ *
+ * Raise an error on incorrect parameters or when the fiber is
+ * cancelled:
+ *
+ * - IllegalParams:    an incorrect handle parameter.
+ * - IllegalParams:    called on a closed handle.
+ * - FiberIsCancelled: cancelled by an outside code.
+ *
+ * Return a process status table (the same as ph.status and
+ * ph.info().status). @see lbox_popen_info() for the format
+ * of the table.
+ */
+static int
+lbox_popen_wait(struct lua_State *L)
+{
+	/*
+	 * FIXME: Use trigger or fiber conds to sleep and wake up.
+	 * FIXME: Add timeout option: ph:wait({timeout = <...>})
+	 */
+	struct popen_handle *handle;
+	bool is_closed;
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: ph:wait()");
+		return luaT_error(L);
+	}
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	int state;
+	int exit_code;
+
+	while (true) {
+		popen_state(handle, &state, &exit_code);
+		assert(state < POPEN_STATE_MAX);
+		if (state != POPEN_STATE_ALIVE)
+			break;
+		fiber_sleep(POPEN_LUA_WAIT_DELAY);
+		luaL_testcancel(L);
+	}
+
+	return luaT_push_popen_process_status(L, state, exit_code);
+}
+
+/**
+ * Read data from a child peer.
+ *
+ * @param handle        handle of a child process
+ * @param opts          an options table
+ * @param opts.stdout   whether to read from stdout, boolean
+ *                      (default: true)
+ * @param opts.stderr   whether to read from stderr, boolean
+ *                      (default: false)
+ * @param opts.timeout  time quota in seconds
+ *                      (default: 100 years)
+ *
+ * Read data from stdout or stderr streams with @a timeout.
+ * By default it reads from stdout. Set @a opts.stderr to
+ * `true` to read from stderr.
+ *
+ * It is not possible to read from stdout and stderr both in
+ * one call. Set either @a opts.stdout or @a opts.stderr.
+ *
+ * Raise an error on incorrect parameters or when the fiber is
+ * cancelled:
+ *
+ * - IllegalParams:    incorrect type or value of a parameter.
+ * - IllegalParams:    called on a closed handle.
+ * - IllegalParams:    opts.stdout and opts.stderr are set both
+ * - IllegalParams:    a requested IO operation is not supported
+ *                     by the handle (stdout / stderr is not
+ *                     piped).
+ * - IllegalParams:    attempt to operate on a closed file
+ *                     descriptor.
+ * - FiberIsCancelled: cancelled by an outside code.
+ *
+ * Return a string on success, an empty string at EOF.
+ *
+ * Return `nil, err` on a failure. Possible reasons:
+ *
+ * - SocketError: an IO error occurs at read().
+ * - TimedOut:    @a timeout quota is exceeded.
+ * - OutOfMemory: no memory space for a buffer to read into.
+ * - LuajitError: ("not enough memory"): no memory space for
+ *                the Lua string.
+ */
+static int
+lbox_popen_read(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+
+	/*
+	 * Actual default is POPEN_FLAG_FD_STDOUT, but
+	 * it is set only when no std* option is passed.
+	 */
+	unsigned int flags = POPEN_FLAG_NONE;
+
+	ev_tstamp timeout = TIMEOUT_INFINITY;
+	struct region *region = &fiber()->gc;
+	size_t region_svp = region_used(region);
+
+	/* Extract handle. */
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL)
+		goto usage;
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	/* Extract options. */
+	if (!lua_isnoneornil(L, 2)) {
+		if (lua_type(L, 2) != LUA_TTABLE)
+			goto usage;
+
+		/* FIXME: Shorten boolean options parsing. */
+
+		lua_getfield(L, 2, "stdout");
+		if (!lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				goto usage;
+			if (lua_toboolean(L, -1) == 0)
+				flags &= ~POPEN_FLAG_FD_STDOUT;
+			else
+				flags |= POPEN_FLAG_FD_STDOUT;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, 2, "stderr");
+		if (!lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				goto usage;
+			if (lua_toboolean(L, -1) == 0)
+				flags &= ~POPEN_FLAG_FD_STDERR;
+			else
+				flags |= POPEN_FLAG_FD_STDERR;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, 2, "timeout");
+		if (!lua_isnil(L, -1) &&
+		    (timeout = luaT_check_timeout(L, -1)) < 0.0)
+			goto usage;
+		lua_pop(L, 1);
+	}
+
+	/* Read from stdout by default. */
+	if (!(flags & (POPEN_FLAG_FD_STDOUT | POPEN_FLAG_FD_STDERR)))
+		flags |= POPEN_FLAG_FD_STDOUT;
+
+	size_t size = POPEN_LUA_READ_BUF_SIZE;
+	char *buf = region_alloc(region, size);
+	if (buf == NULL) {
+		diag_set(OutOfMemory, size, "region_alloc", "read buffer");
+		return luaT_push_nil_and_error(L);
+	}
+
+	static_assert(POPEN_LUA_READ_BUF_SIZE <= SSIZE_MAX,
+		      "popen: read buffer is too big");
+	ssize_t rc = popen_read_timeout(handle, buf, size, flags, timeout);
+	if (rc < 0 || luaT_push_string_noxc(L, buf, rc) != 0)
+		goto err;
+	region_truncate(region, region_svp);
+	return 1;
+
+usage:
+	diag_set(IllegalParams, "Bad params, use: ph:read([{"
+		 "stdout = <boolean>, "
+		 "stderr = <boolean>, "
+		 "timeout = <number>}])");
+	return luaT_error(L);
+err:
+	region_truncate(region, region_svp);
+	struct error *e = diag_last_error(diag_get());
+	if (e->type == &type_IllegalParams ||
+	    e->type == &type_FiberIsCancelled)
+		return luaT_error(L);
+	return luaT_push_nil_and_error(L);
+}
+
+/**
+ * Write data to a child peer.
+ *
+ * @param handle        a handle of a child process
+ * @param str           a string to write
+ * @param opts          table of options
+ * @param opts.timeout  time quota in seconds
+ *                      (default: 100 years)
+ *
+ * Write string @a str to stdin stream of a child process.
+ *
+ * The function may yield forever if a child process does
+ * not read data from stdin and a pipe buffer becomes full.
+ * Size of this buffer depends on a platform. Use
+ * @a opts.timeout when unsure.
+ *
+ * When @a opts.timeout is not set, the function blocks
+ * (yields the fiber) until all data is written or an error
+ * happened.
+ *
+ * Raise an error on incorrect parameters or when the fiber is
+ * cancelled:
+ *
+ * - IllegalParams:    incorrect type or value of a parameter.
+ * - IllegalParams:    called on a closed handle.
+ * - IllegalParams:    string length is greater then SSIZE_MAX.
+ * - IllegalParams:    a requested IO operation is not supported
+ *                     by the handle (stdin is not piped).
+ * - IllegalParams:    attempt to operate on a closed file
+ *                     descriptor.
+ * - FiberIsCancelled: cancelled by an outside code.
+ *
+ * Return `true` on success.
+ *
+ * Return `nil, err` on a failure. Possible reasons:
+ *
+ * - SocketError: an IO error occurs at write().
+ * - TimedOut:    @a timeout quota is exceeded.
+ */
+static int
+lbox_popen_write(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+	const char *str;
+	size_t len;
+	ev_tstamp timeout = TIMEOUT_INFINITY;
+
+	/* Extract handle and string to write. */
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL ||
+	    (str = luaL_tolstring_strict(L, 2, &len)) == NULL)
+		goto usage;
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	/* Extract options. */
+	if (!lua_isnoneornil(L, 3)) {
+		if (lua_type(L, 3) != LUA_TTABLE)
+			goto usage;
+
+		lua_getfield(L, 3, "timeout");
+		if (!lua_isnil(L, -1) &&
+		    (timeout = luaT_check_timeout(L, -1)) < 0.0)
+			goto usage;
+		lua_pop(L, 1);
+	}
+
+	unsigned int flags = POPEN_FLAG_FD_STDIN;
+	ssize_t rc = popen_write_timeout(handle, str, len, flags, timeout);
+	assert(rc < 0 || rc == (ssize_t)len);
+	if (rc < 0) {
+		struct error *e = diag_last_error(diag_get());
+		if (e->type == &type_IllegalParams ||
+		    e->type == &type_FiberIsCancelled)
+			return luaT_error(L);
+		return luaT_push_nil_and_error(L);
+	}
+	lua_pushboolean(L, true);
+	return 1;
+
+usage:
+	diag_set(IllegalParams, "Bad params, use: ph:write(str[, {"
+		 "timeout = <number>}])");
+	return luaT_error(L);
+}
+
+/**
+ * Close parent's ends of std* fds.
+ *
+ * @param handle        handle of a child process
+ * @param opts          an options table
+ * @param opts.stdin    close parent's end of stdin, boolean
+ * @param opts.stdout   close parent's end of stdout, boolean
+ * @param opts.stderr   close parent's end of stderr, boolean
+ *
+ * The main reason to use this function is to send EOF to
+ * child's stdin. However parent's end of stdout / stderr
+ * may be closed too.
+ *
+ * The function does not fail on already closed fds (idempotence).
+ * However it fails on attempt to close the end of a pipe that was
+ * never exist. In other words, only those std* options that
+ * were set to popen.opts.PIPE at a handle creation may be used
+ * here (for popen.shell: 'r' corresponds to stdout, 'R' to stderr
+ * and 'w' to stdin).
+ *
+ * The function does not close any fds on a failure: either all
+ * requested fds are closed or neither of them.
+ *
+ * Example:
+ *
+ *  | local popen = require('popen')
+ *  |
+ *  | local ph = popen.shell('sed s/foo/bar/', 'rw')
+ *  | ph:write('lorem foo ipsum')
+ *  | ph:shutdown({stdin = true})
+ *  | local res = ph:read()
+ *  | ph:close()
+ *  | print(res) -- lorem bar ipsum
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams:  an incorrect handle parameter.
+ * - IllegalParams:  called on a closed handle.
+ * - IllegalParams:  neither stdin, stdout nor stderr is choosen.
+ * - IllegalParams:  a requested IO operation is not supported
+ *                   by the handle (one of std* is not piped).
+ *
+ * Return `true` on success.
+ */
+static int
+lbox_popen_shutdown(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+
+	/* Extract handle. */
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL)
+		goto usage;
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	unsigned int flags = POPEN_FLAG_NONE;
+
+	/* Extract options. */
+	if (!lua_isnoneornil(L, 2)) {
+		if (lua_type(L, 2) != LUA_TTABLE)
+			goto usage;
+
+		/*
+		 * FIXME: Those blocks duplicates ones from
+		 * lbox_popen_read().
+		 *
+		 * Let's introduce a helper like
+		 * luaT_popen_parse_stdX() but about boolean
+		 * flags rather than stream actions.
+		 */
+
+		lua_getfield(L, 2, "stdin");
+		if (!lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				goto usage;
+			if (lua_toboolean(L, -1) == 0)
+				flags &= ~POPEN_FLAG_FD_STDIN;
+			else
+				flags |= POPEN_FLAG_FD_STDIN;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, 2, "stdout");
+		if (!lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				goto usage;
+			if (lua_toboolean(L, -1) == 0)
+				flags &= ~POPEN_FLAG_FD_STDOUT;
+			else
+				flags |= POPEN_FLAG_FD_STDOUT;
+		}
+		lua_pop(L, 1);
+
+		lua_getfield(L, 2, "stderr");
+		if (!lua_isnil(L, -1)) {
+			if (lua_type(L, -1) != LUA_TBOOLEAN)
+				goto usage;
+			if (lua_toboolean(L, -1) == 0)
+				flags &= ~POPEN_FLAG_FD_STDERR;
+			else
+				flags |= POPEN_FLAG_FD_STDERR;
+		}
+		lua_pop(L, 1);
+	}
+
+	if (popen_shutdown(handle, flags) != 0) {
+		/*
+		 * FIXME: This block is often duplicated,
+		 * let's extract it to a helper.
+		 */
+		struct error *e = diag_last_error(diag_get());
+		if (e->type == &type_IllegalParams)
+			return luaT_error(L);
+		return luaT_push_nil_and_error(L);
+	}
+
+	lua_pushboolean(L, true);
+	return 1;
+
+usage:
+	diag_set(IllegalParams, "Bad params, use: ph:shutdown({"
+		 "stdin = <boolean>, "
+		 "stdout = <boolean>, "
+		 "stderr = <boolean>})");
+	return luaT_error(L);
+
+}
+
+/**
+ * Return information about popen handle.
+ *
+ * @param handle  a handle of a child process
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams: an incorrect handle parameter.
+ * - IllegalParams: called on a closed handle.
+ *
+ * Return information about the handle in the following
+ * format:
+ *
+ *     {
+ *         pid = <number> or <nil>,
+ *         command = <string>,
+ *         opts = <table>,
+ *         status = <table>,
+ *         stdin = one-of(
+ *             popen.stream.OPEN   (== 'open'),
+ *             popen.stream.CLOSED (== 'closed'),
+ *             nil,
+ *         ),
+ *         stdout = one-of(
+ *             popen.stream.OPEN   (== 'open'),
+ *             popen.stream.CLOSED (== 'closed'),
+ *             nil,
+ *         ),
+ *         stderr = one-of(
+ *             popen.stream.OPEN   (== 'open'),
+ *             popen.stream.CLOSED (== 'closed'),
+ *             nil,
+ *         ),
+ *     }
+ *
+ * `pid` is a process id of the process when it is alive,
+ * otherwise `pid` is nil.
+ *
+ * `command` is a concatenation of space separated arguments
+ * that were passed to execve(). Multiword arguments are quoted.
+ * Quotes inside arguments are not escaped.
+ *
+ * `opts` is a table of handle options in the format of
+ * popen.new() `opts` parameter. `opts.env` is not shown here,
+ * because the environment variables map is not stored in a
+ * handle.
+ *
+ * `status` is a table that represents a process status in the
+ * following format:
+ *
+ *     {
+ *         state = one-of(
+ *             popen.state.ALIVE    (== 'alive'),
+ *             popen.state.EXITED   (== 'exited'),
+ *             popen.state.SIGNALED (== 'signaled'),
+ *         )
+ *
+ *         -- Present when `state` is 'exited'.
+ *         exit_code = <number>,
+ *
+ *         -- Present when `state` is 'signaled'.
+ *         signo = <number>,
+ *         signame = <string>,
+ *     }
+ *
+ * `stdin`, `stdout`, `stderr` reflect status of parent's end
+ * of a piped stream. When a stream is not piped the field is
+ * not present (`nil`). When it is piped, the status may be
+ * one of the following:
+ *
+ * - popen.stream.OPEN    (== 'open')
+ * - popen.stream.CLOSED  (== 'closed')
+ *
+ * The status may be changed from 'open' to 'closed'
+ * by :shutdown({std... = true}) call.
+ *
+ * @see luaT_push_popen_opts()
+ * @see luaT_push_popen_process_status()
+ *
+ * Example 1 (tarantool console):
+ *
+ *  | tarantool> require('popen').new({'/usr/bin/touch', '/tmp/foo'})
+ *  | ---
+ *  | - command: /usr/bin/touch /tmp/foo
+ *  |   status:
+ *  |     state: alive
+ *  |   opts:
+ *  |     stdout: inherit
+ *  |     stdin: inherit
+ *  |     group_signal: false
+ *  |     keep_child: false
+ *  |     close_fds: true
+ *  |     restore_signals: true
+ *  |     shell: false
+ *  |     setsid: false
+ *  |     stderr: inherit
+ *  |   pid: 9499
+ *  | ...
+ *
+ * Example 2 (tarantool console):
+ *
+ *  | tarantool> require('popen').shell('grep foo', 'wrR')
+ *  | ---
+ *  | - stdout: open
+ *  |   command: sh -c 'grep foo'
+ *  |   stderr: open
+ *  |   status:
+ *  |     state: alive
+ *  |   stdin: open
+ *  |   opts:
+ *  |     stdout: pipe
+ *  |     stdin: pipe
+ *  |     group_signal: true
+ *  |     keep_child: false
+ *  |     close_fds: true
+ *  |     restore_signals: true
+ *  |     shell: true
+ *  |     setsid: true
+ *  |     stderr: pipe
+ *  |   pid: 10497
+ *  | ...
+ */
+static int
+lbox_popen_info(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: ph:info()");
+		return luaT_error(L);
+	}
+	if (is_closed)
+		return luaT_popen_handle_closed_error(L);
+
+	struct popen_stat st = {};
+
+	popen_stat(handle, &st);
+
+	lua_createtable(L, 0, 7);
+
+	if (st.pid >= 0) {
+		lua_pushinteger(L, st.pid);
+		lua_setfield(L, -2, "pid");
+	}
+
+	lua_pushstring(L, popen_command(handle));
+	lua_setfield(L, -2, "command");
+
+	luaT_push_popen_opts(L, st.flags);
+	lua_setfield(L, -2, "opts");
+
+	int state;
+	int exit_code;
+	popen_state(handle, &state, &exit_code);
+	assert(state < POPEN_STATE_MAX);
+	luaT_push_popen_process_status(L, state, exit_code);
+	lua_setfield(L, -2, "status");
+
+	luaT_push_popen_stdX_status(L, handle, STDIN_FILENO);
+	lua_setfield(L, -2, "stdin");
+
+	luaT_push_popen_stdX_status(L, handle, STDOUT_FILENO);
+	lua_setfield(L, -2, "stdout");
+
+	luaT_push_popen_stdX_status(L, handle, STDERR_FILENO);
+	lua_setfield(L, -2, "stderr");
+
+	return 1;
+}
+
+/**
+ * Close a popen handle.
+ *
+ * @param handle  a handle to close
+ *
+ * Basically it kills a process using SIGKILL and releases all
+ * resources assosiated with the popen handle.
+ *
+ * Details about signaling:
+ *
+ * - The signal is sent only when opts.keep_child is not set.
+ * - The signal is sent only when a process is alive according
+ *   to the information available on current even loop iteration.
+ *   (There is a gap here: a zombie may be signaled; it is
+ *   harmless.)
+ * - The signal is sent to a process or a grocess group depending
+ *   of opts.group_signal. (@see lbox_popen_new() for details of
+ *   group signaling).
+ *
+ * Resources are released disregarding of whether a signal
+ * sending succeeds: fds are closed, memory is released,
+ * the handle is marked as closed.
+ *
+ * No operation is possible on a closed handle except
+ * :close(), which always successful on closed handle
+ * (idempotence).
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams: an incorrect handle parameter.
+ *
+ * The function may return `true` or `nil, err`, but it always
+ * frees the handle resources. So any return value usually
+ * means success for a caller. The return values are purely
+ * informational: it is for logging or same kind of reporting.
+ *
+ * Possible diagnostics (don't consider them as errors):
+ *
+ * - SystemError: no permission to send a signal to
+ *                a process or a process group
+ *
+ *                This diagnostics may appear due to
+ *                Mac OS behaviour on zombies when
+ *                opts.group_signal is set,
+ *                @see lbox_popen_signal().
+ *
+ *                Whether it may appear due to other
+ *                reasons is unclear.
+ *
+ * Always return `true` when a process is known as dead (say,
+ * after ph:wait()): no signal will be send, so no 'failure'
+ * may appear.
+ */
+static int
+lbox_popen_close(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: ph:close()");
+		return luaT_error(L);
+	}
+
+	/* Do nothing on a closed handle. */
+	if (is_closed) {
+		lua_pushboolean(L, true);
+		return 1;
+	}
+
+	if (popen_delete(handle) != 0)
+		return luaT_push_nil_and_error(L);
+
+	luaT_mark_popen_handle_closed(L, 1);
+
+	lua_pushboolean(L, true);
+	return 1;
+}
+
+/**
+ * Get a field from a handle.
+ *
+ * @param handle  a handle of a child process
+ * @param key     a field name, string
+ *
+ * The function performs the following steps.
+ *
+ * Raise an error on incorrect parameters:
+ *
+ * - IllegalParams: incorrect type or value of a parameter.
+ *
+ * If there is a handle method with @a key name, return it.
+ *
+ * Raise an error on closed popen handle:
+ *
+ * - IllegalParams: called on a closed handle.
+ *
+ * If a @key is one of the following, return a value for it:
+ *
+ * - pid
+ * - command
+ * - opts
+ * - status
+ * - stdin
+ * - stdout
+ * - stderr
+ *
+ * @see lbox_popen_info() for description of those fields.
+ *
+ * Otherwise return `nil`.
+ */
+static int
+lbox_popen_index(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+	const char *key;
+
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL ||
+	    (key = luaL_tolstring_strict(L, 2, NULL)) == NULL) {
+		diag_set(IllegalParams,
+			 "Bad params, use __index(ph, <string>)");
+		return luaT_error(L);
+	}
+
+	/*
+	 * If `key` is a method name, return it.
+	 *
+	 * The __index metamethod performs only checks that
+	 * it needs on its own. Despite there are common parts
+	 * across methods, it is better to validate all
+	 * parameters within a method itself.
+	 *
+	 * In particular, methods should perform a check for
+	 * closed handles.
+	 */
+	lua_getmetatable(L, 1);
+	lua_pushvalue(L, 2);
+	lua_rawget(L, -2);
+	if (! lua_isnil(L, -1))
+		return 1;
+
+	/* Does not allow to get a field from a closed handle. */
+	if (is_closed) {
+		diag_set(IllegalParams,
+			 "Attempt to index a closed popen handle");
+		return luaT_error(L);
+	}
+
+	if (strcmp(key, "pid") == 0) {
+		if (handle->pid >= 0)
+			lua_pushinteger(L, handle->pid);
+		else
+			lua_pushnil(L);
+		return 1;
+	}
+
+	if (strcmp(key, "command") == 0) {
+		lua_pushstring(L, popen_command(handle));
+		return 1;
+	}
+
+	if (strcmp(key, "opts") == 0) {
+		luaT_push_popen_opts(L, handle->flags);
+		return 1;
+	}
+
+	int state;
+	int exit_code;
+	popen_state(handle, &state, &exit_code);
+	assert(state < POPEN_STATE_MAX);
+	if (strcmp(key, "status") == 0)
+		return luaT_push_popen_process_status(L, state, exit_code);
+
+	if (strcmp(key, "stdin") == 0)
+		return luaT_push_popen_stdX_status(L, handle, STDIN_FILENO);
+
+	if (strcmp(key, "stdout") == 0)
+		return luaT_push_popen_stdX_status(L, handle, STDOUT_FILENO);
+
+	if (strcmp(key, "stderr") == 0)
+		return luaT_push_popen_stdX_status(L, handle, STDERR_FILENO);
+
+	lua_pushnil(L);
+	return 1;
+}
+
+/**
+ * Popen handle representation for REPL (console).
+ *
+ * @param handle  a handle of a child process
+ *
+ * The function just calls lbox_popen_info() for a
+ * handle when it is not closed.
+ *
+ * Return '<closed popen handle>' string for a closed
+ * handle.
+ *
+ * @see lbox_popen_info()
+ */
+static int
+lbox_popen_serialize(struct lua_State *L)
+{
+	struct popen_handle *handle;
+	bool is_closed;
+
+	if ((handle = luaT_check_popen_handle(L, 1, &is_closed)) == NULL) {
+		diag_set(IllegalParams, "Bad params, use: __serialize(ph)");
+		return luaT_error(L);
+	}
+
+	if (is_closed) {
+		lua_pushliteral(L, "<closed popen handle>");
+		return 1;
+	}
+
+	return lbox_popen_info(L);
+}
+
+/**
+ * Free popen handle resources.
+ *
+ * @param handle  a handle to free
+ *
+ * The same as lbox_popen_close(), but silently exits on any
+ * failure.
+ *
+ * The method may be called manually from Lua, so it is able to
+ * proceed with an incorrect and a closed handle. It also marks
+ * a handle as closed to don't free resources twice if the
+ * handle is collected by Lua GC after a manual call of the
+ * method.
+ *
+ * Don't return a value.
+ */
+static int
+lbox_popen_gc(struct lua_State *L)
+{
+	bool is_closed;
+	struct popen_handle *handle = luaT_check_popen_handle(L, 1, &is_closed);
+	if (handle == NULL || is_closed)
+		return 0;
+	popen_delete(handle);
+	luaT_mark_popen_handle_closed(L, 1);
+	return 0;
+}
+
+/* }}} */
+
+/* {{{ Module initialization */
+
+/**
+ * Create popen functions and methods.
+ *
+ * Module functions
+ * ----------------
+ *
+ * - popen.new()
+ * - popen.shell()
+ *
+ * Module constants
+ * ----------------
+ *
+ * - popen.opts
+ *   - INHERIT (== 'inherit')
+ *   - DEVNULL (== 'devnull')
+ *   - CLOSE   (== 'close')
+ *   - PIPE    (== 'pipe')
+ *
+ * - popen.signal
+ *   - SIGTERM (== 9)
+ *   - SIGKILL (== 15)
+ *   - ...
+ *
+ * - popen.state
+ *   - ALIVE    (== 'alive')
+ *   - EXITED   (== 'exited')
+ *   - SIGNALED (== 'signaled')
+ *
+ * - popen.stream
+ *   - OPEN    (== 'open')
+ *   - CLOSED  (== 'closed')
+ */
+void
+tarantool_lua_popen_init(struct lua_State *L)
+{
+	/* Popen module methods. */
+	static const struct luaL_Reg popen_methods[] = {
+		{"new",		lbox_popen_new,		},
+		{"shell",	lbox_popen_shell,	},
+		{NULL, NULL},
+	};
+	luaL_register_module(L, "popen", popen_methods);
+
+	/*
+	 * Popen handle methods and metamethods.
+	 *
+	 * Usual and closed popen handle userdata types have
+	 * the same set of methods and metamethods.
+	 */
+	static const struct luaL_Reg popen_handle_methods[] = {
+		{"signal",		lbox_popen_signal,	},
+		{"terminate",		lbox_popen_terminate,	},
+		{"kill",		lbox_popen_kill,	},
+		{"wait",		lbox_popen_wait,	},
+		{"read",		lbox_popen_read,	},
+		{"write",		lbox_popen_write,	},
+		{"shutdown",		lbox_popen_shutdown,	},
+		{"info",		lbox_popen_info,	},
+		{"close",		lbox_popen_close,	},
+		{"__index",		lbox_popen_index	},
+		{"__serialize",		lbox_popen_serialize	},
+		{"__gc",		lbox_popen_gc		},
+		{NULL, NULL},
+	};
+	luaL_register_type(L, popen_handle_uname, popen_handle_methods);
+	luaL_register_type(L, popen_handle_closed_uname, popen_handle_methods);
+
+	/* Signals. */
+	lua_newtable(L);
+	for (int i = 0; popen_lua_signals[i].signame != NULL; ++i) {
+		lua_pushinteger(L, popen_lua_signals[i].signo);
+		lua_setfield(L, -2, popen_lua_signals[i].signame);
+	}
+	lua_setfield(L, -2, "signal");
+
+	/* Stream actions. */
+	lua_newtable(L);
+	for (int i = 0; popen_lua_actions[i].name != NULL; ++i) {
+		lua_pushstring(L, popen_lua_actions[i].value);
+		lua_setfield(L, -2, popen_lua_actions[i].name);
+	}
+	lua_setfield(L, -2, "opts");
+
+	/* Stream status. */
+	lua_newtable(L);
+	for (int i = 0; popen_lua_stream_status[i].name != NULL; ++i) {
+		lua_pushstring(L, popen_lua_stream_status[i].value);
+		lua_setfield(L, -2, popen_lua_stream_status[i].name);
+	}
+	lua_setfield(L, -2, "stream");
+
+	/* Process states. */
+	lua_newtable(L);
+	for (int i = 0; popen_lua_states[i].name != NULL; ++i) {
+		lua_pushstring(L, popen_lua_states[i].value);
+		lua_setfield(L, -2, popen_lua_states[i].name);
+	}
+	lua_setfield(L, -2, "state");
+}
+
+/* }}} */
diff --git a/src/lua/popen.h b/src/lua/popen.h
new file mode 100644
index 0000000000000000000000000000000000000000..e23c5d7e50d8031347ace12d635804b54e4451d2
--- /dev/null
+++ b/src/lua/popen.h
@@ -0,0 +1,44 @@
+#ifndef TARANTOOL_LUA_POPEN_H_INCLUDED
+#define TARANTOOL_LUA_POPEN_H_INCLUDED
+/*
+ * Copyright 2010-2020, Tarantool AUTHORS, please see AUTHORS file.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above
+ *    copyright notice, this list of conditions and the
+ *    following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above
+ *    copyright notice, this list of conditions and the following
+ *    disclaimer in the documentation and/or other materials
+ *    provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+ * <COPYRIGHT HOLDER> OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ * THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+#if defined(__cplusplus)
+extern "C" {
+#endif /* defined(__cplusplus) */
+
+struct lua_State;
+void tarantool_lua_popen_init(struct lua_State *L);
+
+#if defined(__cplusplus)
+} /* extern "C" */
+#endif /* defined(__cplusplus) */
+
+#endif /* TARANTOOL_LUA_POPEN_H_INCLUDED */
diff --git a/test/app-tap/popen.test.lua b/test/app-tap/popen.test.lua
new file mode 100755
index 0000000000000000000000000000000000000000..e1aef9ef99173ed2ac5c7f52385523f0af104e83
--- /dev/null
+++ b/test/app-tap/popen.test.lua
@@ -0,0 +1,605 @@
+#!/usr/bin/env tarantool
+
+local popen = require('popen')
+local ffi = require('ffi')
+local errno = require('errno')
+local fiber = require('fiber')
+local clock = require('clock')
+local tap = require('tap')
+local fun = require('fun')
+
+-- For process_is_alive().
+ffi.cdef([[
+    int
+    kill(pid_t pid, int signo);
+]])
+
+-- {{{ Helpers
+
+--
+-- Verify whether a process is alive.
+--
+local function process_is_alive(pid)
+    local rc = ffi.C.kill(pid, 0)
+    return rc == 0 or errno() ~= errno.ESRCH
+end
+
+--
+-- Verify whether a process is dead or not exist.
+--
+local function process_is_dead(pid)
+    return not process_is_alive(pid)
+end
+
+--
+-- Yield the current fiber until a condition becomes true or
+-- timeout (60 seconds) exceeds.
+--
+-- Don't use test-run's function to allow to run the test w/o
+-- test-run. It is often convenient during debugging.
+--
+local function wait_cond(func, ...)
+    local timeout = 60
+    local delay = 0.1
+
+    local deadline = clock.monotonic() + timeout
+    local res
+
+    while true do
+        res = {func(...)}
+        -- Success or timeout.
+        if res[1] or clock.monotonic() > deadline then break end
+        fiber.sleep(delay)
+    end
+
+    return unpack(res, 1, table.maxn(res))
+end
+
+-- }}}
+
+--
+-- Trivial echo output.
+--
+local function test_trivial_echo_output(test)
+    test:plan(6)
+
+    local script = 'printf "1 2 3 4 5"'
+    local exp_script_output = '1 2 3 4 5'
+
+    -- Start printf, wait it to finish, read the output and close
+    -- the handler.
+    local ph = popen.shell(script, 'r')
+    local pid = ph.pid
+    local exp_status = {
+        state = popen.state.EXITED,
+        exit_code = 0,
+    }
+    local status = ph:wait()
+    test:is_deeply(status, exp_status, 'verify process status')
+    local script_output = ph:read()
+    test:is(script_output, exp_script_output, 'verify script output')
+
+    -- Note: EPERM due to opts.group_signal and a zombie process
+    -- on Mac OS is not possible here, because we waited for the
+    -- process: it is not zombie, but not exists at all.
+    local res, err = ph:close()
+    test:is_deeply({res, err}, {true, nil}, 'close() successful')
+
+    -- Verify that the process is actually killed.
+    local is_dead = wait_cond(process_is_dead, pid)
+    test:ok(is_dead, 'the process is killed after close()')
+
+    -- Verify that :close() is idempotent.
+    local res, err = ph:close()
+    test:is_deeply({res, err}, {true, nil}, 'close() is idempotent')
+
+    -- Sending a signal using a closed handle gives an error.
+    local exp_err = 'popen: attempt to operate on a closed handle'
+    local ok, err = pcall(ph.signal, ph, popen.signal.SIGTERM)
+    test:is_deeply({ok, err.type, tostring(err)},
+                   {false, 'IllegalParams', exp_err},
+                   'signal() on closed handle gives an error')
+end
+
+--
+-- Test info and force killing of a child process.
+--
+local function test_kill_child_process(test)
+    test:plan(10)
+
+    -- Run and kill a process.
+    local script = 'while true; do sleep 10; done'
+    local ph = popen.shell(script, 'r')
+    local res, err = ph:kill()
+    test:is_deeply({res, err}, {true, nil}, 'kill() successful')
+
+    local exp_status = {
+        state = popen.state.SIGNALED,
+        signo = popen.signal.SIGKILL,
+        signame = 'SIGKILL',
+    }
+
+    -- Wait for the process termination, verify wait() return
+    -- values.
+    local status = ph:wait()
+    test:is_deeply(status, exp_status, 'wait() return values')
+
+    -- Verify status() return values for a terminated process.
+    test:is_deeply(ph.status, exp_status, 'status() return values')
+
+    -- Verify info for a terminated process.
+    local info = ph:info()
+    test:is(info.pid, nil, 'info.pid is nil')
+
+    local exp_command = ("sh -c '%s'"):format(script)
+    test:is(info.command, exp_command, 'verify info.script')
+
+    local exp_opts = {
+        stdin = popen.opts.INHERIT,
+        stdout = popen.opts.PIPE,
+        stderr = popen.opts.INHERIT,
+        shell = true,
+        setsid = true,
+        close_fds = true,
+        restore_signals = true,
+        group_signal = true,
+        keep_child = false,
+    }
+    test:is_deeply(info.opts, exp_opts, 'verify info.opts')
+
+    test:is_deeply(info.status, exp_status, 'verify info.status')
+
+    test:is(info.stdin, nil, 'verify info.stdin')
+    test:is(info.stdout, popen.stream.OPEN, 'verify info.stdout')
+    test:is(info.stderr, nil, 'verify info.stderr')
+
+    ph:close()
+end
+
+--
+-- Test that a loss handle does not leak (at least the
+-- corresponding process is killed).
+--
+local function test_gc(test)
+    test:plan(1)
+
+    -- Run a process, verify that it exists.
+    local script = 'while true; do sleep 10; done'
+    local ph = popen.shell(script, 'r')
+    local pid = ph.pid
+    assert(process_is_alive(pid))
+
+    -- Loss the handle.
+    ph = nil -- luacheck: no unused
+    collectgarbage()
+
+    -- Verify that the process is actually killed.
+    local is_dead = wait_cond(process_is_dead, pid)
+    test:ok(is_dead, 'the process is killed when the handle is collected')
+end
+
+--
+-- Simple read() / write() test.
+--
+local function test_read_write(test)
+    test:plan(8)
+
+    local payload = 'hello'
+
+    -- The script copies data from stdin to stdout.
+    local script = 'prompt=""; read -r prompt; printf "$prompt"'
+    local ph = popen.shell(script, 'rw')
+
+    -- Write to stdin, read from stdout.
+    local res, err = ph:write(payload .. '\n')
+    test:is_deeply({res, err}, {true, nil}, 'write() succeeds')
+    local ok = ph:shutdown({stdin = true})
+    test:ok(ok, 'shutdown() succeeds')
+    local res, err = ph:read()
+    test:is_deeply({res, err}, {payload, nil}, 'read() from stdout succeeds')
+
+    ph:close()
+
+    -- The script copies data from stdin to stderr.
+    local script = 'prompt=""; read -r prompt; printf "$prompt" 1>&2'
+    local ph = popen.shell(script, 'Rw')
+
+    -- Write to stdin, read from stderr.
+    local res, err = ph:write(payload .. '\n')
+    test:is_deeply({res, err}, {true, nil}, 'write() succeeds')
+    local ok = ph:shutdown({stdin = true})
+    test:ok(ok, 'shutdown() succeeds')
+    local res, err = ph:read({stderr = true})
+    test:is_deeply({res, err}, {payload, nil}, 'read() from stderr succeeds')
+
+    ph:close()
+
+    -- The same script: copy from stdin to stderr.
+    local script = 'prompt=""; read -r prompt; printf "$prompt" 1>&2'
+    local ph = popen.shell(script, 'Rw')
+
+    -- Ensure that read waits for data and does not return
+    -- prematurely.
+    local res_w, err_w
+    fiber.create(function()
+        fiber.sleep(0.1)
+        res_w, err_w = ph:write(payload .. '\n')
+        ph:shutdown({stdin = true})
+    end)
+    local res, err = ph:read({stderr = true})
+    test:is_deeply({res_w, err_w}, {true, nil}, 'write() succeeds')
+    test:is_deeply({res, err}, {payload, nil}, 'read() from stderr succeeds')
+
+    ph:close()
+end
+
+--
+-- Test timeouts: just wait for 0.1 second to elapse, then write
+-- data and re-read for sure.
+--
+local function test_read_timeout(test)
+    test:plan(3)
+
+    local payload = 'hello'
+    local script = 'prompt=""; read -r prompt; printf "$prompt"'
+    local ph = popen.shell(script, 'rw')
+
+    -- Read and get a timeout error.
+    local exp_err = 'timed out'
+    local res, err = ph:read({timeout = 0.1})
+    test:is_deeply({res, err.type, tostring(err)}, {nil, 'TimedOut', exp_err},
+                   'timeout error')
+
+    -- Write and read after the timeout error.
+    local res, err = ph:write(payload .. '\n')
+    test:is_deeply({res, err}, {true, nil}, 'write data')
+    ph:shutdown({stdin = true})
+    local res, err = ph:read()
+    test:is_deeply({res, err}, {payload, nil}, 'read data')
+
+    ph:close()
+end
+
+--
+-- Ensure that read() returns when some data is available (even if
+-- it is one byte).
+--
+local function test_read_chunk(test)
+    test:plan(1)
+
+    local payload = 'hello'
+    local script = ('printf "%s"; sleep 120'):format(payload)
+    local ph = popen.shell(script, 'r')
+
+    -- When a first byte is available, read() should return all
+    -- bytes arrived at the time.
+    local latch = fiber.channel(1)
+    local res, err
+    fiber.create(function()
+        res, err = ph:read()
+        latch:put(true)
+    end)
+    -- Wait 1 second at max.
+    latch:get(1)
+    test:is_deeply({res, err}, {payload, nil}, 'data available prior to EOF')
+
+    ph:close()
+end
+
+--
+-- Ensure that shutdown() closes asked streams: at least
+-- it is reflected in a handle information.
+--
+local function test_shutdown(test)
+    test:plan(9)
+
+    -- Verify std* status.
+    local function test_stream_status(test, ph, pstream, exp_pstream)
+        test:plan(6)
+        local info = ph:info()
+        for _, s in ipairs({'stdin', 'stdout', 'stderr'}) do
+            local exp_status = s == pstream and exp_pstream or nil
+            test:is(ph[s], exp_status, ('%s open'):format(s))
+            test:is(info[s], exp_status, ('%s open'):format(s))
+        end
+    end
+
+    -- Create, verify pstream status, shutdown it,
+    -- verify status again.
+    for _, pstream in ipairs({'stdin', 'stdout', 'stderr'}) do
+        local ph = popen.new({'/bin/true'}, {[pstream] = popen.opts.PIPE})
+
+        test:test(('%s before shutdown'):format(pstream),
+                  test_stream_status, ph, pstream,
+                  popen.stream.OPEN)
+
+        local ok = ph:shutdown({[pstream] = true})
+        test:ok(ok, ('shutdown({%s = true}) successful'):format(pstream))
+
+        test:test(('%s after shutdown'):format(pstream),
+                  test_stream_status, ph, pstream,
+                  popen.stream.CLOSED)
+
+        -- FIXME: Verify that read / write from pstream gives
+        -- certain error.
+
+        ph:close()
+    end
+end
+
+local function test_shell_invalid_args(test)
+    local function argerr(slot, _)
+        if slot == 1 then
+            return 'popen.shell: wrong parameter'
+        elseif slot == 2 then
+            return 'popen.shell: wrong parameter'
+        else
+            error('Invalid argument check')
+        end
+    end
+
+    -- 1st parameter.
+    local cases1 = {
+        [{nil}]                              = argerr(1, 'no value'),
+        [{true}]                             = argerr(1, 'boolean'),
+        [{false}]                            = argerr(1, 'boolean'),
+        [{0}]                                = argerr(1, 'number'),
+        -- A string is ok.
+        [{''}]                               = nil,
+        [{{}}]                               = argerr(1, 'table'),
+        [{popen.shell}]                      = argerr(1, 'function'),
+        [{io.stdin}]                         = argerr(1, 'userdata'),
+        [{coroutine.create(function() end)}] = argerr(1, 'thread'),
+        [{require('ffi').new('void *')}]     = argerr(1, 'cdata'),
+    }
+
+    -- 2nd parameter.
+    local cases2 = {
+        -- nil is ok ('wrR' is optional).
+        [{nil}]                              = nil,
+        [{true}]                             = argerr(2, 'boolean'),
+        [{false}]                            = argerr(2, 'boolean'),
+        [{0}]                                = argerr(2, 'number'),
+        -- A string is ok.
+        [{''}]                               = nil,
+        [{{}}]                               = argerr(2, 'table'),
+        [{popen.shell}]                      = argerr(2, 'function'),
+        [{io.stdin}]                         = argerr(2, 'userdata'),
+        [{coroutine.create(function() end)}] = argerr(2, 'thread'),
+        [{require('ffi').new('void *')}]     = argerr(2, 'cdata'),
+    }
+
+    test:plan(fun.iter(cases1):length() * 2 + fun.iter(cases2):length() * 2)
+
+    -- Call popen.shell() with
+    for args, err in pairs(cases1) do
+        local arg = unpack(args)
+        local ok, res = pcall(popen.shell, arg)
+        test:ok(not ok, ('command (ok): expected string, got %s')
+                        :format(type(arg)))
+        test:ok(res:match(err), ('command (err): expected string, got %s')
+                                :format(type(arg)))
+    end
+
+    for args, err in pairs(cases2) do
+        local arg = unpack(args)
+        local ok, res = pcall(popen.shell, 'printf test', arg)
+        test:ok(not ok, ('mode (ok): expected string, got %s')
+                        :format(type(arg)))
+        test:ok(res:match(err), ('mode (err): expected string, got %s')
+                                :format(type(arg)))
+    end
+end
+
+local function test_new_invalid_args(test)
+    local function argerr(arg, typename)
+        if arg == 'argv' then
+            return ('popen.new: wrong parameter "%s": expected table, got %s')
+                :format(arg, typename)
+        else
+            error('Invalid argument check')
+        end
+    end
+
+    -- 1st parameter.
+    local cases1 = {
+        [{nil}]                              = argerr('argv', 'nil'),
+        [{true}]                             = argerr('argv', 'boolean'),
+        [{false}]                            = argerr('argv', 'boolean'),
+        [{0}]                                = argerr('argv', 'number'),
+        [{''}]                               = argerr('argv', 'string'),
+        -- FIXME: A table is ok, but not an empty one.
+        [{{}}]                               = nil,
+        [{popen.shell}]                      = argerr('argv', 'function'),
+        [{io.stdin}]                         = argerr('argv', 'userdata'),
+        [{coroutine.create(function() end)}] = argerr('argv', 'thread'),
+        [{require('ffi').new('void *')}]     = argerr('argv', 'cdata'),
+    }
+
+    test:plan(fun.iter(cases1):length() * 2)
+
+    -- Call popen.new() with wrong "argv" parameter.
+    for args, err in pairs(cases1) do
+        local arg = unpack(args)
+        local ok, res = pcall(popen.new, arg)
+        test:ok(not ok, ('new argv (ok): expected table, got %s')
+                        :format(type(arg)))
+        test:ok(res:match(err), ('new argv (err): expected table, got %s')
+                                :format(type(arg)))
+    end
+end
+
+local function test_methods_on_closed_handle(test)
+    local methods = {
+        signal    = {popen.signal.SIGTERM},
+        terminate = {},
+        kill      = {},
+        wait      = {},
+        read      = {},
+        write     = {'hello'},
+        info      = {},
+        -- Close call is idempotent one.
+        close     = nil,
+    }
+
+    test:plan(fun.iter(methods):length() * 2)
+
+    local ph = popen.shell('printf "1 2 3 4 5"', 'r')
+    ph:close()
+
+    -- Call methods on a closed handle.
+    for method, args in pairs(methods) do
+        local ok, err = pcall(ph[method], ph, unpack(args))
+        test:ok(not ok, ('%s (ok) on closed handle'):format(method))
+        test:ok(err:match('popen: attempt to operate on a closed handle'),
+                ('%s (err) on closed handle'):format(method))
+    end
+end
+
+local function test_methods_on_invalid_handle(test)
+    local methods = {
+        signal    = {popen.signal.SIGTERM},
+        terminate = {},
+        kill      = {},
+        wait      = {},
+        read      = {},
+        write     = {'hello'},
+        info      = {},
+        close     = {},
+    }
+
+    test:plan(fun.iter(methods):length() * 4)
+
+    local ph = popen.shell('printf "1 2 3 4 5"', 'r')
+
+    -- Call methods without parameters.
+    for method in pairs(methods) do
+        local ok, err = pcall(ph[method])
+        test:ok(not ok, ('%s (ok) no handle and args'):format(method))
+        test:ok(err:match('Bad params, use: ph:' .. method),
+                ('%s (err) no handle and args'):format(method))
+    end
+
+    ph:close()
+
+    -- A table looks like a totally bad handler.
+    local bh = {}
+
+    -- Call methods on a bad handle.
+    for method, args in pairs(methods) do
+        local ok, err = pcall(ph[method], bh, unpack(args))
+        test:ok(not ok, ('%s (ok) on invalid handle'):format(method))
+        test:ok(err:match('Bad params, use: ph:' .. method),
+                ('%s (err) on invalid handle'):format(method))
+    end
+end
+
+local test = tap.test('popen')
+test:plan(11)
+
+test:test('trivial_echo_output', test_trivial_echo_output)
+test:test('kill_child_process', test_kill_child_process)
+test:test('gc', test_gc)
+test:test('read_write', test_read_write)
+test:test('read_timeout', test_read_timeout)
+test:test('read_chunk', test_read_chunk)
+test:test('test_shutdown', test_shutdown)
+test:test('shell_invalid_args', test_shell_invalid_args)
+test:test('new_invalid_args', test_new_invalid_args)
+test:test('methods_on_closed_handle', test_methods_on_closed_handle)
+test:test('methods_on_invalid_handle', test_methods_on_invalid_handle)
+
+-- Testing plan
+--
+-- FIXME: Implement this plan.
+--
+-- - api usage
+--   - new
+--     - no argv / nil argv
+--     - bad argv
+--       - wrong type
+--       - hole in the table (nil in a middle)
+--       - item
+--         - wrong type
+--       - zero size (w/ / w/o shell)
+--     - bad opts
+--       - wrong type
+--       - {stdin,stdout,stderr}
+--         - wrong type
+--         - wrong string value
+--       - env
+--         - wrong type
+--         - env item
+--           - wrong key type
+--           - wrong value type
+--           - '=' in key
+--           - '\0' in key
+--           - '=' in value
+--           - '\0' in value
+--       - (boolean options)
+--         - wrong type
+--         - conflicting options (!setsid && signal_group)
+--   - shell
+--     - bad handle
+--     - bad mode
+--   - signal
+--     - signal
+--       - wrong type
+--       - unknown value
+--   - read
+--     - FIXME: more cases
+--   - write
+--     - FIXME: more cases
+--   - __index
+--     - zero args (no even handle)
+--     - bad handle
+--     - FIXME: more cases
+--   - __serialize
+--     - zero args (no even handle)
+--     - bad handle
+--   - __gc
+--     - zero args (no even handle)
+--     - bad handle
+--
+-- - verify behaviour
+--   - popen.new: effect of boolean options
+--   - info: verify all four opts.std* actions
+--   - info: get both true and false for each opts.<...> boolean
+--     option
+--   - FiberIsCancelled is raised from read(), write() and wait()
+--
+-- - verify dubious code paths
+--   - popen.new
+--     - env: pass os.environ() with one replaced value
+--     - env: reallocation of env array
+--     - argv construction with and without shell option
+--     - std* actions actual behaviour
+--     - boolean opts: verify true, false and default values has expected
+--       effect (eps.: explicit false when it is default is not considered as
+--       true); at least look at then in :info()
+--   - read / write
+--     - write that needs several write() calls
+--     - feed large input over a process: write it, read in
+--       several chunks; process should terminate on EOF
+--     - child process die during read / write
+--     - boolean opts: verify true, false and default values has expected
+--       effect (eps.: explicit false when it is default is not considered as
+--       true)
+--     - FIXME: more cases
+--   - no extra fds are seen when close_fds is set
+--     verify with different std* options
+--
+-- - protect against inquisitive persons
+--   - ph.__gc(1)
+--   - ph.__gc(ph); ph = nil; collectgarbage()
+--
+-- - unsorted
+--   - test read / write after shutdown
+--   - shutdown
+--     - should be idempotent
+--     - fails w/o opts or w/ empty opts; i.e. at least one stream should be
+--       chosen
+--     - does not close any fds on a failure
+--     - fails when one piped and one non-piped fd are chosen
+
+os.exit(test:check() and 0 or 1)