Skip to content

Commit

Permalink
timeout option to restrict maximum time spent on every image
Browse files Browse the repository at this point in the history
The original commit discourse/image_optim@8bf3c0e, see discussion in resolved PRs.

Resolves toy#21, resolves toy#148, resolves toy#149, resolves toy#162, resolves toy#184, resolves toy#189.

Co-authored-by: Blake Erickson <[email protected]>
  • Loading branch information
2 people authored and toy committed May 9, 2021
1 parent bb169a6 commit 7c46468
Show file tree
Hide file tree
Showing 28 changed files with 349 additions and 47 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## unreleased

* Add `timeout` option to restrict maximum time spent on every image [#21](https://github.com/toy/image_optim/issues/21) [#148](https://github.com/toy/image_optim/pull/148) [#149](https://github.com/toy/image_optim/pull/149) [#162](https://github.com/toy/image_optim/pull/162) [#184](https://github.com/toy/image_optim/pull/184) [#189](https://github.com/toy/image_optim/pull/189) [@tgxworld](https://github.com/tgxworld) [@oblakeerickson](https://github.com/oblakeerickson) [@toy](https://github.com/toy)

## v0.29.0 (2021-04-28)

* Require at least ruby 1.9.3 [@toy](https://github.com/toy)
Expand Down
1 change: 1 addition & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ optipng:
* `:allow_lossy` — Allow lossy workers and optimizations *(defaults to `false`)*
* `:cache_dir` — Configure cache directory
* `:cache_worker_digests` - Also cache worker digests along with original file digest and worker options: updating workers invalidates cache
* `:timeout` — Maximum time in seconds to spend on one image, note multithreading and cache *(defaults to unlimited)*

Worker can be disabled by passing `false` instead of options hash or by setting option `:disable` to `true`.

Expand Down
18 changes: 15 additions & 3 deletions lib/image_optim.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
require 'image_optim/bin_resolver'
require 'image_optim/cache'
require 'image_optim/config'
require 'image_optim/errors'
require 'image_optim/handler'
require 'image_optim/image_meta'
require 'image_optim/optimized_path'
require 'image_optim/path'
require 'image_optim/timer'
require 'image_optim/worker'
require 'in_threads'
require 'shellwords'
Expand Down Expand Up @@ -46,6 +48,9 @@ class ImageOptim
# Cache worker digests
attr_reader :cache_worker_digests

# Timeout in seconds for each image
attr_reader :timeout

# Initialize workers, specify options using worker underscored name:
#
# pass false to disable worker
Expand Down Expand Up @@ -78,6 +83,7 @@ def initialize(options = {})
allow_lossy
cache_dir
cache_worker_digests
timeout
].each do |name|
instance_variable_set(:"@#{name}", config.send(name))
$stderr << "#{name}: #{send(name)}\n" if verbose
Expand Down Expand Up @@ -110,11 +116,17 @@ def optimize_image(original)
return unless (workers = workers_for_image(original))

optimized = @cache.fetch(original) do
timer = timeout && Timer.new(timeout)

Handler.for(original) do |handler|
workers.each do |worker|
handler.process do |src, dst|
worker.optimize(src, dst)
begin
workers.each do |worker|
handler.process do |src, dst|
worker.optimize(src, dst, :timeout => timer)
end
end
rescue Errors::TimeoutExceeded
handler.result
end
end
end
Expand Down
6 changes: 6 additions & 0 deletions lib/image_optim/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ def initialize(image_optim, workers_by_format)
"#{bin.name}[#{bin.digest}]"
end.sort!.uniq.join(', ')]
end]
@global_options = begin
options = {}
options[:timeout] = image_optim.timeout if image_optim.timeout
options.empty? ? '' : options.inspect
end
end

def fetch(original)
Expand Down Expand Up @@ -68,6 +73,7 @@ def digest(path, format)
digest = Digest::SHA1.file(path)
digest.update options_by_format(format)
digest.update bins_by_format(format) if @cache_worker_digests
digest.update @global_options
s = digest.hexdigest
"#{s[0..1]}/#{s[2..-1]}"
end
Expand Down
51 changes: 45 additions & 6 deletions lib/image_optim/cmd.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require 'image_optim/errors'
require 'English'

class ImageOptim
Expand All @@ -10,11 +11,30 @@ class << self
# Return success status
# Will raise SignalException if process was interrupted
def run(*args)
success = system(*args)
if args.last.is_a?(Hash) && (timeout = args.last.delete(:timeout))
args.last[Gem.win_platform? ? :new_pgroup : :pgroup] = true

check_status!
pid = Process.spawn(*args)

waiter = Process.detach(pid)
if waiter.join(timeout)
status = waiter.value

check_status!(status)

status.success?
else
cleanup(pid, waiter)

success
fail Errors::TimeoutExceeded
end
else
success = system(*args)

check_status!

success
end
end

# Run using backtick
Expand All @@ -30,9 +50,7 @@ def capture(cmd)

private

def check_status!
status = $CHILD_STATUS

def check_status!(status = $CHILD_STATUS)
return unless status.signaled?

# jruby incorrectly returns true for `signaled?` if process exits with
Expand All @@ -46,6 +64,27 @@ def check_status!

fail SignalException, status.termsig
end

def cleanup(pid, waiter)
if Gem.win_platform?
kill('KILL', pid)
else
kill('-TERM', pid)

# Allow 10 seconds for the process to exit
waiter.join(10)

kill('-KILL', pid)
end

waiter.join
end

def kill(signal, pid)
Process.kill(signal, pid)
rescue Errno::ESRCH, Errno::EPERM
# expected
end
end
end
end
8 changes: 8 additions & 0 deletions lib/image_optim/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def threads
end
end

