akash-akya

akash-akya

Hi all,

I’m tinkering around the idea of streaming data through an external program (think streaming video through ffmpeg command and receiving the output back) from the last few weeks. Mainly focused on communicating with long-running programs with back-pressure. After exploring many approaches I settled on this. ExCmd uses named FIFO to solve back-pressure and other issues. It also uses odu (which is based on goon) to fill gaps in the erlang ports.

Currently, it’s at an early stage. I’m still thinking about the interface it should provide to expose all its functionality for different use cases effectively.

Please check it out and share your feedback :slight_smile:

Background

Why not use built-in ports?

  • Unlike beam ports, ExCmd puts back pressure on the external program
  • Proper program termination. No more zombie process
  • Ability to close stdin and wait for output (with ports one can not selectively close stdin)

While exploring the options, I also played around another approach, which does not use named FIFO. Its more like GenStage, the receiver beam process “demands” external program for output using stdin and stdout., but it has its own set of other issues.

Showing Posts 1 to 10

akash-akya

akash-akya OP

Added ability to stream input and output. Now one can do something like this

def audio_stream!(stream) do
  # read from stdin and write to stdout
  proc_stream = ExCmd.stream!("ffmpeg", ~w(-i - -f mp3 -))

  Task.async(fn ->
    Stream.into(stream, proc_stream)
    |> Stream.run()
  end)

  proc_stream
end

File.stream!("music_video.mkv", [], 65535)
|> audio_stream!()
|> Stream.into(File.stream!("music.mp3"))
|> Stream.run()

Along with this there are many changes related to interface and error handling. Please check documentation for more details

v0.1.0

Github

dimitarvp

dimitarvp

I have installed both ex_cmd and odu (and have put it in my PATH). Then:

ExCmd.stream!("find", ["/Users/dimi/Downloads/temp", "-name", "*.html"]) |> Enum.to_list()

Blocks for 5 seconds and gives me this:

