subspawn 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +6 -0
- data/Gemfile +10 -0
- data/README.md +32 -0
- data/Rakefile +8 -0
- data/lib/subspawn/fd_parse.rb +148 -0
- data/lib/subspawn/fd_types.rb +180 -0
- data/lib/subspawn/graph_helper.rb +152 -0
- data/lib/subspawn/pipes.rb +49 -0
- data/lib/subspawn/replace-builtin.rb +30 -0
- data/lib/subspawn/replace-pty.rb +40 -0
- data/lib/subspawn/replace.rb +2 -0
- data/lib/subspawn/version.rb +5 -0
- data/lib/subspawn.rb +221 -0
- metadata +86 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8517f5eae58feb737016bbdadf7a5fc6df22226834e7531e135a13549fbeb6c0
|
4
|
+
data.tar.gz: f57039d8c089adf0859ebea1123c7f216f8955028f2463b7204c04cb43f09272
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0d53923840da47ec6454de99ae34a1c9181a1baf0c4c25cef0e1763facb836c4b910e3bc47c7360f04ab26298dff2fdda994a274a3061b1ae707fb3e443ca0c3
|
7
|
+
data.tar.gz: '020817f64ac627bfe26bb044945f5d1cf2770ef5200270a95a501fba0cd7d2d2452f25ebd5a40d4fa3267d2736d3ebf8c7c9fcf34dee7bbb175b8eeaf514176e'
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# SubSpawn Gem (High-level API)
|
2
|
+
|
3
|
+
Native process launching. See parent SubSpawn readme for details
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install the gem and add to the application's Gemfile by executing:
|
8
|
+
|
9
|
+
$ bundle add subspawn subspawn-posix
|
10
|
+
|
11
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
12
|
+
|
13
|
+
$ gem install subspawn subspawn-posix
|
14
|
+
|
15
|
+
## Usage
|
16
|
+
|
17
|
+
```rb
|
18
|
+
require 'subspawn'
|
19
|
+
|
20
|
+
Subspawn.spawn(["ls", "/"])
|
21
|
+
|
22
|
+
# or to augment the built-in ruby methoda
|
23
|
+
require 'subspawn/replace'
|
24
|
+
|
25
|
+
Process.spawn("ls", "/", :setsid=> true)
|
26
|
+
PTY.spawn(...)
|
27
|
+
PTY.subspawn(..., options...)
|
28
|
+
```
|
29
|
+
|
30
|
+
## Development
|
31
|
+
|
32
|
+
See parent SubSpawn readme
|
data/Rakefile
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
require_relative './fd_types'
|
2
|
+
require_relative './graph_helper'
|
3
|
+
require_relative './pipes'
|
4
|
+
|
5
|
+
module SubSpawn::Internal
|
6
|
+
# argument value to int (or :tty)
|
7
|
+
def self.parse_fd(fd, allow_pty=false)
|
8
|
+
# fd = if fd.respond_to? :to_path
|
9
|
+
# fd = if fd.respond_to? :to_file
|
10
|
+
# fd = if fd.respond_to? :to_file
|
11
|
+
case fd
|
12
|
+
when Integer then fd
|
13
|
+
when IO then fd.fileno
|
14
|
+
when :in, :input, :stdin then 0
|
15
|
+
when :out, :output, :stdout then 1
|
16
|
+
when :err, :error, :stderr then 2
|
17
|
+
else
|
18
|
+
if allow_pty and %i{pty tty}.include? fd
|
19
|
+
:tty
|
20
|
+
else
|
21
|
+
raise ArgumentError, "Unknown FD type: #{fd.inspect}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# mode conversion
|
27
|
+
def self.guess_mode(d)
|
28
|
+
read = d.include? 0 # stdin
|
29
|
+
write = (d.include? 1 or d.include? 2) # stdout
|
30
|
+
if read && write
|
31
|
+
raise ArgumentError, "Invalid FD source specification: ambiguious mode (r & w)"
|
32
|
+
elsif read
|
33
|
+
:read
|
34
|
+
elsif write
|
35
|
+
:write
|
36
|
+
else
|
37
|
+
raise ArgumentError, "Invalid FD source specification: ambiguious mode (stdio missing)"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# make FdSource objects of each redirection
|
42
|
+
def self.parse_fd_opts(fds, &settty)
|
43
|
+
child_lookup = {}
|
44
|
+
fds.map do |dests, src|
|
45
|
+
d = dests.map{|x| parse_fd(x, true)} # TODO: configurable
|
46
|
+
src = case src
|
47
|
+
when Array
|
48
|
+
case src.first
|
49
|
+
when String # file
|
50
|
+
#raise ArgumentError, "Invalid :child FD source specification" unless src.length == 2
|
51
|
+
# TODO: validate
|
52
|
+
FdSource::File.new(d, *src)
|
53
|
+
when :child
|
54
|
+
raise ArgumentError, "Invalid :child FD source specification" unless src.length == 2
|
55
|
+
# {a => c, b => [child, a]} is the same as {[a, b] => c}
|
56
|
+
# so we can transform the former into the latter
|
57
|
+
newfd = parse_fd(src.last)
|
58
|
+
# TODO: this isn't an error, create a new one
|
59
|
+
raise ArgumentError, "Invalid :child FD source specification" unless child_lookup[newfd]
|
60
|
+
child_lookup[newfd].tap{|child|
|
61
|
+
# add our destinations to the source's redirection
|
62
|
+
d.each { |di| child.dests << di }
|
63
|
+
}
|
64
|
+
else
|
65
|
+
raise ArgumentError, "Invalid FD source specification"
|
66
|
+
end
|
67
|
+
when String
|
68
|
+
FdSource::File.new d, src, ({read: IO::RDONLY, write: IO::WRONLY | IO::CREAT | IO::TRUNC}[guess_mode(d)])
|
69
|
+
when :close
|
70
|
+
FdSource::Close.new d
|
71
|
+
when :pty, :tty
|
72
|
+
FdSource::PTY.new d
|
73
|
+
when :pipe
|
74
|
+
FdSource::Pipe.new d, guess_mode(d)
|
75
|
+
when :pipe_r
|
76
|
+
FdSource::Pipe.new d, :read # TODO: ensure pipe direction is sensical
|
77
|
+
when :pipe_w
|
78
|
+
FdSource::Pipe.new d, :read
|
79
|
+
else
|
80
|
+
if d.include? :tty and src.is_a? File # TODO: is this redundant?
|
81
|
+
settty.call(src.path)
|
82
|
+
d.delete(:tty)
|
83
|
+
end
|
84
|
+
FdSource::Basic.new d, parse_fd(src)
|
85
|
+
end
|
86
|
+
# save redirected fds so we can sneak a child reference in
|
87
|
+
src.tap{|x| d.each{|c|
|
88
|
+
raise ArgumentError, "Invalid FD source specification: duplicate FDs" if child_lookup[c]
|
89
|
+
child_lookup[c] = x
|
90
|
+
} }
|
91
|
+
end.reject(&:nil?)
|
92
|
+
end
|
93
|
+
|
94
|
+
# convert an unordered list of FdSource's into an ordered list with appropriate
|
95
|
+
# temporaries for a correct swap
|
96
|
+
def self.graph_order(fds)
|
97
|
+
# Note that we keep all dests together. It may be smarter to break dests apart to avoid
|
98
|
+
# temporaries, but that is left as an excercise to the reader
|
99
|
+
next_temp = [fds.map(&:max).max || 2, 2].max + 1
|
100
|
+
graph = Bigraph.new
|
101
|
+
# build graphs
|
102
|
+
fds.each do |fd|
|
103
|
+
from = fd.heads || [Object.new]
|
104
|
+
to = fd.tails || [Object.new]
|
105
|
+
from.zip(to) do |h, t|
|
106
|
+
graph.insert(h, t, fd)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
#puts graph.to_dot
|
110
|
+
# find and break cycles
|
111
|
+
while point = graph.find_cycle
|
112
|
+
# we probably could find the least-cost fd, but this will do for now
|
113
|
+
items = graph.delete_outgoing(point)
|
114
|
+
#p items
|
115
|
+
temp = FdSource::Temp.new([next_temp], point.to_i)
|
116
|
+
close = FdSource::Close.new([next_temp])
|
117
|
+
items = [temp, *items.map{|x|x.temp_source(next_temp)}, close]
|
118
|
+
#p items
|
119
|
+
# reinsert
|
120
|
+
items.each do |fd|
|
121
|
+
from = fd.heads || [Object.new]
|
122
|
+
to = fd.tails || [Object.new]
|
123
|
+
from.zip(to) do |h, t|
|
124
|
+
graph.insert(h, t, fd)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
#puts graph.to_dot
|
128
|
+
end
|
129
|
+
#puts "removed all cycles, hopeuflly"
|
130
|
+
elts = graph.ordered_kahn.reverse # execute in the opposite order of dependencies
|
131
|
+
#puts graph.to_dot
|
132
|
+
elts
|
133
|
+
end
|
134
|
+
|
135
|
+
# I'd love to use this method, but it doesn't accept env
|
136
|
+
# def self.which_jruby(cmd)
|
137
|
+
# require 'jruby'
|
138
|
+
# org.jruby.util.ShellLauncher.findPathExecutable(JRuby.runtime, cmd)&.absolute_path
|
139
|
+
# end
|
140
|
+
|
141
|
+
def self.which(cmd, env)
|
142
|
+
return nil if cmd.nil? or cmd.to_str == ""
|
143
|
+
SubSpawn::Platform.
|
144
|
+
expand_which(cmd, env).
|
145
|
+
lazy.
|
146
|
+
find {|x|!File.directory? x and File.executable? x}
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
module SubSpawn::Internal
|
2
|
+
class FdSource
|
3
|
+
def initialize(dests, all_dests=dests)
|
4
|
+
@dests = dests
|
5
|
+
@all_dests = all_dests
|
6
|
+
raise SpawnError, "Can't provide :tty in this source list" if dests.include? :tty
|
7
|
+
end
|
8
|
+
attr_reader :dests, :all_dests
|
9
|
+
|
10
|
+
def destroys? value
|
11
|
+
@dests.include? value
|
12
|
+
end
|
13
|
+
def depends? o
|
14
|
+
false
|
15
|
+
end
|
16
|
+
def before? o
|
17
|
+
depends? o
|
18
|
+
end
|
19
|
+
def max
|
20
|
+
@dests.max
|
21
|
+
end
|
22
|
+
def to_dbg
|
23
|
+
[self.class, @dests]
|
24
|
+
end
|
25
|
+
def heads
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
def tails
|
29
|
+
@dests
|
30
|
+
end
|
31
|
+
|
32
|
+
def raw_apply base, value
|
33
|
+
@dests.each {|dest|
|
34
|
+
base.fd(dest, value)
|
35
|
+
}
|
36
|
+
nil # TODO: return the io for basics? would need to cache the fds
|
37
|
+
end
|
38
|
+
|
39
|
+
# TODO: :tty in list shouldn't get here!
|
40
|
+
class Basic < FdSource
|
41
|
+
def initialize(dests, int)
|
42
|
+
super(dests)
|
43
|
+
@value = int
|
44
|
+
end
|
45
|
+
def max
|
46
|
+
[@dests.max, @value].max
|
47
|
+
end
|
48
|
+
def source
|
49
|
+
@value
|
50
|
+
end
|
51
|
+
def depend? o
|
52
|
+
o.dests.include? @value
|
53
|
+
end
|
54
|
+
def temp_source(new)
|
55
|
+
self.class.new(@dests, new)
|
56
|
+
end
|
57
|
+
def before? o
|
58
|
+
# we are before o if:
|
59
|
+
# o destroys any of our sources
|
60
|
+
# or we depend on o
|
61
|
+
depends? o or o.destroys? @value
|
62
|
+
end
|
63
|
+
def heads
|
64
|
+
[@value]
|
65
|
+
end
|
66
|
+
def tails
|
67
|
+
@dests
|
68
|
+
end
|
69
|
+
def to_dbg
|
70
|
+
[@dests, @value]
|
71
|
+
end
|
72
|
+
def apply base
|
73
|
+
raw_apply base, @value
|
74
|
+
end
|
75
|
+
end
|
76
|
+
class Temp < Basic
|
77
|
+
def tails
|
78
|
+
super.map{|x|Xchange.new(x)}
|
79
|
+
end
|
80
|
+
end
|
81
|
+
# class Child < Basic # I don't think this is any different?
|
82
|
+
# def to_dbg
|
83
|
+
# [@dests, :child, @value]
|
84
|
+
# end
|
85
|
+
# end
|
86
|
+
|
87
|
+
class Open < FdSource
|
88
|
+
end
|
89
|
+
|
90
|
+
class File < Open
|
91
|
+
def initialize(dests, file, mode = ::File::RDONLY, perm = 0o666)
|
92
|
+
super(dests)
|
93
|
+
@value = file
|
94
|
+
@mode = mode || ::File::RDONLY
|
95
|
+
if @mode.respond_to? :to_str
|
96
|
+
@mode = case @mode.to_str
|
97
|
+
when "w" then IO::WRONLY | IO::CREAT | IO::TRUNC
|
98
|
+
when "r" then IO::RDONLY
|
99
|
+
when "rb" then IO::RDONLY | IO::BINARY
|
100
|
+
when "wb" then IO::WRONLY | IO::BINARY | IO::CREAT | IO::TRUNC
|
101
|
+
when "r+", "w+" then IO::RDWR | IO::CREAT
|
102
|
+
# TODO: all!
|
103
|
+
else
|
104
|
+
raise ArgumentError "Unknown File mode"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
@perm = perm || 0o666
|
108
|
+
end
|
109
|
+
def to_dbg
|
110
|
+
[@dests, :file, @value, @mode, @perm]
|
111
|
+
end
|
112
|
+
def apply base
|
113
|
+
first, *rest = @dests
|
114
|
+
base.fd_open(first, @value, @mode, @perm)
|
115
|
+
rest.each {|dest| base.fd(dest, first) }
|
116
|
+
nil
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class Pipe < Open
|
121
|
+
def initialize(dests, dir)
|
122
|
+
super(dests)
|
123
|
+
@dir = dir
|
124
|
+
end
|
125
|
+
def apply base
|
126
|
+
@saved ||= IO.pipe
|
127
|
+
r, w = {read: @saved, write: @saved.reverse}[@dir]
|
128
|
+
raw_apply base, r
|
129
|
+
@dests.each {|dest| base.fd_close(w) } # if you want the other end, pass it in yourself
|
130
|
+
|
131
|
+
SubSpawn::IoHolder::Pipe.new(w, r)
|
132
|
+
end
|
133
|
+
#attr_reader :output
|
134
|
+
end
|
135
|
+
class PTY < Open
|
136
|
+
def initialize(dests)
|
137
|
+
tty, ntty = dests.partition{|x|x == :tty}
|
138
|
+
super(ntty, dests)
|
139
|
+
@settty = !tty.empty?
|
140
|
+
end
|
141
|
+
def apply base
|
142
|
+
# MRI doesn't do a chmod for PTY.spawn. Weird, but lets just copy that here
|
143
|
+
m,s = (@saved ||= SubSpawn::Platform::PtyHelper.open_internal)
|
144
|
+
base.fd_close(m) # if you want the master, pass it in yourself
|
145
|
+
raw_apply base, s
|
146
|
+
if @settty
|
147
|
+
base.tty = s
|
148
|
+
end
|
149
|
+
SubSpawn::IoHolder::PTY.new(m,s)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
class Close < FdSource
|
154
|
+
def to_dbg
|
155
|
+
[:close, @dests]
|
156
|
+
end
|
157
|
+
|
158
|
+
def heads
|
159
|
+
nil
|
160
|
+
end
|
161
|
+
def tails
|
162
|
+
@dests
|
163
|
+
end
|
164
|
+
def apply base
|
165
|
+
@dests.each {|dest| base.fd_close(dest) }
|
166
|
+
nil
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
Xchange = Struct.new(:fd)
|
171
|
+
class Xchange
|
172
|
+
def to_s
|
173
|
+
"xc_#{fd}"
|
174
|
+
end
|
175
|
+
def to_i
|
176
|
+
fd
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module SubSpawn::Internal
|
4
|
+
class Bigraph
|
5
|
+
def initialize
|
6
|
+
@fwd = {}
|
7
|
+
@rev = {}
|
8
|
+
end
|
9
|
+
def insert(head, tail, data)
|
10
|
+
node = BiNode.new(head, tail, data)
|
11
|
+
((@fwd[head] ||= {})[tail] ||= []) << node
|
12
|
+
((@rev[tail] ||= {})[head] ||= []) << node
|
13
|
+
end
|
14
|
+
def unmark!
|
15
|
+
@fwd.each do |h, ts|
|
16
|
+
ts.each do |t, lst|
|
17
|
+
lst.each {|n| n.marker = nil}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
def roots
|
22
|
+
unmark!
|
23
|
+
roots = []
|
24
|
+
@rev.each do |t, hs|
|
25
|
+
hs.each do |h, lst|
|
26
|
+
roots += lst unless @rev.key? h
|
27
|
+
end
|
28
|
+
end
|
29
|
+
roots
|
30
|
+
end
|
31
|
+
def roots_h
|
32
|
+
unmark!
|
33
|
+
roots = []
|
34
|
+
@rev.each do |t, hs|
|
35
|
+
hs.each do |h, lst|
|
36
|
+
roots << h unless @rev.key? h
|
37
|
+
end
|
38
|
+
end
|
39
|
+
roots.uniq
|
40
|
+
end
|
41
|
+
def leaves
|
42
|
+
unmark!
|
43
|
+
leaves = []
|
44
|
+
@fwd.each do |h, ts|
|
45
|
+
ts.each do |t, lst|
|
46
|
+
leaves += lst unless @fwd.key? t
|
47
|
+
end
|
48
|
+
end
|
49
|
+
leaves
|
50
|
+
end
|
51
|
+
def find_cycle
|
52
|
+
unmark!
|
53
|
+
# we have a forest, do for each forestry
|
54
|
+
rts = roots_h
|
55
|
+
if rts.empty? # probably have a loop
|
56
|
+
#puts "empt"
|
57
|
+
rts = [@fwd.keys.first]
|
58
|
+
end
|
59
|
+
at, rts = *rts
|
60
|
+
while at
|
61
|
+
#puts "searching at #{at} with #{rts}"
|
62
|
+
tmp = find_cycle_next(at)
|
63
|
+
return tmp unless tmp.nil?
|
64
|
+
at, rts = *rts
|
65
|
+
end
|
66
|
+
end
|
67
|
+
def find_cycle_next(start)
|
68
|
+
@fwd[start]&.each do |t, list|
|
69
|
+
#puts "cc = [#{start} -> #{t}]"
|
70
|
+
if list.any?(&:marker)
|
71
|
+
return t
|
72
|
+
end
|
73
|
+
list.each(&:mark)
|
74
|
+
tmp = find_cycle_next(t)
|
75
|
+
return tmp unless tmp.nil?
|
76
|
+
end
|
77
|
+
return nil
|
78
|
+
end
|
79
|
+
def delete_outgoing(head)
|
80
|
+
@fwd[head].flat_map{|t, k|delete(head, t)}
|
81
|
+
end
|
82
|
+
def delete(head, tail)
|
83
|
+
@fwd[head].delete(tail)
|
84
|
+
@rev[tail].delete(head).map(&:data)
|
85
|
+
end
|
86
|
+
def find_unmarked
|
87
|
+
@fwd.each do |h, ts|
|
88
|
+
ts.each do |t, lst|
|
89
|
+
return [h, t] unless lst.first.marker
|
90
|
+
end
|
91
|
+
end
|
92
|
+
return nil
|
93
|
+
end
|
94
|
+
def ordered_kahn
|
95
|
+
unmark!
|
96
|
+
# now traverse the tree until we hit a junction, ensuring
|
97
|
+
# dependencies are met
|
98
|
+
# Note this assumes you removed all the cycles from your code
|
99
|
+
# returns each graph seperately as a sort of forest
|
100
|
+
l = []
|
101
|
+
s = Set.new(roots_h)
|
102
|
+
|
103
|
+
until s.empty?
|
104
|
+
n = s.first
|
105
|
+
#puts "checking #{n} in #{s}"
|
106
|
+
s.delete(n)
|
107
|
+
@fwd[n]&.each do |e, ms|
|
108
|
+
next if ms.any?(&:marker) # already visited
|
109
|
+
ms.each(&:mark)
|
110
|
+
# no incoming edges
|
111
|
+
unless @rev[e]&.any?{|nh, lst| !lst.first.marker}
|
112
|
+
# nil -> add
|
113
|
+
# any(unmarked) true -> none
|
114
|
+
# all(marked) -> add
|
115
|
+
s << e
|
116
|
+
l += ms
|
117
|
+
#puts "blank incoming #{e}"
|
118
|
+
else
|
119
|
+
#puts "had incoming #{e}: #{@rev[e]}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
if find_unmarked#.tap{|x| p x, "um"}
|
125
|
+
puts to_dot
|
126
|
+
#raise "Unmarked graph edges, an unresolved cycle probably exists. This is a bug in SubSpawn"
|
127
|
+
nil
|
128
|
+
else
|
129
|
+
return l.map(&:data)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# for debugging
|
134
|
+
def to_dot
|
135
|
+
body = @fwd.map do |h, ts|
|
136
|
+
ts.map do |t, lst|
|
137
|
+
"\"#{h}\" -> \"#{t}\" ;" #{lst.first&.marker};"
|
138
|
+
end
|
139
|
+
end.join("\n")
|
140
|
+
return "digraph G {\n#{body}\n}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
class BiNode
|
144
|
+
def initialize(head, tail, data)
|
145
|
+
@head, @tail, @data, @marker = head, tail, data, nil
|
146
|
+
end
|
147
|
+
attr_accessor :head, :tail, :data, :marker
|
148
|
+
def mark
|
149
|
+
@marker = true
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
|
2
|
+
class SubSpawn::IoHolder
|
3
|
+
def initialize(map)
|
4
|
+
@map = map
|
5
|
+
end
|
6
|
+
|
7
|
+
def [](key)
|
8
|
+
tmp = get(key)
|
9
|
+
tmp = tmp.to_io if tmp.respond_to? :to_io
|
10
|
+
tmp
|
11
|
+
end
|
12
|
+
def get(key)
|
13
|
+
@map[SubSpawn::Internal.parse_fd(key, true)]
|
14
|
+
end
|
15
|
+
def composite? key
|
16
|
+
self.get(key).is_a? Composite
|
17
|
+
end
|
18
|
+
def empty?
|
19
|
+
@map.values.reject(&:nil?).empty?
|
20
|
+
end
|
21
|
+
|
22
|
+
class Composite
|
23
|
+
end
|
24
|
+
|
25
|
+
class Pipe < Composite
|
26
|
+
def initialize(parent, child)
|
27
|
+
@parent, @child = parent, child
|
28
|
+
end
|
29
|
+
attr_reader :parent, :child
|
30
|
+
alias :to_io :parent
|
31
|
+
def fileno
|
32
|
+
@parent.fileno
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class PTY < Composite
|
37
|
+
def initialize(master, slave)
|
38
|
+
@master, @slave = master, slave
|
39
|
+
end
|
40
|
+
attr_reader :master, :slave
|
41
|
+
alias :to_io :master
|
42
|
+
def to_ary
|
43
|
+
[@master, @slave]
|
44
|
+
end
|
45
|
+
def fileno
|
46
|
+
@master.fileno
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'subspawn'
|
2
|
+
|
3
|
+
module Kernel
|
4
|
+
class << self
|
5
|
+
alias :builtin_spawn :spawn
|
6
|
+
def spawn(*args)
|
7
|
+
SubSpawn.spawn_compat(*args)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
alias :builtin_spawn :spawn
|
13
|
+
def spawn(*args)
|
14
|
+
SubSpawn.spawn_compat(*args)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module Process
|
19
|
+
class << self
|
20
|
+
alias :builtin_spawn :spawn
|
21
|
+
|
22
|
+
def spawn(*args)
|
23
|
+
SubSpawn.spawn_compat(*args)
|
24
|
+
end
|
25
|
+
|
26
|
+
def subspawn(args, opt={})
|
27
|
+
SubSpawn.spawn(args, opt)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'subspawn'
|
2
|
+
|
3
|
+
$_ss_overwrite = defined? PTY
|
4
|
+
|
5
|
+
module PTY
|
6
|
+
unless $_ss_overwrite
|
7
|
+
class ChildExited < RuntimeError
|
8
|
+
def initialize(status)
|
9
|
+
@status = status
|
10
|
+
end
|
11
|
+
attr_reader :status
|
12
|
+
end
|
13
|
+
end
|
14
|
+
class << self
|
15
|
+
if $_ss_overwrite
|
16
|
+
alias :builtin_spawn :spawn
|
17
|
+
alias :builtin_getpty :getpty
|
18
|
+
end
|
19
|
+
|
20
|
+
def spawn(*args, &block)
|
21
|
+
SubSpawn.pty_spawn_compat(*args, &block)
|
22
|
+
end
|
23
|
+
def subspawn(command, opts={}, &block)
|
24
|
+
SubSpawn.pty_spawn(command, opts, &block)
|
25
|
+
end
|
26
|
+
alias :getpty :spawn
|
27
|
+
|
28
|
+
def open(&blk)
|
29
|
+
SubSpawn::Platform::PtyHelper.open(&blk)
|
30
|
+
end
|
31
|
+
|
32
|
+
def check(pid, do_raise=false)
|
33
|
+
return if Process.waitpid(pid, Process::WNOHANG | Process::WUNTRACED).nil?
|
34
|
+
return $? unless do_raise
|
35
|
+
raise ::PTY::ChildExited.new($?)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
$_ss_overwrite = nil
|
data/lib/subspawn.rb
ADDED
@@ -0,0 +1,221 @@
|
|
1
|
+
require 'ffi'
|
2
|
+
require 'subspawn/version'
|
3
|
+
require 'subspawn/fd_parse'
|
4
|
+
if FFI::Platform.unix?
|
5
|
+
require 'subspawn/posix'
|
6
|
+
SubSpawn::Platform = SubSpawn::POSIX
|
7
|
+
elsif FFI::Platform.windows?
|
8
|
+
raise "SubSpawn Win32 is not yet implemented"
|
9
|
+
else
|
10
|
+
raise "Unknown FFI platform"
|
11
|
+
end
|
12
|
+
|
13
|
+
module SubSpawn
|
14
|
+
# TODO: things to check: set $?
|
15
|
+
def self.spawn_compat(command, *command2)
|
16
|
+
#File.write('/tmp/spawn.trace', [command, *command2].inspect + "\n", mode: 'a+')
|
17
|
+
|
18
|
+
# return just the pid
|
19
|
+
delta_env = nil
|
20
|
+
# check for env
|
21
|
+
if command.respond_to? :to_hash
|
22
|
+
delta_env = command.to_hash
|
23
|
+
command = command2
|
24
|
+
else # 2-arg ctor
|
25
|
+
command = [command] + command2
|
26
|
+
end
|
27
|
+
opt = {}
|
28
|
+
if command.last.respond_to? :to_hash
|
29
|
+
*command, opt = *command
|
30
|
+
end
|
31
|
+
if command.first.is_a? Array and command.first.length != 2
|
32
|
+
raise ArgumentError, "First argument must be an pair TODO: check this"
|
33
|
+
end
|
34
|
+
raise ArgumentError, "Must provide a command to execute" if command.empty?
|
35
|
+
raise ArgumentError, "Must provide options as a hash" unless opt.is_a? Hash
|
36
|
+
if opt.key? :env and delta_env
|
37
|
+
# TODO: warn?
|
38
|
+
raise SpawnError, "SubSpawn.spawn_compat doesn't allow :env key, try SubSpawn.spawn instead"
|
39
|
+
# unsupported
|
40
|
+
else
|
41
|
+
opt[:env] = delta_env if delta_env
|
42
|
+
end
|
43
|
+
copt = {:__ss_compat => true }
|
44
|
+
copt[:__ss_compat_testing] = opt.delete(:__ss_compat_testing)
|
45
|
+
begin
|
46
|
+
cf = nil
|
47
|
+
if command.length == 1 and (cf = command.first).respond_to? :to_str
|
48
|
+
# and ((cf = cf.to_str).include? " " or (Internal.which(cmd)))
|
49
|
+
#command = ["sh", "-c", cf] # TODO: refactor
|
50
|
+
command = [command.first.to_str]
|
51
|
+
copt[:__ss_compat_shell] = true
|
52
|
+
end
|
53
|
+
rescue NoMethodError => e # by spec
|
54
|
+
raise TypeError.new(e)
|
55
|
+
end
|
56
|
+
SubSpawn.__spawn_internal(command, opt, copt).first
|
57
|
+
end
|
58
|
+
# TODO: accept block mode?
|
59
|
+
def self.spawn(command, opt={})
|
60
|
+
__spawn_internal(command, opt, {})
|
61
|
+
end
|
62
|
+
def self.spawn_shell(command, opt={})
|
63
|
+
__spawn_internal(Platform.shell_command(command), opt, {})
|
64
|
+
end
|
65
|
+
def self.__spawn_internal(command, opt, copt)
|
66
|
+
unless command.respond_to? :to_ary # TODO: fix this check up with new parsing
|
67
|
+
raise ArgumentError, "First argument must be an array" unless command.is_a? String
|
68
|
+
# not the cleanest check, but should be better than generic exec errors
|
69
|
+
raise SpawnError, "SubSpawn only accepts arrays #LINK TODO" if command.include? " "
|
70
|
+
command = [command]
|
71
|
+
else
|
72
|
+
command = command.to_ary.dup
|
73
|
+
end
|
74
|
+
unless opt.respond_to? :to_hash # TODO: fix this check up with new parsing
|
75
|
+
raise ArgumentError, "Second argument must be a hash, did you mean to use spawn([#{command.inspect}, #{opt.inspect}]) ?"
|
76
|
+
end
|
77
|
+
fds = []
|
78
|
+
env_opts = {base: ENV, set: false, deltas: nil, only: false}
|
79
|
+
begin
|
80
|
+
if command.first.respond_to? :to_ary
|
81
|
+
warn "argv0 and array syntax both provided to SubSpawn. Preferring argv0" if opt[:argv0]
|
82
|
+
command[0], tmp = *command.first.to_ary.map(&:to_str) # by spec
|
83
|
+
opt[:argv0] = opt[:argv0] || tmp
|
84
|
+
end
|
85
|
+
command = command.map(&:to_str) # by spec
|
86
|
+
rescue NoMethodError => e # by spec
|
87
|
+
raise TypeError.new(e)
|
88
|
+
end
|
89
|
+
arg0 = command.first
|
90
|
+
raise ArgumentError, "Cannot spawn with null bytes: OS uses C-style strings" if command.any? {|x|x.include? "\0"}
|
91
|
+
base = SubSpawn::Platform.new(*command, arg0: (opt[:argv0] || arg0).to_s)
|
92
|
+
opt.each do |key, value|
|
93
|
+
case key
|
94
|
+
when Array # P.s
|
95
|
+
fds << [key,value]
|
96
|
+
# TODO: ,:output, :input, :error, :stderr, :stdin, :stdout, :pty, :tty ?
|
97
|
+
when Integer, IO, :in, :out, :err # P.s: in, out, err, IO, Integer
|
98
|
+
fds << [[key], value]
|
99
|
+
# TODO: , :cwd
|
100
|
+
when :chdir # P.s: :chdir
|
101
|
+
base.cwd = value.respond_to?(:to_path) ? value.to_path : value
|
102
|
+
when :tty, :pty
|
103
|
+
if value == :tty || value == :pty
|
104
|
+
fds << [[key], value] # make a new pty this way
|
105
|
+
else
|
106
|
+
base.tty = value
|
107
|
+
#base.sid!# TODO: yes? no?
|
108
|
+
end
|
109
|
+
when :sid
|
110
|
+
base.sid! if value
|
111
|
+
when :env
|
112
|
+
if env_opts[:deltas]
|
113
|
+
warn "Provided multiple ENV options"
|
114
|
+
end
|
115
|
+
env_opts[:deltas] = value
|
116
|
+
env_opts[:set] ||= value != nil
|
117
|
+
when :setenv, :set_env, :env=
|
118
|
+
if env_opts[:deltas]
|
119
|
+
warn "Provided multiple ENV options"
|
120
|
+
end
|
121
|
+
env_opts[:deltas] = env_opts[:base] = value
|
122
|
+
env_opts[:set] = value != nil
|
123
|
+
env_opts[:only] = true
|
124
|
+
|
125
|
+
# Difference: new_pgroup is linux too?
|
126
|
+
when :pgroup, :new_pgroup, :process_group # P.s: pgroup, :new_pgroup
|
127
|
+
raise TypeError, "pgroup must be boolean or integral" if value.is_a? Symbol
|
128
|
+
base.pgroup = value == true ? 0 : value if value
|
129
|
+
when :signal_mask # TODO: signal_default
|
130
|
+
base.signal_mask(value)
|
131
|
+
when /rlimit_(.*)/ # P.s
|
132
|
+
name = $1
|
133
|
+
keys = [value].flatten
|
134
|
+
base.rlimit(name, *keys)
|
135
|
+
when :rlimit # NEW?
|
136
|
+
raise ArgumentError, "rlimit as a hash must be a hash" unless value.respond_to? :to_h
|
137
|
+
value.to_h.each do |key, values|
|
138
|
+
base.rlimit(key, *[values].flatten)
|
139
|
+
end
|
140
|
+
when :umask # P.s
|
141
|
+
raise ArgumentError, "umask must be numeric" unless value.is_a? Integer
|
142
|
+
base.umask = value
|
143
|
+
when :unsetenv_others # P.s
|
144
|
+
env_opts[:only] = !!value
|
145
|
+
env_opts[:set] ||= !!value
|
146
|
+
when :close_others # P.s
|
147
|
+
warn "CLOEXEC is set by default, :close_others is a no-op in SubSpawn.spawn call. Consider :keep"
|
148
|
+
when :argv0
|
149
|
+
# Alraedy processed
|
150
|
+
else
|
151
|
+
# TODO: exception always?
|
152
|
+
if copt[:__ss_compat]
|
153
|
+
raise ArgumentError, "Unknown SubSpawn argument #{key.inspect}. Ignoring"
|
154
|
+
else
|
155
|
+
warn "Unknown SubSpawn argument #{key.inspect}. Ignoring"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
working_env = if env_opts[:set]
|
160
|
+
base.env = if env_opts[:only]
|
161
|
+
env_opts[:deltas].to_hash
|
162
|
+
else
|
163
|
+
env_opts[:base].to_hash.merge(env_opts[:deltas].to_hash)
|
164
|
+
end.to_h
|
165
|
+
else
|
166
|
+
ENV
|
167
|
+
end
|
168
|
+
# now that we have the working env, we can finally update the command
|
169
|
+
unless copt[:__ss_compat_testing]
|
170
|
+
if copt[:__ss_compat_shell] && Internal.which(command.first, working_env).nil? && command.first.include?(" ") # ruby specs don't allow builtins, apparently
|
171
|
+
command = Platform.shell_command(command.first)
|
172
|
+
base.args = command[1..-1]
|
173
|
+
base.command = base.name = command.first
|
174
|
+
end
|
175
|
+
newcmd = Internal.which(command.first, working_env)
|
176
|
+
# if newcmd is null, let the systemerror shine from below
|
177
|
+
if command.first!= "" && !newcmd.nil? && newcmd != command.first
|
178
|
+
base.command = newcmd
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# parse and clean up fd descriptors
|
183
|
+
fds = Internal.parse_fd_opts(fds) {|path| base.tty = path }
|
184
|
+
# now make a graph and add temporaries
|
185
|
+
ordering = Internal.graph_order(fds)
|
186
|
+
# configure them in order, saving new io descriptors
|
187
|
+
created_pipes = ordering.flat_map do |fd|
|
188
|
+
result = fd.apply(base)
|
189
|
+
fd.all_dests.map{|x| [x, result] }
|
190
|
+
end.to_h
|
191
|
+
# Spawn and return any new pipes
|
192
|
+
[base.spawn!, IoHolder.new(created_pipes)]
|
193
|
+
end
|
194
|
+
|
195
|
+
def self.pty_spawn_compat(*args, &block)
|
196
|
+
pty_spawn(args, &block)
|
197
|
+
end
|
198
|
+
def self.pty_spawn(args, opts={}, &block)
|
199
|
+
# TODO: setsid?
|
200
|
+
# TODO: MRI tries to pull the shell out of the ENV var, but that seems wrong
|
201
|
+
pid, args = SubSpawn.spawn(args, {[:in, :out, :err, :tty] => :pty, :sid => true}.merge(opts))
|
202
|
+
tty = args[:tty]
|
203
|
+
list = [tty, tty, pid]
|
204
|
+
return list unless block_given?
|
205
|
+
|
206
|
+
begin
|
207
|
+
return block.call(*list)
|
208
|
+
ensure
|
209
|
+
tty.close unless tty.closed?
|
210
|
+
# MRI waits this way to ensure the process is reaped
|
211
|
+
if Process.waitpid(pid, Process::WNOHANG)
|
212
|
+
Process.detach(pid)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
COMPLETE_VERSION = {
|
218
|
+
subspawn: SubSpawn::VERSION,
|
219
|
+
platform: SubSpawn::Platform::COMPLETE_VERSION,
|
220
|
+
}
|
221
|
+
end
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: subspawn
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Patrick Plenefisch
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-11-11 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: subspawn-posix
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: ffi
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.0'
|
41
|
+
description: Advanced native subprocess spawning on MRI, JRuby, and TruffleRuby
|
42
|
+
email:
|
43
|
+
- simonpatp@gmail.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".rspec"
|
49
|
+
- Gemfile
|
50
|
+
- README.md
|
51
|
+
- Rakefile
|
52
|
+
- lib/subspawn.rb
|
53
|
+
- lib/subspawn/fd_parse.rb
|
54
|
+
- lib/subspawn/fd_types.rb
|
55
|
+
- lib/subspawn/graph_helper.rb
|
56
|
+
- lib/subspawn/pipes.rb
|
57
|
+
- lib/subspawn/replace-builtin.rb
|
58
|
+
- lib/subspawn/replace-pty.rb
|
59
|
+
- lib/subspawn/replace.rb
|
60
|
+
- lib/subspawn/version.rb
|
61
|
+
homepage: https://github.com/byteit101/subspawn
|
62
|
+
licenses: []
|
63
|
+
metadata:
|
64
|
+
homepage_uri: https://github.com/byteit101/subspawn
|
65
|
+
source_code_uri: https://github.com/byteit101/subspawn
|
66
|
+
changelog_uri: https://github.com/byteit101/subspawn
|
67
|
+
post_install_message:
|
68
|
+
rdoc_options: []
|
69
|
+
require_paths:
|
70
|
+
- lib
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 2.6.0
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
requirements: []
|
82
|
+
rubygems_version: 3.0.3.1
|
83
|
+
signing_key:
|
84
|
+
specification_version: 4
|
85
|
+
summary: Advanced native subprocess spawning
|
86
|
+
test_files: []
|