-
-
Notifications
You must be signed in to change notification settings - Fork 9.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Factor out IO-selection code, prepare for PR #2466 #2762
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
require "utils/io_selector" | ||
|
||
describe Utils::IOSelector do | ||
describe "given text streams" do | ||
let(:first_pipe) { IO.pipe } | ||
let(:second_pipe) { IO.pipe } | ||
|
||
let(:first_reader) { first_pipe[0] } | ||
let(:second_reader) { second_pipe[0] } | ||
|
||
let(:first_writer) { first_pipe[1] } | ||
let(:second_writer) { second_pipe[1] } | ||
|
||
let(:queues) { { first: Queue.new, second: Queue.new } } | ||
|
||
let(:write_first!) do | ||
thread = Thread.new(first_writer, queues[:second]) do |io, queue| | ||
io.puts "Lorem" | ||
wait(1).for { queue.pop }.to end_with("\n") | ||
io.puts "dolor" | ||
io.close | ||
end | ||
thread.abort_on_exception = true | ||
thread | ||
end | ||
|
||
let(:write_second!) do | ||
thread = Thread.new(second_writer, queues[:first]) do |io, queue| | ||
wait(1).for { queue.pop }.to end_with("\n") | ||
io.puts "ipsum" | ||
wait(1).for { queue.pop }.to end_with("\n") | ||
io.puts "sit" | ||
io.puts "amet" | ||
io.close | ||
end | ||
thread.abort_on_exception = true | ||
thread | ||
end | ||
|
||
let(:queue_feeder) do | ||
lambda do |proc_under_test, *args, &block| | ||
proc_under_test.call(*args) do |tag, string_received| | ||
queues[tag] << string_received | ||
block.call(tag, string_received) | ||
end | ||
end | ||
end | ||
|
||
before do | ||
allow_any_instance_of(Utils::IOSelector) | ||
.to receive(:each_line_nonblock) | ||
.and_wrap_original do |*args, &block| | ||
queue_feeder.call(*args, &block) | ||
end | ||
|
||
write_first! | ||
write_second! | ||
end | ||
|
||
after do | ||
write_first!.exit | ||
write_second!.exit | ||
end | ||
|
||
describe "::each_line_from" do | ||
subject do | ||
line_hash = { first: "", second: "", full_text: "" } | ||
|
||
Utils::IOSelector.each_line_from( | ||
first: first_reader, | ||
second: second_reader, | ||
) do |tag, string_received| | ||
line_hash[tag] << string_received | ||
line_hash[:full_text] << string_received | ||
end | ||
line_hash | ||
end | ||
|
||
before { wait(1).for(subject) } | ||
|
||
its([:first]) { is_expected.to eq("Lorem\ndolor\n") } | ||
its([:second]) { is_expected.to eq("ipsum\nsit\namet\n") } | ||
|
||
its([:full_text]) { | ||
is_expected.to eq("Lorem\nipsum\ndolor\nsit\namet\n") | ||
} | ||
end | ||
|
||
describe "::new" do | ||
let(:selector) do | ||
Utils::IOSelector.new( | ||
first: first_reader, | ||
second: second_reader, | ||
) | ||
end | ||
subject { selector } | ||
|
||
describe "pre-read" do | ||
its(:pending_streams) { | ||
are_expected.to eq([first_reader, second_reader]) | ||
} | ||
|
||
its(:separator) { | ||
is_expected.to eq($INPUT_RECORD_SEPARATOR) | ||
} | ||
end | ||
|
||
describe "post-read" do | ||
before do | ||
wait(1).for { | ||
subject.each_line_nonblock {} | ||
true | ||
}.to be true | ||
end | ||
|
||
after { expect(selector.all_streams).to all be_closed } | ||
|
||
its(:pending_streams) { are_expected.to be_empty } | ||
its(:separator) { | ||
is_expected.to eq($INPUT_RECORD_SEPARATOR) | ||
} | ||
end | ||
|
||
describe "#each_line_nonblock" do | ||
subject do | ||
line_hash = { first: "", second: "", full_text: "" } | ||
super().each_line_nonblock do |tag, string_received| | ||
line_hash[tag] << string_received | ||
line_hash[:full_text] << string_received | ||
end | ||
line_hash | ||
end | ||
|
||
before { wait(1).for(subject) } | ||
after { expect(selector.all_streams).to all be_closed } | ||
|
||
its([:first]) { is_expected.to eq("Lorem\ndolor\n") } | ||
its([:second]) { is_expected.to eq("ipsum\nsit\namet\n") } | ||
|
||
its([:full_text]) { | ||
is_expected.to eq("Lorem\nipsum\ndolor\nsit\namet\n") | ||
} | ||
end | ||
|
||
its(:all_streams) { | ||
are_expected.to eq([first_reader, second_reader]) | ||
} | ||
|
||
its(:all_tags) { are_expected.to eq([:first, :second]) } | ||
|
||
describe "#tag_of" do | ||
subject do | ||
{ | ||
"first tag" => super().tag_of(first_reader), | ||
"second tag" => super().tag_of(second_reader), | ||
} | ||
end | ||
|
||
its(["first tag"]) { is_expected.to eq(:first) } | ||
its(["second tag"]) { is_expected.to eq(:second) } | ||
end | ||
end | ||
|
||
describe "::new with a custom separator" do | ||
subject do | ||
tagged_streams = { | ||
first: first_reader, | ||
second: second_reader, | ||
} | ||
Utils::IOSelector.new(tagged_streams, ",") | ||
end | ||
|
||
its(:separator) { is_expected.to eq(",") } | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
require "delegate" | ||
require "English" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't be required; it's part of |
||
|
||
require "extend/io" | ||
|
||
module Utils | ||
# | ||
# The class `IOSelector` is a wrapper for `IO::select` with the | ||
# added benefit that it spans the streams' lifetimes. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand why this is a benefit, can you elaborate. |
||
# | ||
# The class accepts multiple IOs which must be open for reading. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does that mean they must be already open? When would they not be open? |
||
# It then notifies the client as data becomes available | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the client? What is the notification? |
||
# per-stream. | ||
# | ||
# Its main use is to allow a client to read both `stdout` and | ||
# `stderr` of a subprocess in a way that avoids buffer-related | ||
# deadlocks. | ||
# | ||
# For a more in-depth explanation, see: | ||
# https://github.com/Homebrew/brew/pull/2466 | ||
# | ||
class IOSelector < DelegateClass(Hash) | ||
attr_reader :separator | ||
|
||
alias all_streams keys | ||
alias all_tags values | ||
alias tag_of fetch | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are the aliases needed? |
||
|
||
def self.each_line_from(streams = {}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not obvious what the |
||
separator = $INPUT_RECORD_SEPARATOR, &block) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you indent this further in; this looks currently like it's a line in the function. |
||
new(streams, separator).each_line_nonblock(&block) | ||
end | ||
|
||
def initialize(streams = {}, | ||
separator = $INPUT_RECORD_SEPARATOR) | ||
super(streams.invert.compare_by_identity) | ||
@separator = separator | ||
end | ||
|
||
def each_line_nonblock | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is |
||
each_readable_stream_until_eof do |stream| | ||
line = stream.readline_nonblock(separator) || "" | ||
yield(tag_of(stream), line) | ||
end | ||
close_streams | ||
end | ||
|
||
def pending_streams | ||
@pending_streams ||= all_streams.dup | ||
end | ||
|
||
private | ||
|
||
def each_readable_stream_until_eof(&block) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
loop do | ||
readable_streams.each do |stream| | ||
pending_streams.delete(stream) if stream.eof? | ||
yield_gracefully(stream, &block) | ||
end | ||
break if pending_streams.empty? | ||
end | ||
end | ||
|
||
def readable_streams | ||
IO.select(pending_streams)[0] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd favour |
||
end | ||
|
||
def yield_gracefully(stream) | ||
yield(stream) | ||
rescue IO::WaitReadable, IO::WaitWritable, EOFError | ||
# We'll be back until/unless EOF | ||
return | ||
end | ||
|
||
def close_streams | ||
all_streams.each(&:close_read) | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this (and the method parameter) be
&block
too?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I figure that should be
&block
too; however that should be left to another PR.I have left☺️
&b
untouched because the corresponding method signature does not really relate to this issue. I’d prefer to keep commits focused, and diffs too