OsCmd (ci v0.1.0) 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:

  1. 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.
  2. The external OS process will also be taken down if the entire BEAM instance goes down.
  3. Support for timeout-based and manual termination of the OS process.
  4. 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

handler() :: (event() -> any()) | {acc(), (event(), acc() -> acc())}

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

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 to true, the command will be started with a pseudo-terminal interface. If the OS doesn't support pseudo-terminal, this flag is ignored. Defaults to false.
  • :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 the propagate_exit? setting.
  • propagate_exit? - When set to true 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.

Link to this function

stop(server, timeout \\ :infinity)

View Source

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.