[Tarantool-patches] [PATCH v3 4/5] popen/fio: Add ability to run external programs
Cyrill Gorcunov
gorcunov at gmail.com
Wed Dec 11 12:28:32 MSK 2019
In the patch we add support for running external programs via
fio module and two helpers fio.popen and fio.popen_posix.
@TarantoolBot document
Title: Document fio.popen() and fio.popen_posix()
`fio.popen` and `fio.popen_posix` functions allow to spawn a new process,
connect to its stdin, stdout and stderr pipes to communicate and either
wait for the process to exit or terminate it.
`fio.popen_posix(command, mode)` creates a process and invokes the shell
inside, where:
- `command` is the command to execute inside the shell;
- `mode` is a string which must contain either letter `"r"` for reading
output from stdout or letter `"w"` to write into stdin of a child
process. To read and write data use `"rw"`.
`fio.popen_posix` always close all files except std streams, resets signal
handlers to default and setup a new session for a child process.
Example 1
```
popen, err = require('fio').popen_posix("date", "r")
```
Runs the "date" command as `sh -c date` to read the output, closing all file
descriptors except stdout and stderr.
`fio.popen(opts)` opens a process and executes a program inside depending
on the `opts` options table, which should be in form of
`{argv = {...}, envp= {...}, flags = {...}}`, where:
- `argv` is an array of arguments to execute,
the first element of which is used as name
of a program to run and the rest are considered
as arguments. This is the only mandatory
element in `opts` table;
- `envp` is an array of environment variables to
be used instead of inheriting ones from a parent
process. `envp` can be omitted completely then
parent's environment is used, or can be set to
empty array and then the child process won't get
any;
- `flags` is a subtable which describes additional
parameters for the child process:
`stdin = true | false | "devnull"`
`stdout = true | false | "devnull"`
`stdout = true | false | "devnull"`
These keys specify which std descriptors
to open (`true`), close (`false`) or set
to /dev/null device (`"devnull"`).
By default `stdin = false`, `stdout = false`,
`stderr = false` so all streams are closed
in a child process.
`shell = true | false`
This key specify whether a child process should
be executed via `sh -c` (true) or take the binary
name from `argv` array (false).
By default `shell = true`.
`close_fds = true | false`
When set to `true` a child process will not inherit
any files from a parent process (the parent process
is the tarantool itself).
By default `close_fds = true`.
`restore_signals = true | false`
When set to `true` the signal handlers are restored
to defaut values from OS point of view. Since signals
are shared with the process we're spawning there must
be a very strong reason to set this key to `false` value.
By default `restore_signals = true`.
`start_new_session = true | false`
Specifies whether a child process will be a session leader.
By default `start_new_session = true`.
Example 2
```
popen = require('fio').popen({argv = {"date"}, flags = {stdout=true}})
```
It runs `date` command inside shell as in example 1.
Example 3
```
popen = require('fio').popen({argv = {"/usr/bin/echo", "-n", "hello"},
flags = {stdout=true, shell=false}})
```
It runs `/usr/bin/echo` executable directly without opening
shell and keeps stdout to read output later.
Once popen object is created the following methods are provided:
`popen:read(buf,size,opts)`
To read from stdout stream. `buf` and `size` are optional
and when not specified popen will allocate an internal
buffer with size of 512 bytes to keep data. The default
size is managed by `popen.read_buf_size` variable.
`opts` is optional as well but if provided the following
keys are accepted:
`timeout = microseconds`
Specifies timeout in microseconds to interrupt the read
procedure if there is no data provided by a writer. If
set to `-1` then read waits until data appear or peer
end closed (or child process exited).
`stdout = true`
`stderr = true`
Sets which std descriptor of a child process to read.
Only one of them should be provided at a time.
`popen:read2(opts)`
An extended version of `popen:read` which allows to specify
the stream to read. `opts` is a table of options which sould
have the following keys:
`buf`
The destination buffer. Should be an instance of `buffer`
module.
`size`
Number of bytes to read.
`flags = { stdout = true }`
`flags = { stderr = true }`
Specifies which stream to read.
`timeout = microseconds`
Read timeout. Optional, set to `-1` by default.
`popen:write(str)`
`popen:write(buf,size)`
To write data into stdin stream of a child process. Either `str` string
to write should be provided or `buf` buffer and its `size`.
`popen:write2(opts)`
An extended version of `popen:write`. Similar to `popen:read2` it
operates with `opts` options table with keys:
`buf`
The source buffer.
`size`
Source buffer size in bytes.
`flags = { stdout = true }`
Specifies stream to write.
`popen:info()`
Returns information about a child process in form of table with
the following keys:
`pid`
PID of a child process. Set to `-1` if child is exited or
terminated.
`flags`
Internal representation of flags which been used to create
the popen instance.
`stdout = N`
`stderr = N`
`stdin = N`
File descriptors numbers for appropriate streams.
Closed state is represented by value `-1`.
`state = alive`
`state = exited`
`state = signaled`
A symbolic name for a child process state. Until process
terminated we consider it as `alive`. `exited` is set when
process finished execution and exit by self. `signaled` is
set when a child process has been terminated by a signal.
`exit_code`
Return status of the child process. When `state = signaled`
the `exit_code` set to signal number caused process to exit.
`popen:kill()`
Force termination of a child process. This is not immediate action,
and might require some time before the child process get exited.
To verify if the child is exited use `popen:wait` method.
`popen:terminate()`
Soft termination of a child process. Similar to `popen:kill()`
but on soft termination the child process is allowed to do internal
cleanup. Still soft termination may be completely ignored by the
child.
`popen:wait()`
Waits for child process to exit. This function must be called
only after `popen:kill()` or `popen:terminate()`. It spins in
an idle cycle fetching the child state in a polling manner.
Polling time is `1` second by default and controlled by
`popen.wait_secs` variable.
`popen:close()`
Closes popen instance and release all associated resources.
If there is a running child process it will be killed.
Part-of #4031
Signed-off-by: Cyrill Gorcunov <gorcunov at gmail.com>
---
src/lua/fio.lua | 576 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 576 insertions(+)
diff --git a/src/lua/fio.lua b/src/lua/fio.lua
index cb224f3d0..c7be5cd02 100644
--- a/src/lua/fio.lua
+++ b/src/lua/fio.lua
@@ -5,6 +5,7 @@ local ffi = require('ffi')
local buffer = require('buffer')
local fiber = require('fiber')
local errno = require('errno')
+local bit = require('bit')
ffi.cdef[[
int umask(int mask);
@@ -519,4 +520,579 @@ fio.path.lexists = function(filename)
return fio.lstat(filename) ~= nil
end
+local popen_methods = { }
+
+--
+-- Make sure the bits are matching POPEN_FLAG_x
+-- flags in popen header. This is api but internal
+-- one, we should not make it visible to users.
+local popen_flag_bits = {
+ STDIN = 0,
+ STDOUT = 1,
+ STDERR = 2,
+
+ STDIN_DEVNULL = 3,
+ STDOUT_DEVNULL = 4,
+ STDERR_DEVNULL = 5,
+
+ STDIN_CLOSE = 6,
+ STDOUT_CLOSE = 7,
+ STDERR_CLOSE = 8,
+
+ --
+ -- bits 9,10,11 are reserved
+ -- for future use
+
+
+
+ SHELL = 12,
+ SETSID = 13,
+ CLOSE_FDS = 14,
+ RESTORE_SIGNALS = 15,
+}
+
+local popen_flags = {
+ NONE = 0,
+ STDIN = bit.lshift(1, popen_flag_bits.STDIN),
+ STDOUT = bit.lshift(1, popen_flag_bits.STDOUT),
+ STDERR = bit.lshift(1, popen_flag_bits.STDERR),
+
+ STDIN_DEVNULL = bit.lshift(1, popen_flag_bits.STDIN_DEVNULL),
+ STDOUT_DEVNULL = bit.lshift(1, popen_flag_bits.STDOUT_DEVNULL),
+ STDERR_DEVNULL = bit.lshift(1, popen_flag_bits.STDERR_DEVNULL),
+
+ STDIN_CLOSE = bit.lshift(1, popen_flag_bits.STDIN_CLOSE),
+ STDOUT_CLOSE = bit.lshift(1, popen_flag_bits.STDOUT_CLOSE),
+ STDERR_CLOSE = bit.lshift(1, popen_flag_bits.STDERR_CLOSE),
+
+ SHELL = bit.lshift(1, popen_flag_bits.SHELL),
+ SETSID = bit.lshift(1, popen_flag_bits.SETSID),
+ CLOSE_FDS = bit.lshift(1, popen_flag_bits.CLOSE_FDS),
+ RESTORE_SIGNALS = bit.lshift(1, popen_flag_bits.RESTORE_SIGNALS),
+}
+
+--
+-- Get default flags for popen
+local function popen_default_flags()
+ local flags = popen_flags.NONE
+
+ -- default flags: close everything and use shell
+ flags = bit.bor(flags, popen_flags.STDIN_CLOSE)
+ flags = bit.bor(flags, popen_flags.STDOUT_CLOSE)
+ flags = bit.bor(flags, popen_flags.STDERR_CLOSE)
+ flags = bit.bor(flags, popen_flags.SHELL)
+ flags = bit.bor(flags, popen_flags.SETSID)
+ flags = bit.bor(flags, popen_flags.CLOSE_FDS)
+ flags = bit.bor(flags, popen_flags.RESTORE_SIGNALS)
+
+ return flags
+end
+
+--
+-- Close a popen object and release all resources.
+-- In case if there is a running child process
+-- it will be killed.
+--
+-- Returns @ret = true if popen handle closed, and
+-- @ret = false, @err ~= nil on error.
+popen_methods.close = function(self)
+ local ret, err = internal.popen_delete(self.popen_handle)
+ if err ~= nil then
+ return false, err
+ end
+ self.popen_handle = nil
+ return true
+end
+
+--
+-- Kill a child process
+--
+-- Returns @ret = true on success,
+-- @ret = false, @err ~= nil on error.
+popen_methods.kill = function(self)
+ return internal.popen_kill(self.popen_handle)
+end
+
+--
+-- Terminate a child process
+--
+-- Returns @ret = true on success,
+-- @ret = false, @err ~= nil on error.
+popen_methods.terminate = function(self)
+ return internal.popen_term(self.popen_handle)
+end
+
+--
+-- Fetch a child process state
+--
+-- Returns @err = nil, @state = (1 - if exited,
+-- 2 - if killed by a signal) and @exit_code ~= nil,
+-- otherwise @err ~= nil.
+--
+-- FIXME: Should the @state be a named constant?
+popen_methods.state = function(self)
+ return internal.popen_state(self.popen_handle)
+end
+
+--
+-- Wait until a child process get exited.
+--
+-- Returns the same as popen_methods.status.
+popen_methods.wait = function(self)
+ local err, state, code
+ while true do
+ err, state, code = internal.popen_state(self.popen_handle)
+ if err or state then
+ break
+ end
+ fiber.sleep(self.wait_secs)
+ end
+ return err, state, code
+end
+
+--
+-- A map for popen option keys into tfn ('true|false|nil') values
+-- where bits are just set without additional manipulations.
+--
+-- For example stdin=true means to open stdin end for write,
+-- stdin=false to close the end, finally stdin[=nil] means to
+-- provide /dev/null into a child peer.
+local popen_opts_tfn = {
+ stdin = {
+ popen_flags.STDIN,
+ popen_flags.STDIN_CLOSE,
+ popen_flags.STDIN_DEVNULL,
+ },
+ stdout = {
+ popen_flags.STDOUT,
+ popen_flags.STDOUT_CLOSE,
+ popen_flags.STDOUT_DEVNULL,
+ },
+ stderr = {
+ popen_flags.STDERR,
+ popen_flags.STDERR_CLOSE,
+ popen_flags.STDERR_DEVNULL,
+ },
+}
+
+--
+-- A map for popen option keys into tf ('true|false') values
+-- where bits are set on 'true' and clear on 'false'.
+local popen_opts_tf = {
+ shell = {
+ popen_flags.SHELL,
+ },
+ close_fds = {
+ popen_flags.CLOSE_FDS
+ },
+ restore_signals = {
+ popen_flags.RESTORE_SIGNALS
+ },
+ start_new_session = {
+ popen_flags.SETSID
+ },
+}
+
+--
+-- Parses flags options from popen_opts_tfn and
+-- popen_opts_tf tables.
+local function parse_popen_flags(epfx, flags, opts)
+ if opts == nil then
+ return flags
+ end
+ for k,v in pairs(opts) do
+ if popen_opts_tfn[k] == nil then
+ if popen_opts_tf[k] == nil then
+ error(string.format("%s: Unknown key %s", epfx, k))
+ end
+ if v == true then
+ flags = bit.bor(flags, popen_opts_tf[k][1])
+ elseif v == false then
+ flags = bit.band(flags, bit.bnot(popen_opts_tf[k][1]))
+ else
+ error(string.format("%s: Unknown value %s", epfx, v))
+ end
+ else
+ if v == true then
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][2]))
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][3]))
+ flags = bit.bor(flags, popen_opts_tfn[k][1])
+ elseif v == false then
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][1]))
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][3]))
+ flags = bit.bor(flags, popen_opts_tfn[k][2])
+ elseif v == "devnull" then
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][1]))
+ flags = bit.band(flags, bit.bnot(popen_opts_tfn[k][2]))
+ flags = bit.bor(flags, popen_opts_tfn[k][3])
+ else
+ error(string.format("%s: Unknown value %s", epfx, v))
+ end
+ end
+ end
+ return flags
+end
+
+--
+-- popen:read2 - read stream of a child with options
+-- @opts: options table
+--
+-- The options should have the following keys
+--
+-- @flags: stdout=true or stderr=true
+-- @buf: const_char_ptr_t buffer
+-- @size: size of the buffer
+-- @timeout: read timeout in microsecond
+--
+-- Returns @res = bytes, err = nil in case if read processed
+-- without errors, @res = nil, @err = nil if timeout elapsed
+-- and no data appeared on the peer, @res = nil, @err ~= nil
+-- otherwise.
+popen_methods.read2 = function(self, opts)
+ --
+ -- We can reuse parse_popen_flags since only
+ -- a few flags we're interesed in -- stdout/stderr
+ local flags = parse_popen_flags("fio.popen:read2",
+ popen_flags.NONE,
+ opts['flags'])
+ local timeout = -1
+ if opts['buf'] == nil then
+ error("fio.popen:read2 {'buf'} key is missed")
+ elseif opts['size'] == nil then
+ error("fio.popen:read2 {'size'} key is missed")
+ elseif opts['timeout'] ~= nil then
+ timeout = tonumber(opts['timeout'])
+ end
+
+ return internal.popen_read(self.popen_handle, opts['buf'],
+ tonumber(opts['size']),
+ timeout, flags)
+end
+
+-- popen:write2 - write data to a child with options
+-- @opts: options table
+--
+-- The options should have the following keys
+--
+-- @flags: stdin=true
+-- @buf: const_char_ptr_t buffer
+-- @size: size of the buffer
+--
+-- Returns @res = bytes, err = nil in case if write processed
+-- without errors, or @res = nil, @err ~= nil otherwise.
+popen_methods.write2 = function(self, opts)
+ local flags = parse_popen_flags("fio.popen:write2",
+ popen_flags.NONE,
+ opts['flags'])
+ if opts['buf'] == nil then
+ error("fio.popen:write2 {'buf'} key is missed")
+ elseif opts['size'] == nil then
+ error("fio.popen:write2 {'size'} key is missed")
+ end
+ return internal.popen_write(self.popen_handle, opts['buf'],
+ tonumber(opts['size']), flags)
+end
+
+--
+-- Write data to stdin of a child process
+-- @buf: a buffer to write
+-- @size: size of the buffer
+--
+-- Both parameters are optional and some
+-- string could be passed instead
+--
+-- write(str)
+-- write(buf, len)
+--
+-- Returns @res = bytes, @err = nil in case of success,
+-- @res = , @err ~= nil in case of error.
+popen_methods.write = function(self, buf, size)
+ if not ffi.istype(const_char_ptr_t, buf) then
+ buf = tostring(buf)
+ size = #buf
+ end
+ return self:write2({ buf = buf, size = size,
+ flags = { stdin = true }})
+end
+
+--
+-- popen:read - read the output stream in blocked mode
+-- @buf: a buffer to read to, optional
+-- @size: size of the buffer, optional
+-- @opts: options table, optional
+--
+-- The options may have the following keys:
+--
+-- @flags: stdout=true or stderr=true,
+-- to specify which stream to read;
+-- by default stdout is used
+--
+-- @timeout: read timeout in microcesonds, when
+-- specified the read will be interrupted
+-- after specified time elapced, to make
+-- nonblocking read; by default the read
+-- is blocking operation
+--
+-- The following variants are accepted as well
+--
+-- read() -> str
+-- read(buf) -> len
+-- read(size) -> str
+-- read(buf, size) -> len
+--
+-- FIXME: Should we merged it with plain fio.write()?
+popen_methods.read = function(self, buf, size, opts)
+ local tmpbuf
+
+ -- read() or read(buf)
+ if (not ffi.istype(const_char_ptr_t, buf) and buf == nil) or
+ (ffi.istype(const_char_ptr_t, buf) and size == nil) then
+ -- read @self.read_buf_size bytes at once by default
+ size = self.read_buf_size
+ end
+
+ -- read(size)
+ if not ffi.istype(const_char_ptr_t, buf) then
+ size = buf or size
+ tmpbuf = buffer.ibuf()
+ buf = tmpbuf:reserve(size)
+ end
+
+ if opts == nil then
+ opts = {
+ timeout = -1,
+ flags = { stdout = true },
+ }
+ end
+
+ local res, err = self:read2({ buf = buf, size = size,
+ timeout = opts['timeout'],
+ flags = opts['flags'] })
+
+ if res == nil then
+ if tmpbuf ~= nil then
+ tmpbuf:recycle()
+ end
+ return nil, err
+ end
+
+ if tmpbuf ~= nil then
+ tmpbuf:alloc(res)
+ res = ffi.string(tmpbuf.rpos, tmpbuf:size())
+ tmpbuf:recycle()
+ end
+
+ return res
+end
+
+--
+-- popen:info -- obtain information about a popen object
+--
+-- Returns @info table, err == nil on success,
+-- @info = nil, @err ~= nil otherwise.
+--
+-- The @info table contains the following keys:
+--
+-- @pid: pid of a child process
+-- @flags: flags associated (popen_flags bitmap)
+-- @stdout: parent peer for stdout, -1 if closed
+-- @stderr: parent peer for stderr, -1 if closed
+-- @stdin: parent peer for stdin, -1 if closed
+-- @state: alive | exited | signaled | unknown
+-- @exit_code: exit code of a child process if been
+-- exited or killed by a signal
+--
+-- The child should never be in "unknown" state, reserved
+-- for unexpected errors.
+--
+popen_methods.info = function(self)
+ return internal.popen_info(self.popen_handle)
+end
+
+local popen_mt = { __index = popen_methods }
+
+--
+-- Create a new popen object from options
+local function popen_new(opts)
+ local handle, err = internal.popen_new(opts)
+ if err ~= nil then
+ return nil, err
+ end
+
+ local popen_handle = {
+ -- a handle itself for future use
+ popen_handle = handle,
+
+ -- sleeping period for the @wait method
+ wait_secs = 1,
+
+ -- size of a read buffer to allocate
+ -- in case of implicit read
+ read_buf_size = 512,
+ }
+
+ setmetatable(popen_handle, popen_mt)
+ return popen_handle
+end
+
+--
+-- Parse "mode" string to flags
+local function parse_popen_mode(epfx, flags, mode)
+ if mode == nil then
+ return flags
+ end
+ for i = 1, #mode do
+ local c = mode:sub(i, i)
+ if c == 'r' then
+ flags = bit.band(flags, bit.bnot(popen_flags.STDOUT_CLOSE))
+ flags = bit.bor(flags, popen_flags.STDOUT)
+ flags = bit.band(flags, bit.bnot(popen_flags.STDERR_CLOSE))
+ flags = bit.bor(flags, popen_flags.STDERR)
+ elseif c == 'w' then
+ flags = bit.band(flags, bit.bnot(popen_flags.STDIN_CLOSE))
+ flags = bit.bor(flags, popen_flags.STDIN)
+ else
+ error(string.format("%s: Unknown mode %s", epfx, c))
+ end
+ end
+ return flags
+end
+
+--
+-- fio.popen_posix - create a new child process and execute a command inside
+-- @command: a command to run
+-- @mode: r - to grab child's stdout for read [*]
+-- (stderr kept opened as well for 2>&1 redirection)
+-- w - to obtain stdin for write
+--
+-- [*] are default values
+--
+-- Note: Since there are two options only the following parameters
+-- are implied (see popen_default_flags): all fds except stdin/out/err
+-- are closed, signals are restored to default handlers, the command
+-- is executed in a new session.
+--
+-- Examples:
+--
+-- popen = require('fio').popen_posix("date", "r")
+-- runs date as "sh -c date" to read the output,
+-- closing all file descriptors except stdout/err
+-- inside a child process
+--
+fio.popen_posix = function(command, mode)
+ local flags = popen_default_flags()
+
+ if type(command) ~= 'string' then
+ error("Usage: fio.popen(command[, rw])")
+ end
+
+ -- Mode gives simplified flags
+ flags = parse_popen_mode("fio.popen", flags, mode)
+
+ local opts = {
+ argv = { command },
+ argc = 1,
+ flags = flags,
+ envc = -1,
+ }
+
+ return popen_new(opts)
+end
+
+-- fio.popen - execute a child program in a new process
+-- @opt: table of options
+--
+-- @opts carries of the following options:
+--
+-- @argv: an array of a program to run with
+-- command line options, mandatory
+--
+-- @env: an array of environment variables to
+-- be used inside a process, if not
+-- set then the current environment is
+-- inherited, if set to an empty array
+-- then the environment will be dropped
+--
+-- @flags: a dictionary to describe communication pipes
+-- and other parameters of a process to run
+--
+-- stdin=true to write into STDIN_FILENO of a child process
+-- stdin=false to close STDIN_FILENO inside a child process [*]
+-- stdin="devnull" a child will get /dev/null as STDIN_FILENO
+--
+-- stdout=true to write into STDOUT_FILENO of a child process
+-- stdout=false to close STDOUT_FILENO inside a child process [*]
+-- stdin="devnull" a child will get /dev/null as STDOUT_FILENO
+--
+-- stderr=true to read STDERR_FILENO from a child process
+-- stderr=false to close STDERR_FILENO inside a child process [*]
+-- stdin="devnull" a child will get /dev/null as STDERR_FILENO
+--
+-- shell=true runs a child process via "sh -c" [*]
+-- shell=false invokes a child process executable directly
+--
+-- close_fds=true close all inherited fds from a parent [*]
+--
+-- restore_signals=true
+-- all signals modified by a caller reset
+-- to default handler [*]
+--
+-- start_new_session=true
+-- start executable inside a new session [*]
+--
+-- [*] are default values
+--
+-- Examples:
+--
+-- popen = require('fio').popen({argv = {"date"}, flags = {stdout=true}})
+-- popen:read()
+-- popen:close()
+--
+-- Execute 'date' command inside a shell, read the result
+-- and close the popen object
+--
+-- popen = require('fio').popen({argv = {"/usr/bin/echo", "-n", "hello"},
+-- flags = {stdout=true, shell=false}})
+-- popen:read()
+-- popen:close()
+--
+-- Execute /usr/bin/echo with arguments '-n','hello' directly
+-- without using a shell, read the result from stdout and close
+-- the popen object
+--
+fio.popen = function(opts)
+ local flags = popen_default_flags()
+
+ if opts == nil or type(opts) ~= 'table' then
+ error("Usage: fio.run({argv={},envp={},flags={}")
+ end
+
+ -- Test for required arguments
+ if opts["argv"] == nil then
+ error("fio.run: argv key is missing")
+ end
+
+ -- Process flags and save updated mask
+ -- to pass into engine (in case of missing
+ -- flags we just use defaults).
+ if opts["flags"] ~= nil then
+ flags = parse_popen_flags("fio.run", flags, opts["flags"])
+ end
+ opts["flags"] = flags
+
+ -- We will need a number of args for speed sake
+ opts["argc"] = #opts["argv"]
+
+ -- Same applies to the environment (note though
+ -- that env={} is pretty valid and env=nil
+ -- as well.
+ if opts["env"] ~= nil then
+ opts["envc"] = #opts["env"]
+ else
+ opts["envc"] = -1
+ end
+
+ return popen_new(opts)
+end
+
return fio
--
2.20.1
More information about the Tarantool-patches
mailing list