• Stars
    star
    105
  • Rank 316,220 (Top 7 %)
  • Language
    Elixir
  • License
    Apache License 2.0
  • Created almost 4 years ago
  • Updated 11 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Alternative to ports for running external programs. It provides back-pressure, non-blocking io, and solves port related issues

Exile

CI Hex.pm docs

Exile is an alternative to ports for running external programs. It provides back-pressure, non-blocking io, and tries to fix ports issues.

Exile is built around the idea of having demand-driven, asynchronous interaction with external process. Think of streaming a video through ffmpeg to serve a web request. Exile internally uses NIF. See Rationale for details. It also provides stream abstraction for interacting with an external program. For example, getting audio out of a stream is as simple as

Exile.stream!(~w(ffmpeg -i pipe:0 -f mp3 pipe:1), input: File.stream!("music_video.mkv", [], 65_535))
|> Stream.into(File.stream!("music.mp3"))
|> Stream.run()

See Exile.stream!/2 module doc for more details about handling stderr and other options.

Exile.stream!/2 is a convenience wrapper around Exile.Process. Prefer using Exile.stream! over using Exile.Process directly.

Exile requires OTP v22.1 and above.

Exile is based on NIF, please know consequence of that before using Exile. For basic use cases use ExCmd instead.

Installation

def deps do
  [
    {:exile, "~> x.x.x"}
  ]
end

Quick Start

Run a command and read from stdout

iex> Exile.stream!(~w(echo Hello))
...> |> Enum.into("") # collect as string
"Hello\n"

Run a command with list of strings as input

iex> Exile.stream!(~w(cat), input: ["Hello", " ", "World"])
...> |> Enum.into("") # collect as string
"Hello World"

Run a command with input as Stream

iex> input_stream = Stream.map(1..10, fn num -> "#{num} " end)
iex> Exile.stream!(~w(cat), input: input_stream)
...> |> Enum.into("")
"1 2 3 4 5 6 7 8 9 10 "

Run a command with input as infinite stream

# create infinite stream
iex> input_stream = Stream.repeatedly(fn -> "A" end)
iex> binary =
...>   Exile.stream!(~w(cat), input: input_stream, ignore_epipe: true) # we need to ignore epipe since we are terminating the program before the input completes
...>   |> Stream.take(2) # we must limit since the input stream is infinite
...>   |> Enum.into("")
iex> is_binary(binary)
true
iex> "AAAAA" <> _ = binary

Run a command with input Collectable

# Exile calls the callback with a sink where the process can push the data
iex> Exile.stream!(~w(cat), input: fn sink ->
...>   Stream.map(1..10, fn num -> "#{num} " end)
...>   |> Stream.into(sink) # push to the external process
...>   |> Stream.run()
...> end)
...> |> Stream.take(100) # we must limit since the input stream is infinite
...> |> Enum.into("")
"1 2 3 4 5 6 7 8 9 10 "

When the command wait for the input stream to close

# base64 command wait for the input to close and writes data to stdout at once
iex> Exile.stream!(~w(base64), input: ["abcdef"])
...> |> Enum.into("")
"YWJjZGVm\n"

stream!/2 raises non-zero exit as error

iex> Exile.stream!(["sh", "-c", "echo 'foo' && exit 10"])
...> |> Enum.to_list()
** (Exile.Stream.AbnormalExit) program exited with exit status: 10

stream/2 variant returns exit status as last element

iex> Exile.stream(["sh", "-c", "echo 'foo' && exit 10"])
...> |> Enum.to_list()
[
  "foo\n",
  {:exit, {:status, 10}} # returns exit status of the program as last element
]

You can fetch exit_status from the error for stream!/2

iex> try do
...>   Exile.stream!(["sh", "-c", "exit 10"])
...>   |> Enum.to_list()
...> rescue
...>   e in Exile.Stream.AbnormalExit ->
...>     e.exit_status
...> end
10

With max_chunk_size set

iex> data =
...>   Exile.stream!(~w(cat /dev/urandom), max_chunk_size: 100, ignore_epipe: true)
...>   |> Stream.take(5)
...>   |> Enum.into("")
iex> byte_size(data)
500

When input and output run at different rate

iex> input_stream = Stream.map(1..1000, fn num -> "X #{num} X\n" end)
iex> Exile.stream!(~w(grep 250), input: input_stream)
...> |> Enum.into("")
"X 250 X\n"

With stderr enabled

iex> Exile.stream!(["sh", "-c", "echo foo\necho bar >> /dev/stderr"], enable_stderr: true)
...> |> Enum.to_list()
[{:stdout, "foo\n"}, {:stderr, "bar\n"}]

For more details about stream API, see Exile.stream!/2 and Exile.stream/2.

For more details about inner working, please check Exile.Process documentation.

