Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,25 @@ end
RuboCop::RakeTask.new(:lint)

desc "Runs the examples"
task examples: :install do
task :examples do
success = true

Dir["examples/*.rb"].each do |example|
puts "👉 Running #{example}"

if system(RbConfig.ruby, example)
puts "✅ #{example} ran successfully"
else
puts "❌ #{example} failed"
success &&= false
end
end

exit 1 unless success
end

desc "Runs the examples in unbundled mode"
task examples_unbundled: :install do
success = true

Bundler.with_unbundled_env do
Expand Down
2 changes: 1 addition & 1 deletion examples/wasm_split.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
WASM

wasm_split = Binaryen::Command.new("wasm-split", timeout: 2)
result = wasm_split.run("-", stdin: wasm_code, stderr: $stderr)
result = wasm_split.run(stdin: wasm_code, stderr: $stderr)
puts(result)
104 changes: 81 additions & 23 deletions lib/binaryen/command.rb
Original file line number Diff line number Diff line change
@@ -1,50 +1,108 @@
# frozen_string_literal: true

require "English"
require "shellwords"
require "timeout"
require "posix/spawn"
require "timeout"
require "tempfile"

module Binaryen
# Wrapper around a binaryen executable command with a timeout and streaming IO.
#
# @example Running wasm-opt
#
# ```ruby
# command = Binaryen::Command.new("wasm-opt", timeout: 10)
# optimized_wasm = command.run("-O4", stdin: "(module)")
# ```
class Command
MAX_OUTPUT_SIZE = 256 * 1024 * 1024
include POSIX::Spawn
DEFAULT_MAX_OUTPUT_SIZE = 256 * 1024 * 1024
DEFAULT_TIMEOUT = 10
DEFAULT_ARGS_FOR_COMMAND = {
"wasm-opt" => ["--output=-"],
}.freeze

def initialize(cmd, timeout: 10, ignore_missing: false)
def initialize(cmd, timeout: DEFAULT_TIMEOUT, max_output_size: DEFAULT_MAX_OUTPUT_SIZE, ignore_missing: false)
@cmd = command_path(cmd, ignore_missing) || raise(ArgumentError, "command not found: #{cmd}")
@timeout = timeout
@default_args = DEFAULT_ARGS_FOR_COMMAND.fetch(cmd, [])
@max_output_size = max_output_size
end

def run(*arguments, stdin: nil, stderr: nil)
args = [@cmd] + arguments + @default_args
child = POSIX::Spawn::Child.new(*args, input: stdin, timeout: @timeout, max: MAX_OUTPUT_SIZE)
stderr&.write(child.err)
status = child.status

raise Binaryen::NonZeroExitStatus, "command exited with status #{status.exitstatus}" unless status.success?

child.out
rescue POSIX::Spawn::MaximumOutputExceeded => e
raise Binaryen::MaximumOutputExceeded, e.message
rescue POSIX::Spawn::TimeoutExceeded => e
raise Timeout::Error, e.message
if stdin
with_stdin_tempfile(stdin) { |path| spawn_command(*args, path, stderr: stderr) }
else
spawn_command(*args, stderr: stderr)
end
end

private

def with_stdin_tempfile(content)
Tempfile.open("binaryen-stdin") do |f|
f.binmode
f.write(content)
f.close
yield f.path
end
end

def command_path(cmd, ignore_missing)
Dir[File.join(Binaryen.bindir, cmd)].first || (ignore_missing && cmd)
end

def spawn_command(*args, stderr: nil)
out = "".b
data_buffer = "".b
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
pid, stdin, stdout, stderr_stream = popen4(*args)
stdin.close
@pid = pid
readers = [stdout, stderr_stream]

while readers.any?
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
remaining_time = @timeout - elapsed_time
ready = IO.select(readers, nil, nil, remaining_time)
raise Timeout::Error, "command timed out after #{@timeout} seconds" if ready.nil?

ready[0].each do |io|
max_amount_to_read = @max_output_size - out.bytesize + 1
data = io.read_nonblock(max_amount_to_read, data_buffer, exception: false)

if data == :wait_readable
# If the IO object is not ready for reading, read_nonblock returns :wait_readable.
# This isn't an error, but a notification.
next
elsif data.nil?
# At EOF, read_nonblock returns nil instead of raising EOFError.
readers.delete(io)
elsif io == stdout
out << data_buffer
elsif io == stderr_stream && stderr
stderr << data_buffer
end
rescue Errno::EPIPE, Errno::EINTR
# Handle EPIPE and EINTR errors
readers.delete(io)
end

if out.bytesize > @max_output_size
Process.kill("TERM", @pid)
raise Binaryen::MaximumOutputExceeded, "maximum output size exceeded (#{@max_output_size} bytes)"
end

if remaining_time < 0
Process.kill("TERM", @pid)
raise Timeout::Error, "command timed out after #{@timeout} seconds"
end
end

_, status = Process.waitpid2(pid, Process::WUNTRACED)

raise Binaryen::NonZeroExitStatus, "command exited with status #{status.exitstatus}" unless status.success?

out
ensure
[stdin, stdout, stderr_stream].each do |io|
io.close
rescue
nil
end
end
end
end
11 changes: 6 additions & 5 deletions test/command_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ def test_it_returns_a_readable_stdout_stream
end

def test_raises_when_output_exceeds_maximum
cmd = Binaryen::Command.new("ruby", timeout: 30, ignore_missing: true)
cmd = Binaryen::Command.new("ruby", timeout: 30, ignore_missing: true, max_output_size: 1024)
assert_raises(Binaryen::MaximumOutputExceeded) do
cmd.run("-e", "puts('a' * 256 * 1024 * 1024)")
cmd.run("-e", "puts('a' * 1025)")
end
end

Expand All @@ -35,9 +35,10 @@ def test_times_out_sanely_on_reads
assert_proper_timeout_for_command("ruby", "-e", "loop do puts('yes'); sleep 0.01; end")
end

def test_times_out_sanely_on_blocking_writes
stdin = "y" * (64 * 1024 * 1024)
assert_proper_timeout_for_command("ruby", "-e", "while STDIN.getc; sleep 0.01; end", stdin: stdin)
def test_passes_stdin_as_a_file
command = Binaryen::Command.new("cat", timeout: 2, ignore_missing: true)
result = command.run(stdin: "hello world")
assert_equal("hello world", result)
end

def test_it_raises_an_error_with_a_reasonable_message_if_the_command_is_not_found
Expand Down