** (exit) exited in: GenServer.call(#PID<0.382.0>, {:open_fifo, :output, :read}, 5000)
    ** (EXIT) time out
    (elixir 1.10.2) lib/gen_server.ex:1023: GenServer.call/3
    (ex_cmd 0.1.0) lib/ex_cmd/stream.ex:43: anonymous fn/1 in Enumerable.ExCmd.Stream.reduce/3
    (elixir 1.10.2) lib/stream.ex:1407: anonymous fn/5 in Stream.resource/3
    (elixir 1.10.2) lib/enum.ex:3383: Enum.reverse/1
    (elixir 1.10.2) lib/enum.ex:2982: Enum.to_list/1

Am I doing something wrong? The directory has 10 files in total, 2 of which are HTML.

akash-akya

akash-akya OP

Hi @dimitarvp, thanks for taking your time to check it.

In this case we are trying to read output FIFO (that is the output of the find command), without writing any input. This fails because the external wrapper command odu is expecting a process to open input FIFO in write mode.
This is so because we can not know if the external command needs input or not. odu assume every command needs an input stream and blocks till streams are connected.

So to fix we just have to open a dummy writer

proc_stream = ExCmd.stream!("find", ["/Users/dimi/Downloads/temp", "-name", "*.html"])
Task.async(fn -> Enum.into([], proc_stream) end)
proc_stream |> Enum.to_list()

I think, we should have better error message and maybe we should have an option for explicitly saying this command does not use input.

dimitarvp

dimitarvp

Thanks for clarifying. This code is however not intuitive (and why do we need to spawn a Task for it to work?). Would you consider adding options to ExCmd.stream! then? For example, something like ExCmd.stream!(..., :stdout_only) would invisibly execute the code you pasted above.

akash-akya

akash-akya OP

I agree I’ll add an option to disable stdin


why do we need to spawn a Task for it to work?

tl;dr if we do not spawn separate process it will cause deadlock.

If anyone interested in this topic,
Stream is hiding synchronization happening between beam processes and external programs under the hood.

Without Task it would look something similar to this

proc_stream = ExCmd.stream!("find", ["/Users/dimi/Downloads/temp", "-name", "*.html"])
Enum.into([], proc_stream)
proc_stream |> Enum.to_list()

this is roughly equivalent to following steps with syscalls

1. create stream struct
2. syscall: fd = open("input.pipe", O_WRONLY)
3. syscall: close(fd)
4. run external program (at some point odu will call exec("cmd"))
5. syscall: fd = open("output.pipe", O_RDONLY)
...

step-2 is blocking call, this will return only after “input.pipe” is opened by the reader, which is the external program. But we start the external program at step-4, hence the deadlock.

This behavior is more visible if one uses low-level API instead of using stream abstraction.

open does have a non-blocking flag O_NONBLOCK. but,

  1. beam does not support passing this flag
  2. behavior is undefined for open with writer mode under POSIX

Interestingly, before OTP-21 allowed opening FIFO. A popular solution to open a FIFO in erlang/elixir was to use :erlang.open_port. :erlang.open_port is blocking call too, but in this case, it blocks the whole vm!

{_, 0} = System.cmd("mkfifo", ["test.pipe"])
spawn(fn -> Port.open('test.pipe', [:eof, :out]) end)
:timer.sleep(500) # force scheduler to execute fifo open
IO.puts "This line is never printed!"
dimitarvp

dimitarvp

Thanks a lot for the explanation! Makes sense.

Options like :stdin_only, :stdout_only and :stdin_and_stdout (the default) would help a lot. Boilerplate should be hidden away behind options and/or convenience functions like stream_stdin!, stream_stdout! and stream! (which expects both as it is right now).

Not sure about the names, they might not be good.

As for odu itself, it introduces a needlessly complex external dependency installation that some programmers might not be willing to subject themselves to. I’d suggest you write an in-app small Rust library. I can help you integrate Elixir with Rust – the release candidate of Rustler 0.22 has a much nicer and shorter syntax compared to previous versions and is now a joy to use.

dimitarvp

dimitarvp

Another thing: would there be a way to stream the spawned command’s output line by line? Currently I have to store a rather huge string in memory and then call String.split(the_whole_command_output, "\n"). Defeats the whole purpose of using Stream really. :frowning:

akash-akya

akash-akya OP

Options like :stdin_only , :stdout_only and :stdin_and_stdout (the default) would help a lot. Boilerplate should be hidden away behind options and/or convenience functions like stream_stdin! , stream_stdout! and stream! (which expects both as it is right now).

Yes. I’m more inclined towards just adding additional no_stdin, no_stdout options and keep the same stream! interface. Just to avoid adding more functions, which can be confusing. But we can have separate functions if that makes more sense.

As for odu itself, it introduces a needlessly complex external dependency installation that some programmers might not be willing to subject themselves to. I’d suggest you write an in-app small Rust library. I can help you integrate Elixir with Rust – the release candidate of Rustler 0.22 has a much nicer and shorter syntax compared to previous versions and is now a joy to use.

Agree, I want to ditch the odu and have everything in a single library. When I started odu, I was mostly experimenting, and keeping it separate seemed simpler. Rustler looks interesting, I’ll look into it as soon as I get some time :slight_smile:.

Another thing, currently all this ceremony is because beam does not expose file descriptors for stdin and stdout. Definitely there will be some valid reason for that. But if we some how get a NIF/driver interface which let us access stdin/stdout fd, then we can just use :file.open with that fd and we can get rid of whole fifo thingy. This is all hand-waving, there might some issue in actual implementation.

Another thing: would there be a way to stream the spawned command’s output line by line? Currently I have to store a rather huge string in memory and then call String.split(the_whole_command_output, "\n") . Defeats the whole purpose of using Stream really.

We can split as soon as we get the output right? something like

    proc_stream
    |> Stream.transform("", fn data, acc ->
      lines = IO.iodata_to_binary([acc | data]) |> String.split("\n")
      Enum.split(lines, length(lines) - 1)
    end)

˚The size of data chunk we get (data in abve example) depends on the command we are running and when that command flushs its output and when we are issuing read. This size is limited by fifo buffer size which is controlled by OS. Usually this size will be max 65kb (sometimes its less in mac os). So unless user is explicitly collecting output for something there should not be memory leak.

I prefer to avoid adding spliting lines to ex_cmd itself. But if enough people want this, we can add

dimitarvp

dimitarvp

Let me see if I am doing this right:

  @doc ~S"""
  Returns a `Stream` that yields full file paths corresponding to each HTML file
  inside the specified directory.
  """
  def stream_html_files_excmd(path) when is_binary(path) do
    expanded_path = Path.expand(path)
    stream = ExCmd.stream!("find", [expanded_path, "-name", "*.html"])

    # The `ex_cmd` library expects something to be written to the stdin of the invoked command.
    # So we spawn a separate dummy stdin writer `Task` to satisfy `ex_cmd`.
    Task.async(fn -> Enum.into([], stream) end)

    # Collect the stdout from the spawned command.
    stream
    |> Stream.transform("", fn data, acc ->
      lines = to_string([acc | data]) |> String.split("\n")
      Enum.split(lines, length(lines) - 1)
    end)
  end

Then it can just be used like stream_html_files_excmd("~/data/scraped.website") |> Enum.to_list().

This gives me the list of files that I need (double-checked with previously stored runs of find itself) and is working tens of times faster than Path.wildcard.

What sets your library apart is the Enumerable and Collectable integrations. Took me a bit to brush my Stream knowledge and with your help all is clear now.

Thank you for helping.

Where Next? Top

Trending in Announcing Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
fuelen
Hi all! I want to present a small library which provides a mix task for generating an Entity-Relationship Diagram for Ecto schemas. You...
New
anuaralfetahe
Hello Published a new library - ProcessHub! ProcessHub is a library designed to manage process distribution within the Elixir cluster. ...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
mudasobwa
I am seeing a lot of aplications of Argumentum ad Vericundiam in software discussions. They do link some piece of writing and point us to...
New
bartblast
Hey folks, I just published a post about Hologram’s funding and where the project goes next - the short version: Curiosum as Main Spons...
New
AstonJ
This showed up on my feed.. anyone heard of it? Just hype? Ox Alpha is a reasoning model designed for coding, sustained ag...
New
sorenone
Today we’re releasing Oban for Python. Not an Oban client in Python. Not a pythonx wrapper embedded in Elixir. Nope, it’s a fully operati...
New
sergio
It’s not that it’s vocabulary is too advanced. It’s something worse. I get lost trying to follow even a paragraph written by Claude. It’...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews