shrimple 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.travis.yml +9 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +22 -0
- data/README.md +125 -0
- data/Rakefile +7 -0
- data/lib/render.js +68 -0
- data/lib/shrimple.rb +120 -0
- data/lib/shrimple/default_config.rb +114 -0
- data/lib/shrimple/phantom.rb +108 -0
- data/lib/shrimple/process.rb +131 -0
- data/lib/shrimple/process_monitor.rb +84 -0
- data/shrimple.gemspec +21 -0
- data/spec/parse_and_print_stdin.js +10 -0
- data/spec/shrimple/phantom_spec.rb +93 -0
- data/spec/shrimple/process_monitor_spec.rb +69 -0
- data/spec/shrimple/process_spec.rb +85 -0
- data/spec/shrimple_long_spec.rb +205 -0
- data/spec/shrimple_spec.rb +119 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/syntax_error.js +3 -0
- data/spec/test_file.html +6 -0
- metadata +131 -0
@@ -0,0 +1,108 @@
|
|
1
|
+
# Adds a pleasant API on top of Shrimple::Process
|
2
|
+
|
3
|
+
require 'shrimple/process'
|
4
|
+
require 'stringio'
|
5
|
+
|
6
|
+
class Shrimple
|
7
|
+
class PhantomError < StandardError; end
|
8
|
+
class TimedOut < StandardError; end
|
9
|
+
|
10
|
+
class Phantom < Process
|
11
|
+
attr_reader :options, :config
|
12
|
+
|
13
|
+
def initialize options
|
14
|
+
@options = options
|
15
|
+
@onSuccess = options.delete(:onSuccess)
|
16
|
+
@onError = options.delete(:onError)
|
17
|
+
|
18
|
+
# write the file required by phantom's --config option
|
19
|
+
if options[:config]
|
20
|
+
@config = Tempfile.new(File.basename(options[:output] || 'shrimple') + '.config')
|
21
|
+
@config.write(options[:config].to_json)
|
22
|
+
@config.close
|
23
|
+
end
|
24
|
+
|
25
|
+
# create the ios to supply input and read output
|
26
|
+
@stdin = new_io(options[:stdin] || StringIO.new(options.to_json))
|
27
|
+
@stdout = new_io(options[:output], 'wb')
|
28
|
+
@stderr = new_io(options[:stderr], 'wt')
|
29
|
+
|
30
|
+
if options[:debug]
|
31
|
+
# hm, should this be replaced with methods? or maybe a superclass?
|
32
|
+
$stderr.puts "COMMAND: #{command_line}"
|
33
|
+
$stderr.puts "STDIN: #{options.to_json}"
|
34
|
+
end
|
35
|
+
|
36
|
+
super(command_line, @stdin, @stdout, @stderr, options[:timeout])
|
37
|
+
end
|
38
|
+
|
39
|
+
# blocks until the PhantomJS process is finished. raises an exception if it failed.
|
40
|
+
def wait
|
41
|
+
stop
|
42
|
+
unless @child.value.success?
|
43
|
+
raise Shrimple::TimedOut.new if timed_out?
|
44
|
+
raise Shrimple::PhantomError.new("PhantomJS returned #{@child.value.exitstatus}: #{stderr}")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def stdout
|
49
|
+
read_io @stdout
|
50
|
+
end
|
51
|
+
|
52
|
+
def stderr
|
53
|
+
read_io @stderr
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
# cleans up after the process. synchronized so it's guaranteed to only be called once.
|
58
|
+
# process is removed from the process table after this call returns
|
59
|
+
def _cleanup
|
60
|
+
super
|
61
|
+
|
62
|
+
proc = (success? ? @onSuccess : @onError)
|
63
|
+
proc.call(self) if proc
|
64
|
+
|
65
|
+
@config.unlink if @config
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
private
|
70
|
+
def command_line
|
71
|
+
if @options[:executable].nil?
|
72
|
+
raise "PhantomJS not found. Specify its executable with 'executable' option."
|
73
|
+
end
|
74
|
+
if @options[:executable].kind_of? Array
|
75
|
+
# if executable is an array then we assume it contains all necessary args (so :renderer is ignored)
|
76
|
+
command = @options[:executable]
|
77
|
+
else
|
78
|
+
command = [@options[:executable]]
|
79
|
+
command << "--config=#{@config.path}" if @config
|
80
|
+
command << @options[:renderer]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# pass a filepath, an IO object or equivlanet, or nil to create an empty StringIO ready for data.
|
85
|
+
def new_io name, *opt
|
86
|
+
if name
|
87
|
+
if name.kind_of? String
|
88
|
+
return File.open(name, *opt)
|
89
|
+
else
|
90
|
+
name
|
91
|
+
end
|
92
|
+
else
|
93
|
+
StringIO.new
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def read_io io
|
98
|
+
if io.kind_of?(StringIO)
|
99
|
+
# can't rewind because then writes go to wrong place
|
100
|
+
io.string
|
101
|
+
else
|
102
|
+
io.rewind
|
103
|
+
io.read
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# Fires off a child process, feeds it, and keeps track of the results.
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require 'json'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'shrimple/process_monitor'
|
7
|
+
|
8
|
+
|
9
|
+
class Shrimple
|
10
|
+
class Process
|
11
|
+
attr_reader :start_time, :stop_time # start and finish times of Phantom process
|
12
|
+
|
13
|
+
# runs cmd, passes instr on its stdin, and fills outio and
|
14
|
+
# errio with the command's output.
|
15
|
+
def initialize cmd, inio, outio, errio, timeout=nil
|
16
|
+
@start_time = Time.now
|
17
|
+
@chin, @chout, @cherr, @child = Open3.popen3(*cmd)
|
18
|
+
|
19
|
+
Shrimple.processes._add(self)
|
20
|
+
@chout.binmode
|
21
|
+
|
22
|
+
@killed = false
|
23
|
+
@timed_out = false
|
24
|
+
|
25
|
+
@thrin = Thread.new { drain(inio, @chin) }
|
26
|
+
@throut = Thread.new { drain(@chout, outio) }
|
27
|
+
@threrr = Thread.new { drain(@cherr, errio) }
|
28
|
+
|
29
|
+
# ensure cleanup is called when the child exits. (strange it requires a whole new thread...?)
|
30
|
+
@thrchild = Thread.new {
|
31
|
+
if timeout
|
32
|
+
outatime unless @child.join(timeout)
|
33
|
+
else
|
34
|
+
@child.join
|
35
|
+
end
|
36
|
+
stop
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
def finished?
|
42
|
+
@stop_time != nil
|
43
|
+
end
|
44
|
+
|
45
|
+
# returns false if the process hasn't finished yet
|
46
|
+
def success?
|
47
|
+
finished? && @child.value.success? ? true : false
|
48
|
+
end
|
49
|
+
|
50
|
+
def killed?
|
51
|
+
@killed
|
52
|
+
end
|
53
|
+
|
54
|
+
def timed_out?
|
55
|
+
@timed_out
|
56
|
+
end
|
57
|
+
|
58
|
+
# kill-o-zaps the phantom process now (using -9 if needed), then waits until it's truly gone
|
59
|
+
def kill seconds_until_panic=2
|
60
|
+
@killed = true
|
61
|
+
if @child.alive?
|
62
|
+
# rescue because process might have died between previous line and this one
|
63
|
+
::Process.kill("TERM", @child.pid) rescue Errno::ESRCH
|
64
|
+
end
|
65
|
+
if !@child.join(seconds_until_panic)
|
66
|
+
::Process.kill("KILL", @child.pid) if @child.alive?
|
67
|
+
end
|
68
|
+
# ensure kill doesn't return until process is truly gone
|
69
|
+
# (there may be a chance of this deadlocking with a blocking callback... not sure)
|
70
|
+
@thrchild.join unless Thread.current == @thrchild
|
71
|
+
end
|
72
|
+
|
73
|
+
# waits patiently until phantom process terminates, then cleans up
|
74
|
+
def stop
|
75
|
+
wait_for_the_end # do all our waiting outside the sync loop
|
76
|
+
Shrimple.processes._remove(self) do
|
77
|
+
_cleanup
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
# only meant to be used by the ProcessMonitor
|
83
|
+
def _child_thread
|
84
|
+
@child
|
85
|
+
end
|
86
|
+
|
87
|
+
# may only be called once, synchronized by stop()
|
88
|
+
def _cleanup
|
89
|
+
raise "Someone else already stopped this process??!!" if @stop_time
|
90
|
+
@stop_time = Time.now
|
91
|
+
end
|
92
|
+
|
93
|
+
# returns true if process was previously active. must be externally synchronized.
|
94
|
+
def _deactivate
|
95
|
+
retval = @inactive
|
96
|
+
@inactive = true
|
97
|
+
return !retval
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
private
|
102
|
+
def wait_for_the_end
|
103
|
+
[@thrin, @throut, @threrr, @child].each(&:join)
|
104
|
+
@thrchild.join unless Thread.current == @thrchild
|
105
|
+
end
|
106
|
+
|
107
|
+
def outatime
|
108
|
+
@timed_out = true
|
109
|
+
kill
|
110
|
+
end
|
111
|
+
|
112
|
+
# reads every last drop, then closes both files. must be threadsafe.
|
113
|
+
def drain reader, writer
|
114
|
+
begin
|
115
|
+
# randomly chosen buffer size
|
116
|
+
loop { writer.write(reader.readpartial(256*1024)) }
|
117
|
+
rescue EOFError
|
118
|
+
# not an error
|
119
|
+
# puts "EOF STDOUT" if reader == @chout
|
120
|
+
# puts "EOF STDERR" if reader == @cherr
|
121
|
+
# puts "EOF STDIN #{reader}" if writer == @chin
|
122
|
+
rescue Errno::EPIPE
|
123
|
+
# child was killed, no problem
|
124
|
+
ensure
|
125
|
+
reader.close
|
126
|
+
writer.close rescue Errno::EPIPE
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# keeps track of running Shrimple processes
|
2
|
+
|
3
|
+
require 'thwait'
|
4
|
+
|
5
|
+
|
6
|
+
class Shrimple
|
7
|
+
class TooManyProcessesError < StandardError; end
|
8
|
+
|
9
|
+
class ProcessMonitor
|
10
|
+
attr_accessor :max_processes
|
11
|
+
|
12
|
+
# pass 0 to disable max_processes
|
13
|
+
def initialize(max_processes=20)
|
14
|
+
@mutex ||= Mutex.new
|
15
|
+
@processes ||= [] # TODO: convert this to a hash by child thread?
|
16
|
+
@max_processes = max_processes
|
17
|
+
end
|
18
|
+
|
19
|
+
def first
|
20
|
+
@mutex.synchronize do
|
21
|
+
@processes.first
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def count
|
26
|
+
@mutex.synchronize do
|
27
|
+
@processes.count
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def kill_all
|
32
|
+
while f = first
|
33
|
+
f.kill
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# blocks until any child process returns (unless nonblock is true)
|
38
|
+
# raises an exception if no processes are running, or if called nonblocking
|
39
|
+
# and no processes have finished (see ThreadsWait#next_wait for details).
|
40
|
+
def wait_next nonblock=nil
|
41
|
+
# we wait on child threads since calling waitpid would produce a race condition.
|
42
|
+
|
43
|
+
threads = {}
|
44
|
+
@processes.each { |p|
|
45
|
+
threads[p._child_thread] = p
|
46
|
+
}
|
47
|
+
|
48
|
+
thread = ThreadsWait.new(threads.keys).next_wait(nonblock)
|
49
|
+
process = threads[thread]
|
50
|
+
process.stop # otherwise process will be in an indeterminite state
|
51
|
+
process
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
def _add process
|
56
|
+
@mutex.synchronize do
|
57
|
+
if @max_processes >= 0 && @processes.count >= @max_processes
|
58
|
+
raise Shrimple::TooManyProcessesError.new("launched process #{@processes.count+1} of #{@max_processes} maximum")
|
59
|
+
end
|
60
|
+
@processes.push process
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# removes process from process table. pass a block that cleans up after the process.
|
65
|
+
# _remove may be called lots of times but block will only be called once
|
66
|
+
def _remove process
|
67
|
+
cleanup = false
|
68
|
+
|
69
|
+
@mutex.synchronize do
|
70
|
+
cleanup = process._deactivate
|
71
|
+
raise "process not in process table??" if cleanup && !@processes.include?(process)
|
72
|
+
end
|
73
|
+
|
74
|
+
# don't want to hold mutex when calling callback because it might block
|
75
|
+
if cleanup
|
76
|
+
yield
|
77
|
+
@mutex.synchronize do
|
78
|
+
value = @processes.delete(process)
|
79
|
+
raise "someone else deleted process??" unless value
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
data/shrimple.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'shrimple'
|
3
|
+
s.version = '0.8.0'
|
4
|
+
s.authors = ['Scott Bronson']
|
5
|
+
s.email = ['brons_shrimple@rinspin.com']
|
6
|
+
s.homepage = 'http://github.com/bronson/shrimple'
|
7
|
+
s.summary = 'A simple Ruby interface to PhantomJS'
|
8
|
+
s.description = 'Use PhantomJS to generate PDFs, PNGs, text files, etc.'
|
9
|
+
s.license = 'MIT'
|
10
|
+
|
11
|
+
s.files = `git ls-files -z`.split("\x0")
|
12
|
+
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
13
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
14
|
+
s.require_paths = ['lib']
|
15
|
+
|
16
|
+
s.add_runtime_dependency 'hashie'
|
17
|
+
|
18
|
+
s.add_development_dependency 'rake'
|
19
|
+
s.add_development_dependency 'rspec'
|
20
|
+
s.add_development_dependency 'dimensions'
|
21
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
// ensures config correctly arrives via stdin
|
2
|
+
//
|
3
|
+
// reads json from stdin, adds "processed: true", and writes it to stdout.
|
4
|
+
|
5
|
+
var system = require('system')
|
6
|
+
|
7
|
+
config = JSON.parse(system.stdin.read())
|
8
|
+
config.processed = true
|
9
|
+
console.log(JSON.stringify(config))
|
10
|
+
phantom.exit(0)
|
@@ -0,0 +1,93 @@
|
|
1
|
+
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Shrimple::Phantom do
|
5
|
+
it "doesn't create a config file if no options are set" do
|
6
|
+
s = Shrimple.new(executable: ['sleep', '1'], background: true)
|
7
|
+
|
8
|
+
phantom = s.render('/dev/null')
|
9
|
+
expect(phantom.config).to eq nil
|
10
|
+
phantom.kill
|
11
|
+
|
12
|
+
expect(phantom.config).to eq nil
|
13
|
+
expect(phantom.stdout).to eq ""
|
14
|
+
expect(phantom.stderr).to eq ""
|
15
|
+
end
|
16
|
+
|
17
|
+
it "creates a config file when there are config options and cleans on kill" do
|
18
|
+
s = Shrimple.new(executable: ['sleep', '1'], background: true)
|
19
|
+
s.config.ignoreSslErrors = true
|
20
|
+
|
21
|
+
phantom = s.render('infile')
|
22
|
+
expect(phantom.config).to be_a Tempfile
|
23
|
+
path = phantom.config.path
|
24
|
+
expect(File).to exist(path)
|
25
|
+
config = File.read(path)
|
26
|
+
phantom.kill
|
27
|
+
|
28
|
+
expect(File).not_to exist(path)
|
29
|
+
expect(JSON.parse(config)).to eq ({'ignoreSslErrors' => true})
|
30
|
+
expect(phantom.stdout).to eq ""
|
31
|
+
expect(phantom.stdout).to eq ""
|
32
|
+
expect(phantom.stderr).to eq ""
|
33
|
+
end
|
34
|
+
|
35
|
+
it "cleans up the config file when exiting normally" do
|
36
|
+
s = Shrimple.new(executable: ['/bin/cat'], background: true)
|
37
|
+
s.config.ignoreSslErrors = true
|
38
|
+
|
39
|
+
rd,wr = IO.pipe
|
40
|
+
phantom = s.render(stdin: rd)
|
41
|
+
|
42
|
+
expect(phantom.config).to be_a Tempfile
|
43
|
+
path = phantom.config.path
|
44
|
+
expect(File).to exist(path)
|
45
|
+
wr.write("done.\n")
|
46
|
+
wr.close
|
47
|
+
phantom.stop
|
48
|
+
|
49
|
+
expect(File).not_to exist(path)
|
50
|
+
expect(phantom.stdout).to eq "done.\n"
|
51
|
+
end
|
52
|
+
|
53
|
+
it "times out when running in the foreground" do
|
54
|
+
s = Shrimple.new(executable: ['sleep', '10'], timeout: 0)
|
55
|
+
expect {
|
56
|
+
phantom = s.render('/dev/null')
|
57
|
+
}.to raise_exception(Shrimple::TimedOut)
|
58
|
+
end
|
59
|
+
|
60
|
+
it "times out when running in the background" do
|
61
|
+
s = Shrimple.new(executable: ['sleep', '10'], background: true, timeout: 0)
|
62
|
+
phantom = s.render('/dev/null')
|
63
|
+
Shrimple.processes.wait_next
|
64
|
+
expect(phantom.timed_out?).to eq true
|
65
|
+
expect(phantom.killed?).to eq true
|
66
|
+
expect(phantom.success?).to eq false
|
67
|
+
end
|
68
|
+
|
69
|
+
it "can call multiple callbacks from the same renderer" do
|
70
|
+
success = 0
|
71
|
+
failure = 0
|
72
|
+
s = Shrimple.new(executable: ['cat'])
|
73
|
+
s.onSuccess = Proc.new { |result| success += 1 }
|
74
|
+
s.onError = Proc.new { |result| failure += 1 }
|
75
|
+
s.render('/dev/null')
|
76
|
+
s.render('/dev/null')
|
77
|
+
s.render('/dev/null')
|
78
|
+
s.render('/dev/null')
|
79
|
+
expect(success).to eq 4
|
80
|
+
expect(failure).to eq 0
|
81
|
+
end
|
82
|
+
|
83
|
+
it "can read partial string contents while writing" do
|
84
|
+
# ensure writes still go on the end of the buffer after reading
|
85
|
+
# pending
|
86
|
+
end
|
87
|
+
|
88
|
+
it "can read partial file contents while writing" do
|
89
|
+
# ensure writes still go on the end of the buffer after reading
|
90
|
+
# pending
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|