subspawn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|