OsCmd (ci v0.1.1) View Source
Managed execution of external commands.
This module provides similar functionality to System.cmd/3
, with the difference that the
execution of commands is managed, which provides the following benefits:
- The external OS process is logically linked to the parent BEAM process (the process which started it). If the parent process terminates, the OS process will be taken down.
- The external OS process will also be taken down if the entire BEAM instance goes down.
- Support for timeout-based and manual termination of the OS process.
- Polite-first termination of the OS process (SIGTERM followed by SIGKILL) in the spirit of OTP termination (shutdown strategies).
In this regard, OsCmd
is similar to erlexec and
porcelain, though it doesn't have all the features of those
projects.
For usage details, see start_link/1
.
Link to this section Summary
Functions
Returns a specification for running the command as a Job
action.
Issues an explicit mock allowance to another process.
Awaits for the started command to finish.
Returns a specification to start this module under a supervisor.
Returns a lazy stream of events.
Sets up a mock expectation.
Synchronously runs the command.
Starts the command owner process.
Stops the command and the owner process.
Sets up a mock stub.
Link to this section Types
Specs
acc() :: any()
Specs
event() :: :starting | {:output, output()} | {:stopped, exit_status()}
Specs
exit_status() :: non_neg_integer() | (exit_reason :: :timeout | any())
Specs
Specs
mock() :: String.t() | (command :: String.t(), [start_opt()] -> {:ok, output()} | {:error, OsCmd.Error.t()})
Specs
output() :: String.t()
Specs
start_opt() :: {:name, GenServer.name()} | {:handler, handler()} | {:timeout, pos_integer() | :infinity} | {:cd, String.t()} | {:env, [{String.t() | atom(), String.t() | nil}]} | {:pty, boolean()} | {:propagate_exit?, boolean()} | {:terminate_cmd, String.t()}
Link to this section Functions
Specs
action(String.t(), [start_opt() | Job.action_opt()]) :: Job.action()
Returns a specification for running the command as a Job
action.
The corresponding action will return {:ok, output} | {:error, %OsCmdError{}}
See Job.start_action/2
for details.
Specs
allow(GenServer.server()) :: :ok
Issues an explicit mock allowance to another process.
Note that mocks are automatically inherited by descendants, so you only need to use this for non
descendant processes. See Mox.allow/3
for details.
Specs
await(GenServer.server()) :: {:ok, output :: String.t()} | {:error, OsCmd.Error.t()}
Awaits for the started command to finish.
This function is internally used by run/2
. If you want to use it yourself, you need to pass the
handler &send(some_pid, {self(), &1})
when starting the command. This function can only be
invoked in the process which receives the event messages.
Returns a specification to start this module under a supervisor.
See Supervisor
.
Specs
events(GenServer.server()) :: Enumerable.t()
Returns a lazy stream of events.
This function is internally used by run/2
and await/1
. If you want to use it yourself, you
need to pass the handler &send(some_pid, {self(), &1})
when starting the command. This
function can only be invoked in the process which receives the event messages.
Specs
expect(mock()) :: :ok
Sets up a mock expectation.
The argument can be either a string (the exact command text), or a function. If the string is passed, the mocked command will succeed with the empty output. If the function is passed, it will be invoked when the command is started. The function can then return ok or error tuple.
The expectation will be inherited by all descendant processes (unless overridden somewhere down the process tree).
See Mox.expect/4
for details on expectations.
Specs
run(String.t(), [start_opt()]) :: {:ok, output()} | {:error, OsCmd.Error.t()}
Synchronously runs the command.
This function will start the owner process, wait for it to finish, and return the result which will include the complete output of the command.
If the command exits with a zero exit status, an :ok
tuple is returned. Otherwise, the function
returns an error tuple.
See start_link/1
for detailed explanation.
Specs
start_link(String.t()) :: GenServer.on_start()
start_link({String.t(), [start_opt()]}) :: GenServer.on_start()
Starts the command owner process.
The owner process will start the command, handle its events, and stop when the command finishes.
The started process will synchronously stop the command during while being terminated. The owner
process never stops before the command finishes (unless the owner process is forcefully
terminated with the reason :kill
), which makes it safe to run under a supervisor or Parent
.
The command is a "free-form string" in the shape of: "command arg1 arg2 ..."
. The command has
to be an executable that exists in standard search paths.
Args can be separated by one or more whitespaces, tabs, or newline characters. If any arg has
to contain whitespace characters, you can encircle it in double or single quotes. Inside the
quoted argument, you can use \"
or \'
to inject the quote character, and \\
to inject
the backslash character.
Examples:
OsCmd.start_link("echo 1")`
OsCmd.start_link(~s/
some_cmd
arg1
"arg \" \\ 2"
'arg \' \\ 3'
/)
Due to support for free-form execution, it is possible to execute complex scripts, by starting the shell process
OsCmd.start_link(~s/bash -c "..."/)
However, this is usually not advised, because OsCmd
can't keep all of its guarantees. Any
child process started inside the shell is not guaranteed to be terminated before the owner
process stops, and some of them might even linger on forever.
Options
You can pass additional options by invoking OsCmd.start_link({cmd, opts})
. The following
options are supported:
:cd
- Folder in which the command will be started:env
- OS environment variables which will be set in the command's own environment. Note that the command OS process inherits the environment from the BEAM process. If you want to unset some of the inherited variables, you can include{var_to_unset, nil}
in this list.:pty
- If set totrue
, the command will be started with a pseudo-terminal interface. If the OS doesn't support pseudo-terminal, this flag is ignored. Defaults tofalse
.:timeout
- The duration after which the command will be automatically terminated. Defaults to:infinity
. If the command is timed out, the process will exit with the reason:timeout
, irrespective of thepropagate_exit?
setting.propagate_exit?
- When set totrue
and the exit reason of the command is not zero, the process will exit with{:failed, exit_status}
. Otherwise, the process will always exit with the reason:normal
(unless the command times out).terminate_cmd
- Custom command to use in place of SIGTERM when stopping the OS process. See the "Command termination" section for details.handler
- Custom event handler. See the "Event handling" section for details.name
- Registered name of the process. If not provided, the process won't be registered.
Event handling
During the lifetime of the command, the following events are emitted:
:starting
- the command is being started{:output, output}
- stdout or stderr output{:stopped, exit_status}
- the command stopped with the given exit status
You can install multiple custom handlers to deal with these events. By default, no handler is created, which means that the command is executed silently. Handlers are functions which are executed inside the command owner process. Handlers can be stateless or stateful.
A stateless handler is a function in the shape of fun(event) -> ... end
. This function holds
no state, so its result is ignored. A stateful handler can be specified as
{initial_acc, fun(event, acc) -> next_acc end}
. You can provide multiple handlers, and they
don't have to be of the same type.
Since handlers are executed in the owner process, an unhandled exception in the handler will
lead to process termination. The OS process will be properly taken down before the owner process
stops, but no additional event (including the :stopped
event) will be fired.
It is advised to minimize the logic inside handlers. Handlers are best suited for very simple tasks such as logging or notifying other processes.
Output
The output events will contain output fragments, as they are received. It is therefore possible that some fragment contains only a part of the output line, while another spans multiple lines. It is the responsibility of the client to assemble these fragments according to its needs.
OsCmd
operates on the assumption that output is in utf8 encoding, so it may not work correctly
for other encodings, such as plain binary.
Command termination
When command is being externally terminated (e.g. due to a timeout), a polite termination is
first attempted, by sending a SIGTERM signal to the OS process (if the OS supports such signal),
or alternatively invoking a custom terminated command provided via :terminate_cmd
. If the OS
process doesn't stop in 5 seconds (currently not configurable), a SIGKILL signal will be sent.
Internals
The owner process starts the command as the Erlang port. The command is not started directly. Instead a bridge program (implemented in go) is used to start and manage the OS process. Each command uses its own bridge process. This approach ensures proper cleanup guarantees even if the BEAM OS process is taken down.
As a result, compared to System.cmd/3
, OsCmd
will consume more resources (2x more OS process
instances) and require more hops to pass the output back to Elixir. Most often this won't matter,
but be aware of these trade-offs if you're starting a large number of external processes.
Mocking in tests
Command execution can be mocked, which may be useful if you want to avoid starting long-running commands in tests.
Mocking can done with expect/1
and stub/1
, and explicit allowances can be issued with
allow/1
.
Specs
stop(GenServer.server(), :infinity | pos_integer()) :: :ok
Stops the command and the owner process.
Specs
stub(mock()) :: :ok
Sets up a mock stub.
This function works similarly to expect/1
, except it sets up a stub. See Mox.stub/3
for
details on stubs.