# Timeout in seconds for each image:
# * not set by default and for `nil`
# * otherwise converted to float
def timeout
timeout = get!(:timeout)
timeout ? timeout.to_f : nil
end

# Verbose mode, converted to boolean
def verbose
!!get!(:verbose)
Expand Down
26 changes: 26 additions & 0 deletions lib/image_optim/elapsed_time.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

class ImageOptim
# Use Process.clock_gettime if available to get time more fitting to calculate elapsed time
module ElapsedTime
CLOCK_NAME = %w[
CLOCK_UPTIME_RAW
CLOCK_UPTIME
CLOCK_MONOTONIC_RAW
CLOCK_MONOTONIC
CLOCK_REALTIME
].find{ |name| Process.const_defined?(name) }

CLOCK_ID = CLOCK_NAME && Process.const_get(CLOCK_NAME)

module_function

def now
if CLOCK_ID
Process.clock_gettime(CLOCK_ID)
else
Time.now.to_f
end
end
end
end
9 changes: 9 additions & 0 deletions lib/image_optim/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class ImageOptim
class Error < StandardError; end

module Errors
class TimeoutExceeded < Error; end
end
end
4 changes: 4 additions & 0 deletions lib/image_optim/runner/option_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ def wrap_regex(width)
options[:allow_lossy] = allow_lossy
end

op.on('--timeout N', Float, 'Maximum time in seconds to spend on one image') do |timeout|
options[:timeout] = timeout
end

op.separator nil

ImageOptim::Worker.klasses.each_with_index do |klass, i|
Expand Down
25 changes: 25 additions & 0 deletions lib/image_optim/timer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require 'image_optim/elapsed_time'

class ImageOptim
# Hold start time and timeout
class Timer
include ElapsedTime

def initialize(seconds)
@start = now
@seconds = seconds
end

def elapsed
now - @start
end

def left
@seconds - elapsed
end

alias_method :to_f, :left
end
end
36 changes: 24 additions & 12 deletions lib/image_optim/worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

require 'image_optim/cmd'
require 'image_optim/configuration_error'
require 'image_optim/elapsed_time'
require 'image_optim/path'
require 'image_optim/worker/class_methods'
require 'shellwords'
Expand Down Expand Up @@ -41,7 +42,7 @@ def options

# Optimize image at src, output at dst, must be overriden in subclass
# return true on success
def optimize(_src, _dst)
def optimize(_src, _dst, options = {})
fail NotImplementedError, "implement method optimize in #{self.class}"
end

Expand Down Expand Up @@ -122,33 +123,44 @@ def wrap_resolver_error_message(message)
end

# Run command setting priority and hiding output
def execute(bin, *arguments)
def execute(bin, arguments, options)
resolve_bin!(bin)

cmd_args = [bin, *arguments].map(&:to_s)

start = Time.now

success = run_command(cmd_args)

if @image_optim.verbose
seconds = Time.now - start
$stderr << "#{success ? '✓' : '✗'} #{seconds}s #{cmd_args.shelljoin}\n"
run_command_verbose(cmd_args, options)
else
run_command(cmd_args, options)
end

success
end

# Run command defining environment, setting nice level, removing output and
# reraising signal exception
def run_command(cmd_args)
def run_command(cmd_args, options)
args = [
{'PATH' => @image_optim.env_path},
*%W[nice -n #{@image_optim.nice}],
*cmd_args,
{:out => Path::NULL, :err => Path::NULL},
options.merge(:out => Path::NULL, :err => Path::NULL),
]
Cmd.run(*args)
end

# Wrap run_command and output status, elapsed time and command
def run_command_verbose(cmd_args, options)
start = ElapsedTime.now

begin
success = run_command(cmd_args, options)
status = success ? '✓' : '✗'
success
rescue Errors::TimeoutExceeded
status = 'timeout'
raise
ensure
$stderr << format("%s %.1fs %s\n", status, ElapsedTime.now - start, cmd_args.shelljoin)
end
end
end
end
4 changes: 2 additions & 2 deletions lib/image_optim/worker/advpng.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def run_order
4
end

def optimize(src, dst)
def optimize(src, dst, options = {})
src.copy(dst)
args = %W[
--recompress
Expand All @@ -30,7 +30,7 @@ def optimize(src, dst)
--
#{dst}
]
execute(:advpng, *args) && optimized?(src, dst)
execute(:advpng, args, options) && optimized?(src, dst)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/image_optim/worker/gifsicle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def self.init(image_optim, options = {})
CAREFUL_OPTION =
option(:careful, false, 'Avoid bugs with some software'){ |v| !!v }

def optimize(src, dst)
def optimize(src, dst, options = {})
args = %W[
--output=#{dst}
--no-comments
Expand All @@ -58,7 +58,7 @@ def optimize(src, dst)
end
args.unshift '--careful' if careful
args.unshift "--optimize=#{level}" if level
execute(:gifsicle, *args) && optimized?(src, dst)
execute(:gifsicle, args, options) && optimized?(src, dst)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/image_optim/worker/jhead.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def used_bins
[:jhead, :jpegtran]
end

def optimize(src, dst)
def optimize(src, dst, options = {})
return false unless oriented?(src)

src.copy(dst)
Expand All @@ -34,7 +34,7 @@ def optimize(src, dst)
#{dst}
]
resolve_bin!(:jpegtran)
execute(:jhead, *args) && dst.size?
execute(:jhead, args, options) && dst.size?
end

private
Expand Down
Loading

0 comments on commit 7c46468

Please sign in to comment.