Rationale

Existing approaches

Port

Port is the default way of executing external commands. This is okay when you have control over the external program's implementation and the interaction is minimal. Port has several important issues.

  • it can end up creating zombie process
  • cannot selectively close stdin. This is required when the external programs act on EOF from stdin
  • it sends command output as a message to the beam process. This does not put back pressure on the external program and leads exhausting VM memory

Middleware based solutions

Libraries such as Porcelain, Erlexec, Rambo, etc. solves the first two issues associated with ports - zombie process and selectively closing STDIN. But not the third issue - having back-pressure. At a high level, these libraries solve port issues by spawning an external middleware program which in turn spawns the program we want to run. Internally uses port for reading the output and writing input. Note that these libraries are solving a different subset of issues and have different functionality, please check the relevant project page for details.

  • no back-pressure
  • additional os process (middleware) for every execution of your program
  • in few cases such as porcelain user has to install this external program explicitly
  • might not be suitable when the program requires constant communication between beam process and external program

On the plus side, unlike Exile, bugs in the implementation does not bring down whole beam VM.

ExCmd

This is my other stab at solving back pressure on the external program issue. It implements a demand-driven protocol using odu to solve this. Since ExCmd is also a port based solution, concerns previously mentioned applies to ExCmd too.

Exile

Internally Exile uses non-blocking asynchronous system calls to interact with the external process. It does not use port's message based communication instead does raw stdio using NIF. Uses asynchronous system calls for IO. Most of the system calls are non-blocking, so it should not block the beam schedulers. Makes use of dirty-schedulers for IO.

Highlights

  • Back pressure
  • no middleware program
    • no additional os process. No performance/resource cost
    • no need to install any external command
  • tries to handle zombie process by attempting to clean up external process. But as there is no middleware involved with exile, so it is still possible to endup with zombie process if program misbehave.
  • stream abstraction
  • selectively consume stdout and stderr streams

If you are running executing huge number of external programs concurrently (more than few hundred) you might have to increase open file descriptors limit (ulimit -n)

Non-blocking io can be used for other interesting things. Such as reading named pipe (FIFO) files. Exile.stream!(~w(cat data.pipe)) does not block schedulers, so you can open hundreds of fifo files unlike default file based io.

TODO

  • add benchmarks results

🚨 Obligatory NIF warning

As with any NIF based solution, bugs or issues in Exile implementation can bring down the beam VM. But NIF implementation is comparatively small and mostly uses POSIX system calls. Also, spawned external processes are still completely isolated at OS level.

If all you want is to run a command with no communication, then just sticking with System.cmd is a better.

License

Copyright (c) 2020 Akash Hiremath.

Exile source code is released under Apache License 2.0. Check LICENSE for more information.

More Repositories

1

vix

Elixir extension for libvips
Elixir
114
star
2

ex_cmd

ExCmd is an Elixir library to run external programs and to communicate with back pressure
Elixir
49
star
3

off_broadway_redis_stream

A Broadway producer for Redis Stream
Elixir
16
star
4

unzip

Module to get files out of a zip
Elixir
15
star
5

eglot-flycheck-adaptor

Replaces flymake with flycheck for eglot diagnostics
Emacs Lisp
10
star
6

odu

Middleware program which helps with talking to external programs from Elixir or Erlang.
Go
10
star
7

ex_mustache

Efficient Mustache templates for Elixir
HTML
4
star
8

exscheme

Toy Scheme implementation in Elixir inspired by SICP
Elixir
3
star
9

zero_copy

Demo of sharing binary to and from NIF without copying
C
2
star
10

kino_vix

Vix integrations for Livebook
Elixir
2
star
11

dmenu-unicode

Unicode with ligature (glyphs) support for dmenu using Pango library.
C
2
star
12

slice-mp3

Simple mp3 cutter and data reader
C++
2
star
13

akash-akya.github.io

HTML
2
star
14

sublime-word-movement

Simple Sublime Text like word movement for Emacs
Emacs Lisp
1
star
15

resty.el

WIP: Programmable emacs interface to interact with RESTful endpoints
Emacs Lisp
1
star
16

2048-lisp

2048
Common Lisp
1
star
17

dasa-pada

Java
1
star
18

vachana-app

Java
1
star
19

exdot

Elixir abstraction for generating Graphviz dot formatted string
Elixir
1
star
20

openapi-diff

fork of https://bitbucket.org/atlassian/openapi-diff
TypeScript
1
star
21

xkcd-app

A simple android application for xkcd comics.
Java
1
star
22

external_display_watcher

Mac OS command-line utility to watch for external display connectivity
Objective-C
1
star
23

json-schema-diff

fork of https://bitbucket.org/atlassian/json-schema-diff
TypeScript
1
star