Skip to content

Commit 8f5d4b6

Browse files
committed
Close Open3 pipes when spawn exits early
Prevent file descriptor leaks when process creation exits through an exception or throw.
1 parent cd61ac1 commit 8f5d4b6

2 files changed

Lines changed: 115 additions & 17 deletions

File tree

lib/open3.rb

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -520,30 +520,30 @@ def popen2e(*cmd, &block)
520520
opts[[:out, :err]] = out_w
521521

522522
popen_run(cmd, opts, [in_r, out_w], [in_w, out_r], &block)
523-
ensure
524-
if block
525-
in_r.close
526-
in_w.close
527-
out_r.close
528-
out_w.close
529-
end
530523
end
531524
module_function :popen2e
532525

533526
def popen_run(cmd, opts, child_io, parent_io) # :nodoc:
534527
pid = spawn(*cmd, opts)
535528
wait_thr = Process.detach(pid)
536529
child_io.each(&:close)
530+
child_io = nil
537531
result = [*parent_io, wait_thr]
538532
if defined? yield
539-
begin
540-
return yield(*result)
541-
ensure
533+
yield(*result)
534+
else
535+
result
536+
end
537+
ensure
538+
if result
539+
if defined? yield
542540
parent_io.each(&:close)
543541
wait_thr.join
544542
end
543+
else
544+
child_io&.each(&:close)
545+
parent_io.each(&:close)
545546
end
546-
result
547547
end
548548
module_function :popen_run
549549
class << self
@@ -1354,7 +1354,7 @@ def pipeline_run(cmds, pipeline_opts, child_io, parent_io) # :nodoc:
13541354
opts_base.delete :out
13551355

13561356
wait_thrs = []
1357-
r = nil
1357+
r = r2 = w2 = nil
13581358
cmds.each_with_index {|cmd, i|
13591359
cmd_opts = opts_base.dup
13601360
if String === cmd
@@ -1387,17 +1387,25 @@ def pipeline_run(cmds, pipeline_opts, child_io, parent_io) # :nodoc:
13871387
w2&.close
13881388
r = r2
13891389
}
1390-
result = parent_io + [wait_thrs]
13911390
child_io.each(&:close)
1391+
child_io = nil
1392+
result = parent_io + [wait_thrs]
13921393
if defined? yield
1393-
begin
1394-
return yield(*result)
1395-
ensure
1394+
yield(*result)
1395+
else
1396+
result
1397+
end
1398+
ensure
1399+
if result
1400+
if defined? yield
13961401
parent_io.each(&:close)
13971402
wait_thrs.each(&:join)
13981403
end
1404+
else
1405+
[r, r2, w2, *child_io, *parent_io].each do |io|
1406+
io&.close
1407+
end
13991408
end
1400-
result
14011409
end
14021410
module_function :pipeline_run
14031411
class << self

test/test_open3.rb

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,67 @@ def test_popen2e_noblock
159159
t.join
160160
end
161161

162+
def test_popen_spawn_failure_closes_pipes
163+
[:popen3, :popen2, :popen2e].each do |method|
164+
assert_no_fd_leak(method) do
165+
assert_raise(Errno::ENOENT) do
166+
Open3.public_send(method, "/open3-command-does-not-exist")
167+
end
168+
end
169+
end
170+
end
171+
172+
def test_popen_spawn_throw_closes_pipes
173+
tag = Object.new
174+
assert_no_fd_leak(:popen3) do
175+
stub_open3_spawn(->(*) {throw tag}) do
176+
assert_throw(tag) do
177+
Open3.popen3("unused")
178+
end
179+
end
180+
end
181+
end
182+
183+
def test_pipeline_spawn_failure_closes_pipes
184+
first = [RUBY, '-e', '']
185+
missing = ["/open3-command-does-not-exist"]
186+
187+
[:pipeline_rw, :pipeline_r, :pipeline_w,
188+
:pipeline_start, :pipeline].each do |method|
189+
threads = Thread.list
190+
assert_no_fd_leak(method) do
191+
assert_raise(Errno::ENOENT) do
192+
Open3.public_send(method, first, missing)
193+
end
194+
end
195+
(Thread.list - threads).each do |thread|
196+
thread.join if Process::Waiter === thread
197+
end
198+
end
199+
end
200+
201+
def test_pipeline_spawn_throw_closes_pipes
202+
tag = Object.new
203+
spawn = Open3.method(:spawn)
204+
calls = 0
205+
threads = Thread.list
206+
207+
assert_no_fd_leak(:pipeline_rw) do
208+
stub_open3_spawn(->(*args) {
209+
calls += 1
210+
throw tag if calls == 2
211+
spawn.call(*args)
212+
}) do
213+
assert_throw(tag) do
214+
Open3.pipeline_rw([RUBY, '-e', ''], ["unused"])
215+
end
216+
end
217+
end
218+
(Thread.list - threads).each do |thread|
219+
thread.join if Process::Waiter === thread
220+
end
221+
end
222+
162223
def test_capture3
163224
o, e, s = Open3.capture3(RUBY, '-e', 'i=STDIN.read; print i+"o"; STDOUT.flush; STDERR.print i+"e"', :stdin_data=>"i")
164225
assert_equal("io", o)
@@ -332,4 +393,33 @@ def test_integer_and_symbol_key
332393
assert_equal("test_integer_and_symbol_key\n", out)
333394
assert_predicate(status, :success?)
334395
end
396+
397+
private
398+
399+
def stub_open3_spawn(spawn)
400+
Open3.define_singleton_method(:spawn, spawn)
401+
yield
402+
ensure
403+
Open3.singleton_class.send(:remove_method, :spawn)
404+
end
405+
406+
def assert_no_fd_leak(method)
407+
fd_dir = ["/proc/self/fd", "/dev/fd"].find {|dir| File.directory?(dir) }
408+
omit "cannot inspect open file descriptors" unless fd_dir
409+
410+
gc_was_disabled = GC.disable
411+
before = open_fds(fd_dir)
412+
yield
413+
assert_equal(before, open_fds(fd_dir),
414+
"#{method} leaked file descriptors")
415+
ensure
416+
GC.enable unless gc_was_disabled
417+
end
418+
419+
def open_fds(fd_dir)
420+
Dir.open(fd_dir) do |dir|
421+
fds = dir.children(&:to_i).sort
422+
fds -= [dir.fileno] if dir.respond_to? :fileno
423+
end
424+
end
335425
end

0 commit comments

Comments
 (0)