1require_relative '../../spec_helper'
2require_relative 'fixtures/common'
3
4newline = "\n"
5platform_is :windows do
6  newline = "\r\n"
7end
8
9describe :process_spawn_does_not_close_std_streams, shared: true do
10  platform_is_not :windows do
11    it "does not close STDIN" do
12      code = "STDOUT.puts STDIN.read(0).inspect"
13      cmd = "Process.wait Process.spawn(#{ruby_cmd(code).inspect}, #{@options.inspect})"
14      ruby_exe(cmd, args: "> #{@output}")
15      File.binread(@output).should == %[""#{newline}]
16    end
17
18    it "does not close STDOUT" do
19      code = "STDOUT.puts 'hello'"
20      cmd = "Process.wait Process.spawn(#{ruby_cmd(code).inspect}, #{@options.inspect})"
21      ruby_exe(cmd, args: "> #{@output}")
22      File.binread(@output).should == "hello#{newline}"
23    end
24
25    it "does not close STDERR" do
26      code = "STDERR.puts 'hello'"
27      cmd = "Process.wait Process.spawn(#{ruby_cmd(code).inspect}, #{@options.inspect})"
28      ruby_exe(cmd, args: "2> #{@output}")
29      File.binread(@output).should =~ /hello#{newline}/
30    end
31  end
32end
33
34describe "Process.spawn" do
35  ProcessSpecs.use_system_ruby(self)
36
37  before :each do
38    @name = tmp("process_spawn.txt")
39    @var = "$FOO"
40    platform_is :windows do
41      @var = "%FOO%"
42    end
43  end
44
45  after :each do
46    rm_r @name
47  end
48
49  it "executes the given command" do
50    lambda { Process.wait Process.spawn("echo spawn") }.should output_to_fd("spawn\n")
51  end
52
53  it "returns the process ID of the new process as a Fixnum" do
54    pid = Process.spawn(*ruby_exe, "-e", "exit")
55    Process.wait pid
56    pid.should be_an_instance_of(Fixnum)
57  end
58
59  it "returns immediately" do
60    start = Time.now
61    pid = Process.spawn(*ruby_exe, "-e", "sleep 10")
62    (Time.now - start).should < 5
63    Process.kill :KILL, pid
64    Process.wait pid
65  end
66
67  # argv processing
68
69  describe "with a single argument" do
70    platform_is_not :windows do
71      it "subjects the specified command to shell expansion" do
72        lambda { Process.wait Process.spawn("echo *") }.should_not output_to_fd("*\n")
73      end
74
75      it "creates an argument array with shell parsing semantics for whitespace" do
76        lambda { Process.wait Process.spawn("echo a b  c   d") }.should output_to_fd("a b c d\n")
77      end
78    end
79
80    platform_is :windows do
81      # There is no shell expansion on Windows
82      it "does not subject the specified command to shell expansion on Windows" do
83        lambda { Process.wait Process.spawn("echo *") }.should output_to_fd("*\n")
84      end
85
86      it "does not create an argument array with shell parsing semantics for whitespace on Windows" do
87        lambda { Process.wait Process.spawn("echo a b  c   d") }.should output_to_fd("a b  c   d\n")
88      end
89    end
90
91    it "calls #to_str to convert the argument to a String" do
92      o = mock("to_str")
93      o.should_receive(:to_str).and_return("echo foo")
94      lambda { Process.wait Process.spawn(o) }.should output_to_fd("foo\n")
95    end
96
97    it "raises an ArgumentError if the command includes a null byte" do
98      lambda { Process.spawn "\000" }.should raise_error(ArgumentError)
99    end
100
101    it "raises a TypeError if the argument does not respond to #to_str" do
102      lambda { Process.spawn :echo }.should raise_error(TypeError)
103    end
104  end
105
106  describe "with multiple arguments" do
107    it "does not subject the arguments to shell expansion" do
108      lambda { Process.wait Process.spawn("echo", "*") }.should output_to_fd("*\n")
109    end
110
111    it "preserves whitespace in passed arguments" do
112      out = "a b  c   d\n"
113      platform_is :windows do
114        # The echo command on Windows takes quotes literally
115        out = "\"a b  c   d\"\n"
116      end
117      lambda { Process.wait Process.spawn("echo", "a b  c   d") }.should output_to_fd(out)
118    end
119
120    it "calls #to_str to convert the arguments to Strings" do
121      o = mock("to_str")
122      o.should_receive(:to_str).and_return("foo")
123      lambda { Process.wait Process.spawn("echo", o) }.should output_to_fd("foo\n")
124    end
125
126    it "raises an ArgumentError if an argument includes a null byte" do
127      lambda { Process.spawn "echo", "\000" }.should raise_error(ArgumentError)
128    end
129
130    it "raises a TypeError if an argument does not respond to #to_str" do
131      lambda { Process.spawn "echo", :foo }.should raise_error(TypeError)
132    end
133  end
134
135  describe "with a command array" do
136    it "uses the first element as the command name and the second as the argv[0] value" do
137      platform_is_not :windows do
138        lambda { Process.wait Process.spawn(["/bin/sh", "argv_zero"], "-c", "echo $0") }.should output_to_fd("argv_zero\n")
139      end
140      platform_is :windows do
141        lambda { Process.wait Process.spawn(["cmd.exe", "/C"], "/C", "echo", "argv_zero") }.should output_to_fd("argv_zero\n")
142      end
143    end
144
145    it "does not subject the arguments to shell expansion" do
146      lambda { Process.wait Process.spawn(["echo", "echo"], "*") }.should output_to_fd("*\n")
147    end
148
149    it "preserves whitespace in passed arguments" do
150      out = "a b  c   d\n"
151      platform_is :windows do
152        # The echo command on Windows takes quotes literally
153        out = "\"a b  c   d\"\n"
154      end
155      lambda { Process.wait Process.spawn(["echo", "echo"], "a b  c   d") }.should output_to_fd(out)
156    end
157
158    it "calls #to_ary to convert the argument to an Array" do
159      o = mock("to_ary")
160      platform_is_not :windows do
161        o.should_receive(:to_ary).and_return(["/bin/sh", "argv_zero"])
162        lambda { Process.wait Process.spawn(o, "-c", "echo $0") }.should output_to_fd("argv_zero\n")
163      end
164      platform_is :windows do
165        o.should_receive(:to_ary).and_return(["cmd.exe", "/C"])
166        lambda { Process.wait Process.spawn(o, "/C", "echo", "argv_zero") }.should output_to_fd("argv_zero\n")
167      end
168    end
169
170    it "calls #to_str to convert the first element to a String" do
171      o = mock("to_str")
172      o.should_receive(:to_str).and_return("echo")
173      lambda { Process.wait Process.spawn([o, "echo"], "foo") }.should output_to_fd("foo\n")
174    end
175
176    it "calls #to_str to convert the second element to a String" do
177      o = mock("to_str")
178      o.should_receive(:to_str).and_return("echo")
179      lambda { Process.wait Process.spawn(["echo", o], "foo") }.should output_to_fd("foo\n")
180    end
181
182    it "raises an ArgumentError if the Array does not have exactly two elements" do
183      lambda { Process.spawn([]) }.should raise_error(ArgumentError)
184      lambda { Process.spawn([:a]) }.should raise_error(ArgumentError)
185      lambda { Process.spawn([:a, :b, :c]) }.should raise_error(ArgumentError)
186    end
187
188    it "raises an ArgumentError if the Strings in the Array include a null byte" do
189      lambda { Process.spawn ["\000", "echo"] }.should raise_error(ArgumentError)
190      lambda { Process.spawn ["echo", "\000"] }.should raise_error(ArgumentError)
191    end
192
193    it "raises a TypeError if an element in the Array does not respond to #to_str" do
194      lambda { Process.spawn ["echo", :echo] }.should raise_error(TypeError)
195      lambda { Process.spawn [:echo, "echo"] }.should raise_error(TypeError)
196    end
197  end
198
199  # env handling
200
201  after :each do
202    ENV.delete("FOO")
203  end
204
205  it "sets environment variables in the child environment" do
206    Process.wait Process.spawn({"FOO" => "BAR"}, "echo #{@var}>#{@name}")
207    File.read(@name).should == "BAR\n"
208  end
209
210  it "unsets environment variables whose value is nil" do
211    ENV["FOO"] = "BAR"
212    Process.wait Process.spawn({"FOO" => nil}, "echo #{@var}>#{@name}")
213    expected = "\n"
214    platform_is :windows do
215      # Windows does not expand the variable if it is unset
216      expected = "#{@var}\n"
217    end
218    File.read(@name).should == expected
219  end
220
221  it "calls #to_hash to convert the environment" do
222    o = mock("to_hash")
223    o.should_receive(:to_hash).and_return({"FOO" => "BAR"})
224    Process.wait Process.spawn(o, "echo #{@var}>#{@name}")
225    File.read(@name).should == "BAR\n"
226  end
227
228  it "calls #to_str to convert the environment keys" do
229    o = mock("to_str")
230    o.should_receive(:to_str).and_return("FOO")
231    Process.wait Process.spawn({o => "BAR"}, "echo #{@var}>#{@name}")
232    File.read(@name).should == "BAR\n"
233  end
234
235  it "calls #to_str to convert the environment values" do
236    o = mock("to_str")
237    o.should_receive(:to_str).and_return("BAR")
238    Process.wait Process.spawn({"FOO" => o}, "echo #{@var}>#{@name}")
239    File.read(@name).should == "BAR\n"
240  end
241
242  it "raises an ArgumentError if an environment key includes an equals sign" do
243    lambda do
244      Process.spawn({"FOO=" => "BAR"}, "echo #{@var}>#{@name}")
245    end.should raise_error(ArgumentError)
246  end
247
248  it "raises an ArgumentError if an environment key includes a null byte" do
249    lambda do
250      Process.spawn({"\000" => "BAR"}, "echo #{@var}>#{@name}")
251    end.should raise_error(ArgumentError)
252  end
253
254  it "raises an ArgumentError if an environment value includes a null byte" do
255    lambda do
256      Process.spawn({"FOO" => "\000"}, "echo #{@var}>#{@name}")
257    end.should raise_error(ArgumentError)
258  end
259
260  # :unsetenv_others
261
262  before :each do
263    @minimal_env = {
264      "PATH" => ENV["PATH"],
265      "HOME" => ENV["HOME"]
266    }
267    @common_env_spawn_args = [@minimal_env, "echo #{@var}>#{@name}"]
268  end
269
270  platform_is_not :windows do
271    it "unsets other environment variables when given a true :unsetenv_others option" do
272      ENV["FOO"] = "BAR"
273      Process.wait Process.spawn(*@common_env_spawn_args, unsetenv_others: true)
274      $?.success?.should be_true
275      File.read(@name).should == "\n"
276    end
277  end
278
279  it "does not unset other environment variables when given a false :unsetenv_others option" do
280    ENV["FOO"] = "BAR"
281    Process.wait Process.spawn(*@common_env_spawn_args, unsetenv_others: false)
282    $?.success?.should be_true
283    File.read(@name).should == "BAR\n"
284  end
285
286  platform_is_not :windows do
287    it "does not unset environment variables included in the environment hash" do
288      env = @minimal_env.merge({"FOO" => "BAR"})
289      Process.wait Process.spawn(env, "echo #{@var}>#{@name}", unsetenv_others: true)
290      $?.success?.should be_true
291      File.read(@name).should == "BAR\n"
292    end
293  end
294
295  # :pgroup
296
297  platform_is_not :windows do
298    it "joins the current process group by default" do
299      lambda do
300        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"))
301      end.should output_to_fd(Process.getpgid(Process.pid).to_s)
302    end
303
304    it "joins the current process if pgroup: false" do
305      lambda do
306        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"), pgroup: false)
307      end.should output_to_fd(Process.getpgid(Process.pid).to_s)
308    end
309
310    it "joins the current process if pgroup: nil" do
311      lambda do
312        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"), pgroup: nil)
313      end.should output_to_fd(Process.getpgid(Process.pid).to_s)
314    end
315
316    it "joins a new process group if pgroup: true" do
317      process = lambda do
318        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"), pgroup: true)
319      end
320
321      process.should_not output_to_fd(Process.getpgid(Process.pid).to_s)
322      process.should output_to_fd(/\d+/)
323    end
324
325    it "joins a new process group if pgroup: 0" do
326      process = lambda do
327        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"), pgroup: 0)
328      end
329
330      process.should_not output_to_fd(Process.getpgid(Process.pid).to_s)
331      process.should output_to_fd(/\d+/)
332    end
333
334    it "joins the specified process group if pgroup: pgid" do
335      pgid = Process.getpgid(Process.pid)
336      lambda do
337        Process.wait Process.spawn(ruby_cmd("print Process.getpgid(Process.pid)"), pgroup: pgid)
338      end.should output_to_fd(pgid.to_s)
339    end
340
341    it "raises an ArgumentError if given a negative :pgroup option" do
342      lambda { Process.spawn("echo", pgroup: -1) }.should raise_error(ArgumentError)
343    end
344
345    it "raises a TypeError if given a symbol as :pgroup option" do
346      lambda { Process.spawn("echo", pgroup: :true) }.should raise_error(TypeError)
347    end
348  end
349
350  platform_is :windows do
351    it "raises an ArgumentError if given :pgroup option" do
352      lambda { Process.spawn("echo", pgroup: false) }.should raise_error(ArgumentError)
353    end
354  end
355
356  # :rlimit_core
357  # :rlimit_cpu
358  # :rlimit_data
359
360  # :chdir
361
362  it "uses the current working directory as its working directory" do
363    lambda do
364      Process.wait Process.spawn(ruby_cmd("print Dir.pwd"))
365    end.should output_to_fd(Dir.pwd)
366  end
367
368  describe "when passed :chdir" do
369    before do
370      @dir = tmp("spawn_chdir", false)
371      Dir.mkdir @dir
372    end
373
374    after do
375      rm_r @dir
376    end
377
378    it "changes to the directory passed for :chdir" do
379      lambda do
380        Process.wait Process.spawn(ruby_cmd("print Dir.pwd"), chdir: @dir)
381      end.should output_to_fd(@dir)
382    end
383
384    it "calls #to_path to convert the :chdir value" do
385      dir = mock("spawn_to_path")
386      dir.should_receive(:to_path).and_return(@dir)
387
388      lambda do
389        Process.wait Process.spawn(ruby_cmd("print Dir.pwd"), chdir: dir)
390      end.should output_to_fd(@dir)
391    end
392  end
393
394  # :umask
395
396  it "uses the current umask by default" do
397    lambda do
398      Process.wait Process.spawn(ruby_cmd("print File.umask"))
399    end.should output_to_fd(File.umask.to_s)
400  end
401
402  platform_is_not :windows do
403    it "sets the umask if given the :umask option" do
404      lambda do
405        Process.wait Process.spawn(ruby_cmd("print File.umask"), umask: 146)
406      end.should output_to_fd("146")
407    end
408  end
409
410  # redirection
411
412  it "redirects STDOUT to the given file descriptior if out: Fixnum" do
413    File.open(@name, 'w') do |file|
414      lambda do
415        Process.wait Process.spawn("echo glark", out: file.fileno)
416      end.should output_to_fd("glark\n", file)
417    end
418  end
419
420  it "redirects STDOUT to the given file if out: IO" do
421    File.open(@name, 'w') do |file|
422      lambda do
423        Process.wait Process.spawn("echo glark", out: file)
424      end.should output_to_fd("glark\n", file)
425    end
426  end
427
428  it "redirects STDOUT to the given file if out: String" do
429    Process.wait Process.spawn("echo glark", out: @name)
430    File.read(@name).should == "glark\n"
431  end
432
433  it "redirects STDOUT to the given file if out: [String name, String mode]" do
434    Process.wait Process.spawn("echo glark", out: [@name, 'w'])
435    File.read(@name).should == "glark\n"
436  end
437
438  it "redirects STDERR to the given file descriptior if err: Fixnum" do
439    File.open(@name, 'w') do |file|
440      lambda do
441        Process.wait Process.spawn("echo glark>&2", err: file.fileno)
442      end.should output_to_fd("glark\n", file)
443    end
444  end
445
446  it "redirects STDERR to the given file descriptor if err: IO" do
447    File.open(@name, 'w') do |file|
448      lambda do
449        Process.wait Process.spawn("echo glark>&2", err: file)
450      end.should output_to_fd("glark\n", file)
451    end
452  end
453
454  it "redirects STDERR to the given file if err: String" do
455    Process.wait Process.spawn("echo glark>&2", err: @name)
456    File.read(@name).should == "glark\n"
457  end
458
459  it "redirects STDERR to child STDOUT if :err => [:child, :out]" do
460    File.open(@name, 'w') do |file|
461      lambda do
462        Process.wait Process.spawn("echo glark>&2", :out => file, :err => [:child, :out])
463      end.should output_to_fd("glark\n", file)
464    end
465  end
466
467  it "redirects both STDERR and STDOUT to the given file descriptior" do
468    File.open(@name, 'w') do |file|
469      lambda do
470        Process.wait Process.spawn(ruby_cmd("print(:glark); STDOUT.flush; STDERR.print(:bang)"),
471                                   [:out, :err] => file.fileno)
472      end.should output_to_fd("glarkbang", file)
473    end
474  end
475
476  it "redirects both STDERR and STDOUT to the given IO" do
477    File.open(@name, 'w') do |file|
478      lambda do
479        Process.wait Process.spawn(ruby_cmd("print(:glark); STDOUT.flush; STDERR.print(:bang)"),
480                                   [:out, :err] => file)
481      end.should output_to_fd("glarkbang", file)
482    end
483  end
484
485  it "redirects both STDERR and STDOUT at the time to the given name" do
486    touch @name
487    Process.wait Process.spawn(ruby_cmd("print(:glark); STDOUT.flush; STDERR.print(:bang)"), [:out, :err] => @name)
488    File.read(@name).should == "glarkbang"
489  end
490
491  context "when passed close_others: true" do
492    before :each do
493      @output = tmp("spawn_close_others_true")
494      @options = { close_others: true }
495    end
496
497    after :each do
498      rm_r @output
499    end
500
501    it "closes file descriptors >= 3 in the child process" do
502      IO.pipe do |r, w|
503        begin
504          pid = Process.spawn(ruby_cmd("while File.exist? '#{@name}'; sleep 0.1; end"), @options)
505          w.close
506          lambda { r.read_nonblock(1) }.should raise_error(EOFError)
507        ensure
508          rm_r @name
509          Process.wait(pid) if pid
510        end
511      end
512    end
513
514    it_should_behave_like :process_spawn_does_not_close_std_streams
515  end
516
517  context "when passed close_others: false" do
518    before :each do
519      @output = tmp("spawn_close_others_false")
520      @options = { close_others: false }
521    end
522
523    after :each do
524      rm_r @output
525    end
526
527    it "closes file descriptors >= 3 in the child process because they are set close_on_exec by default" do
528      IO.pipe do |r, w|
529        begin
530          pid = Process.spawn(ruby_cmd("while File.exist? '#{@name}'; sleep 0.1; end"), @options)
531          w.close
532          lambda { r.read_nonblock(1) }.should raise_error(EOFError)
533        ensure
534          rm_r @name
535          Process.wait(pid) if pid
536        end
537      end
538    end
539
540    platform_is_not :windows do
541      it "does not close file descriptors >= 3 in the child process if fds are set close_on_exec=false" do
542        IO.pipe do |r, w|
543          r.close_on_exec = false
544          w.close_on_exec = false
545          begin
546            pid = Process.spawn(ruby_cmd("while File.exist? '#{@name}'; sleep 0.1; end"), @options)
547            w.close
548            lambda { r.read_nonblock(1) }.should raise_error(Errno::EAGAIN)
549          ensure
550            rm_r @name
551            Process.wait(pid) if pid
552          end
553        end
554      end
555    end
556
557    it_should_behave_like :process_spawn_does_not_close_std_streams
558  end
559
560  # error handling
561
562  it "raises an ArgumentError if passed no command arguments" do
563    lambda { Process.spawn }.should raise_error(ArgumentError)
564  end
565
566  it "raises an ArgumentError if passed env or options but no command arguments" do
567    lambda { Process.spawn({}) }.should raise_error(ArgumentError)
568  end
569
570  it "raises an ArgumentError if passed env and options but no command arguments" do
571    lambda { Process.spawn({}, {}) }.should raise_error(ArgumentError)
572  end
573
574  it "raises an Errno::ENOENT for an empty string" do
575    lambda { Process.spawn "" }.should raise_error(Errno::ENOENT)
576  end
577
578  it "raises an Errno::ENOENT if the command does not exist" do
579    lambda { Process.spawn "nonesuch" }.should raise_error(Errno::ENOENT)
580  end
581
582  unless File.executable?(__FILE__) # Some FS (e.g. vboxfs) locate all files executable
583    platform_is_not :windows do
584      it "raises an Errno::EACCES when the file does not have execute permissions" do
585        lambda { Process.spawn __FILE__ }.should raise_error(Errno::EACCES)
586      end
587    end
588
589    platform_is :windows do
590      it "raises Errno::EACCES or Errno::ENOEXEC when the file is not an executable file" do
591        lambda { Process.spawn __FILE__ }.should raise_error(SystemCallError) { |e|
592          [Errno::EACCES, Errno::ENOEXEC].should include(e.class)
593        }
594      end
595    end
596  end
597
598  it "raises an Errno::EACCES or Errno::EISDIR when passed a directory" do
599    lambda { Process.spawn File.dirname(__FILE__) }.should raise_error(SystemCallError) { |e|
600      [Errno::EACCES, Errno::EISDIR].should include(e.class)
601    }
602  end
603
604  it "raises an ArgumentError when passed a string key in options" do
605    lambda { Process.spawn("echo", "chdir" => Dir.pwd) }.should raise_error(ArgumentError)
606  end
607
608  it "raises an ArgumentError when passed an unknown option key" do
609    lambda { Process.spawn("echo", nonesuch: :foo) }.should raise_error(ArgumentError)
610  end
611
612  platform_is_not :windows do
613    describe "with Integer option keys" do
614      before :each do
615        @name = tmp("spawn_fd_map.txt")
616        @io = new_io @name, "w+"
617        @io.sync = true
618      end
619
620      after :each do
621        @io.close unless @io.closed?
622        rm_r @name
623      end
624
625      it "maps the key to a file descriptor in the child that inherits the file descriptor from the parent specified by the value" do
626        child_fd = @io.fileno + 1
627        args = ruby_cmd(fixture(__FILE__, "map_fd.rb"), args: [child_fd.to_s])
628        pid = Process.spawn(*args, { child_fd => @io })
629        Process.waitpid pid
630        @io.rewind
631
632        @io.read.should == "writing to fd: #{child_fd}"
633      end
634    end
635  end
636end
637