Added timeout option to detect hung ffmpeg process #27

Merged
merged 4 commits into from Jun 27, 2012
View
@@ -154,6 +154,22 @@ FFMPEG.ffmpeg_binary = '/usr/local/bin/ffmpeg'
This will cause the same command to run as "/usr/local/bin/ffmpeg -i /path/to/input.file ..." instead.
+
+Automatically kill hung processes
+---------------------------------
+
+By default, streamio will wait for 200 seconds between IO feedback from the FFMPEG process. After which an error is logged and the process killed.
+It is possible to modify this behaviour by setting a new default:
+
+``` ruby
+# Change the timeout
+Transcoder.timeout = 30
+
+# Disable the timeout altogether
+Transcoder.timeout = false
+```
+
+
Copyright
---------
@@ -0,0 +1,72 @@
+
+
+if RUBY_VERSION =~ /1\.8/
+ #
+ # This is useful when `timeout.rb`, which, on M.R.I 1.8, relies on green threads, does not work consistently.
+ #
+ begin
+ require 'system_timer'
+ MyTimer = SystemTimer
+ rescue LoadError
+ require 'timeout'
+ MyTimer = Timeout
+ end
+else
+ require 'timeout'
+ MyTimer = Timeout
+end
+
+
+#
+# Monkey Patch timeout support into the IO class
+#
+class IO
+ def each_with_timeout(pid, timeout, sep_string=$/)
+ q = Queue.new
+ th = nil
+
+ timer_set = lambda do |timeout|
+ th = new_thread(pid){ to(timeout){ q.pop } }
+ end
+
+ timer_cancel = lambda do |timeout|
+ th.kill if th rescue nil
+ end
+
+ timer_set[timeout]
+ begin
+ self.each(sep_string) do |buf|
+ timer_cancel[timeout]
+ yield buf
+ timer_set[timeout]
+ end
+ ensure
+ timer_cancel[timeout]
+ end
+ end
+
+
+ private
+
+
+ def new_thread(pid, *a, &b)
+ cur = Thread.current
+ Thread.new(*a) do |*a|
+ begin
+ b[*a]
+ rescue Exception => e
+ cur.raise e
+ if RUBY_PLATFORM =~ /(win|w)(32|64)$/
+ require 'win32/process'
+ Process.kill(1, pid)
+ else
+ Process.kill('INT', pid)
@dbackeus
dbackeus Jun 27, 2012 Collaborator

Why was the killing process changed from SIGKILL to INT?

Isn't it that if the process is hung we might not get out of it with such a soft method as INT?

+ end
+ end
+ end
+ end
+
+ def to timeout = nil
+ MyTimer.timeout(timeout){ yield }
+ end
+end
@@ -1,8 +1,22 @@
require 'open3'
require 'shellwords'
+if RUBY_PLATFORM =~ /(win|w)(32|64)$/
+ require 'win32/process'
@dbackeus
dbackeus Jun 27, 2012 Collaborator

Why are we requiring this here and again in io monkey?

+end
+
module FFMPEG
class Transcoder
+ @@timeout = 200
+
+ def self.timeout=(time)
+ @@timeout = time == false ? false : time.to_i
+ end
+
+ def self.timeout
+ @@timeout
+ end
+
def initialize(movie, output_file, options = EncodingOptions.new, transcoder_options = {})
@movie = movie
@output_file = output_file
@@ -28,26 +42,39 @@ def run
FFMPEG.logger.info("Running transcoding...\n#{command}\n")
output = ""
last_output = nil
- Open3.popen3(command) do |stdin, stdout, stderr|
- yield(0.0) if block_given?
- stderr.each("r") do |line|
- fix_encoding(line)
- output << line
- if line.include?("time=")
- if line =~ /time=(\d+):(\d+):(\d+.\d+)/ # ffmpeg 0.8 and above style
- time = ($1.to_i * 3600) + ($2.to_i * 60) + $3.to_f
- elsif line =~ /time=(\d+.\d+)/ # ffmpeg 0.7 and below style
- time = $1.to_f
- else # better make sure it wont blow up in case of unexpected output
- time = 0.0
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
+ pid = wait_thr.pid
+ begin
+ yield(0.0) if block_given?
+ next_line = Proc.new do |line|
+ fix_encoding(line)
+ output << line
+ if line.include?("time=")
+ if line =~ /time=(\d+):(\d+):(\d+.\d+)/ # ffmpeg 0.8 and above style
+ time = ($1.to_i * 3600) + ($2.to_i * 60) + $3.to_f
+ elsif line =~ /time=(\d+.\d+)/ # ffmpeg 0.7 and below style
+ time = $1.to_f
+ else # better make sure it wont blow up in case of unexpected output
+ time = 0.0
+ end
+ progress = time / @movie.duration
+ yield(progress) if block_given?
+ end
+ if line =~ /Unsupported codec/
+ FFMPEG.logger.error "Failed encoding...\nCommand\n#{command}\nOutput\n#{output}\n"
+ raise "Failed encoding: #{line}"
end
- progress = time / @movie.duration
- yield(progress) if block_given?
end
- if line =~ /Unsupported codec/
- FFMPEG.logger.error "Failed encoding...\nCommand\n#{command}\nOutput\n#{output}\n"
- raise "Failed encoding: #{line}"
+
+ if @@timeout != false
+ stderr.each_with_timeout(pid, @@timeout, "r", &next_line)
+ else
+ stderr.each("r", &next_line)
end
+
+ rescue Timeout::Error => e
+ FFMPEG.logger.error "Process hung...\nCommand\n#{command}\nOutput\n#{output}\n"
+ raise "Process hung"
end
end
@@ -1,3 +1,3 @@
module FFMPEG
- VERSION = "0.8.5"
+ VERSION = "0.8.6"
end
@@ -6,6 +6,7 @@
require 'ffmpeg/version'
require 'ffmpeg/errors'
require 'ffmpeg/movie'
+require 'ffmpeg/io_monkey'
require 'ffmpeg/transcoder'
require 'ffmpeg/encoding_options'
@@ -29,6 +29,16 @@ module FFMPEG
FFMPEG.logger.should_receive(:info).at_least(:once)
end
+ it "should fail when IO timeout is exceeded" do
+ FFMPEG.logger.should_receive(:error)
+ movie = Movie.new("#{fixture_path}/movies/awesome_widescreen.mov")
+ Transcoder.timeout = 1
+ transcoder = Transcoder.new(movie, "#{tmp_path}/timeout.mp4")
+ lambda { transcoder.run }.should raise_error(RuntimeError, /Process hung/)
+ end
+
+ Transcoder.timeout = 200
+
it "should transcode the movie with progress given an awesome movie" do
FileUtils.rm_f "#{tmp_path}/awesome.flv"
@@ -13,8 +13,8 @@ Gem::Specification.new do |s|
s.summary = "Reads metadata and transcodes movies."
s.description = "Simple yet powerful wrapper around ffmpeg to get metadata from movies and do transcoding."
- s.add_development_dependency("rspec", "~> 2.7")
- s.add_development_dependency("rake", "~> 0.9.2")
+ s.add_development_dependency("rspec", ">= 2.7")
+ s.add_development_dependency("rake", ">= 0.9.2")
s.files = Dir.glob("lib/**/*") + %w(README.md LICENSE CHANGELOG)
end