/
subspawn.rb
361 lines (346 loc) · 11.4 KB
/
subspawn.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
require 'ffi'
require 'subspawn/version'
require 'subspawn/fd_parse'
if FFI::Platform.unix?
require 'subspawn/posix'
SubSpawn::Platform = SubSpawn::POSIX
elsif FFI::Platform.windows?
require 'subspawn/win32'
SubSpawn::Platform = SubSpawn::Win32
else
raise "Unknown FFI platform"
end
require 'subspawn/common'
module SubSpawn
# Parse and convert the weird Ruby spawn API into something nicer
def self.__compat_parser(is_popen, command, command2)
delta_env = nil
# check for env
if command.respond_to? :to_hash
delta_env = command.to_hash
command = command2
else # 2-arg ctor
command = [command] + command2
end
opt = {}
if command.last.respond_to? :to_hash
*command, opt = *command
end
if command.first.is_a? Array and command.first.length != 2
raise ArgumentError, "First argument must be an pair TODO: check this"
end
popen = if is_popen && command.length > 1
command.pop
end
raise ArgumentError, "Must provide a command to execute" if command.empty?
raise ArgumentError, "Must provide options as a hash" unless opt.is_a? Hash
if opt.key? :env and delta_env
# TODO: warn?
raise SpawnError, "SubSpawn.spawn_compat doesn't allow :env key, try SubSpawn.spawn instead"
# unsupported
else
opt[:env] = delta_env if delta_env
end
copt = {:__ss_compat => true }
copt[:__ss_compat_testing] = opt.delete(:__ss_compat_testing)
begin
cf = nil
if command.length == 1 and (cf = command.first).respond_to? :to_str
# and ((cf = cf.to_str).include? " " or (Internal.which(cmd)))
#command = ["sh", "-c", cf] # TODO: refactor
command = [command.first.to_str]
copt[:__ss_compat_shell] = true
end
rescue NoMethodError => e # by spec
raise TypeError.new(e)
end
return [popen, command, opt, copt]
end
# Parse and convert the weird Ruby spawn API into something nicer
def self.spawn_compat(command, *command2)
#File.write('/tmp/spawn.trace', [command, *command2].inspect + "\n", mode: 'a+')
__spawn_internal(*__compat_parser(false, command, command2)[1..-1]).first
end
# TODO: accept block mode?
def self.spawn(command, opt={})
__spawn_internal(command, opt, {})
end
def self.spawn_shell(command, opt={})
__spawn_internal(Platform.shell_command(command), opt, {})
end
def self.__spawn_internal(command, opt, copt)
unless command.respond_to? :to_ary # TODO: fix this check up with new parsing
raise ArgumentError, "First argument must be an array" unless command.is_a? String
# not the cleanest check, but should be better than generic exec errors
raise SpawnError, "SubSpawn only accepts arrays #LINK TODO" if command.include? " "
command = [command]
else
command = command.to_ary.dup
end
unless opt.respond_to? :to_hash # TODO: fix this check up with new parsing
raise ArgumentError, "Second argument must be a hash, did you mean to use spawn([#{command.inspect}, #{opt.inspect}]) ?"
end
fds = []
env_opts = {base: ENV, set: false, deltas: nil, only: false}
begin
if command.first.respond_to? :to_ary
warn "argv0 and array syntax both provided to SubSpawn. Preferring argv0" if opt[:argv0]
command[0], tmp = *command.first.to_ary.map(&:to_str) # by spec
opt[:argv0] = opt[:argv0] || tmp
end
command = command.map(&:to_str) # by spec
rescue NoMethodError => e # by spec
raise TypeError.new(e)
end
arg0 = command.first
raise ArgumentError, "Cannot spawn with null bytes: OS uses C-style strings" if command.any? {|x|x.include? "\0"}
base = SubSpawn::Platform.new(*command, arg0: (opt[:argv0] || arg0).to_s)
opt.each do |key, value|
case key
when Array # P.s
fds << [key,value]
# TODO: ,:output, :input, :error, :stderr, :stdin, :stdout, :pty, :tty ?
when Integer, IO, :in, :out, :err # P.s: in, out, err, IO, Integer
fds << [[key], value]
# TODO: , :cwd
when :chdir # P.s: :chdir
base.cwd = value.respond_to?(:to_path) ? value.to_path : value
when :tty, :pty
if value == :tty || value == :pty
fds << [[key], value] # make a new pty this way
else
base.tty = value
#base.sid!# TODO: yes? no?
end
when :sid
if base.respond_to? :sid!
base.sid! if value
else
warn "SubSpawn Platform (#{base.class}) doesn't support 'sid'"
end
when :env
if env_opts[:deltas]
warn "Provided multiple ENV options"
end
env_opts[:deltas] = value
env_opts[:set] ||= value != nil
when :setenv, :set_env, :env=
if env_opts[:deltas]
warn "Provided multiple ENV options"
end
env_opts[:deltas] = env_opts[:base] = value
env_opts[:set] = value != nil
env_opts[:only] = true
# Difference: new_pgroup is linux too?
when :pgroup, :new_pgroup, :process_group # P.s: pgroup, :new_pgroup
raise TypeError, "pgroup must be boolean or integral" if value.is_a? Symbol
base.pgroup = value == true ? 0 : value if value
when :signal_mask # TODO: signal_default
if base.respond_to? :signal_mask
base.signal_mask(value)
else
warn "SubSpawn Platform (#{base.class}) doesn't support 'signal_mask'"
end
when /rlimit_(.*)/ # P.s
unless base.respond_to? :rlimit
warn "SubSpawn Platform (#{base.class}) doesn't support 'rlimit_*'"
else
name = $1
keys = [value].flatten
base.rlimit(name, *keys)
end
when /w32_(.*)/ # NEW
name = $1
raise ArgumentError, "Unknown win32 argument: #{name}" unless %w{desktop title show_window window_pos window_size console_size window_fill start_flags}.include? name
unless base.respond_to? :name
warn "SubSpawn Platform (#{base.class}) doesn't support 'w32_#{$1}'"
else
base.send(name, *value)
end
when :rlimit # NEW?
raise ArgumentError, "rlimit as a hash must be a hash" unless value.respond_to? :to_h
unless base.respond_to? :rlimit
warn "SubSpawn Platform (#{base.class}) doesn't support 'rlimit_*'"
else
value.to_h.each do |key, values|
base.rlimit(key, *[values].flatten)
end
end
when :umask # P.s
raise ArgumentError, "umask must be numeric" unless value.is_a? Integer
unless base.respond_to? :umask
warn "SubSpawn Platform (#{base.class}) doesn't support 'umask'"
else
base.umask = value
end
when :unsetenv_others # P.s
env_opts[:only] = !!value
env_opts[:set] ||= !!value
when :close_others # P.s
warn "CLOEXEC is set by default, :close_others is a no-op in SubSpawn.spawn call. Consider :keep"
when :argv0
# Alraedy processed
else
# TODO: exception always?
if copt[:__ss_compat]
raise ArgumentError, "Unknown SubSpawn argument #{key.inspect}. Ignoring"
else
warn "Unknown SubSpawn argument #{key.inspect}. Ignoring"
end
end
end
working_env = if env_opts[:set]
base.env = if env_opts[:only]
env_opts[:deltas].to_hash
else
env_opts[:base].to_hash.merge(env_opts[:deltas].to_hash)
end.to_h
else
ENV
end
# now that we have the working env, we can finally update the command
unless copt[:__ss_compat_testing]
if copt[:__ss_compat_shell] && Internal.which(command.first, working_env).nil? && command.first.include?(" ") # ruby specs don't allow builtins, apparently
command = Platform.shell_command(command.first)
base.args = command[1..-1]
base.command = base.name = command.first
end
newcmd = Internal.which(command.first, working_env)
# if newcmd is null, let the systemerror shine from below
if command.first!= "" && !newcmd.nil? && newcmd != command.first
base.command = newcmd
end
end
# parse and clean up fd descriptors
fds = Internal.parse_fd_opts(fds) {|path| base.tty = path }
# now make a graph and add temporaries
ordering = Internal.graph_order(fds)
# configure them in order, saving new io descriptors
created_pipes = ordering.flat_map do |fd|
result = fd.apply(base)
fd.all_dests.map{|x| [x, result] }
end.to_h
# Spawn and return any new pipes
[base.spawn!, IoHolder.new(created_pipes)]
end
def self.pty_spawn_compat(*args, &block)
pty_spawn(args, &block)
end
def self.pty_spawn(args, opts={}, &block)
# TODO: setsid?
# TODO: MRI tries to pull the shell out of the ENV var, but that seems wrong
pid, args = SubSpawn.spawn(args, {[:in, :out, :err, :tty] => :pty, :sid => true}.merge(opts))
tty = args[:tty]
list = [tty, tty, pid]
return list unless block_given?
begin
return block.call(*list)
ensure
tty.close unless tty.closed?
# MRI waits this way to ensure the process is reaped
if Process.waitpid(pid, Process::WNOHANG).nil?
Process.detach(pid)
end
end
end
def self.popen(command, mode="r", opt={}, &block)
#Many modes, and "-" is not supported at this time
__popen_internal(command, mode, opt, {}, &block)
end
def self.popen_compat(command, *command2, &block)
#Many modes, and "-" is not supported at this time
mode, command, opt, copt = __compat_parser(true, command, command2)
mode ||= "r"
__popen_internal(command, mode, opt, copt, &block)
end
#Many modes, and "-" is not supported at this time
def self.__popen_internal(command, mode, opt, copt, &block)
outputs = {}
# parse, but ignore irrelevant bits
parsed = Internal.modestr_parse(mode) & (~(IO::TRUNC | IO::CREAT | IO::APPEND | IO::EXCL))
looking = if parsed & IO::WRONLY != 0
outputs[:in] = :pipe
looking = [:in]
elsif parsed & IO::RDWR != 0
outputs[:out] = :pipe
outputs[:in] = :pipe
looking = [:out, :in] # read, write, from our POV
else # read only
outputs[:out] = :pipe
looking = [:out]
end
# do normal spawning. Note: we only chose the internal spawn for popen_compat
pid, rawio = __spawn_internal(command, outputs.merge(opt), copt)
# create a proxy to close the process
io_proxy = looking.length == 1 ? SubSpawn::Common::ClosableIO : SubSpawn::Common::BidiMergedIOClosable
io = io_proxy.new(*looking.map{|x|rawio[x]}) do
# MRI waits this way to ensure the process is reaped
Process.waitpid(pid) # TODO: I think there isn't a WNOHANG here
end
# return or call
return io unless block_given?
begin
return yield(io)
ensure
io.close unless io.closed?
# MRI waits this way to ensure the process is reaped
if Process.waitpid(pid, Process::WNOHANG).nil?
Process.detach(pid)
end
end
end
# Windows doesn't like mixing and matching who is spawning and who is waiting, so use
# subspawn.wait* if you used subspawn.spawn*, while using process.wait* if you used Process.spawn*
# though if you replace process, then it's a moot point
if SubSpawn::Platform.method_defined? :waitpid2
def self.wait(*args)
waitpid *args
end
def self.waitpid(*args)
waitpid2(*args)&.first
end
def self.wait2(*args)
waitpid2 *args
end
def self.waitpid2(*args)
SubSpawn::Platform.waitpid2 *args
end
def self.last_status
SubSpawn::Platform.last_status
end
else
def self.wait(*args)
Process.wait *args
end
def self.waitpid(*args)
Process.waitpid *args
end
def self.wait2(*args)
Process.wait2 *args
end
def self.waitpid2(*args)
Process.waitpid2 *args
end
def self.last_status
Process.last_status
end
end
def self.detach(pid)
Thread.new do
pid, status = *SubSpawn.waitpid2(pid)
# TODO: ensure this loop isn't necessary
# while pid.nil?
# sleep 0.01
# pid, status = *SubSpawn.waitpid2(pid)
# end
status
end.tap do |thr|
thr[:pid] = pid
# TODO: does thread.pid need to exist?
end
end
COMPLETE_VERSION = {
subspawn: SubSpawn::VERSION,
platform: SubSpawn::Platform::COMPLETE_VERSION,
}
end