Project framework + socket connector
The socket connector is responsible for reading from a socket and converting the lines that were read to messages, using a passed-in deserialiser. It also provides an interface to push messages onto the socket, via a serialiser which is also passed in.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/nbproject/private/
|
59
Rakefile
Normal file
59
Rakefile
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
require 'rubygems'
|
||||||
|
require 'rake'
|
||||||
|
require 'rake/clean'
|
||||||
|
require 'rake/gempackagetask'
|
||||||
|
require 'rake/rdoctask'
|
||||||
|
require 'rake/testtask'
|
||||||
|
|
||||||
|
spec = Gem::Specification.new do |s|
|
||||||
|
s.name = 'qmp_client'
|
||||||
|
s.version = '0.0.1'
|
||||||
|
s.has_rdoc = true
|
||||||
|
s.extra_rdoc_files = ['README', 'LICENSE']
|
||||||
|
s.summary = 'QMP client library '
|
||||||
|
s.description = "Library that interfaces with running QEMU processes over QMP"
|
||||||
|
s.author = 'Nicholas Thomas'
|
||||||
|
s.email = 'nick@lupine.me.uk'
|
||||||
|
# s.executables = ['your_executable_here']
|
||||||
|
s.files = %w(LICENSE README Rakefile) + Dir.glob("{bin,lib,spec,test}/**/*")
|
||||||
|
s.require_path = "lib"
|
||||||
|
s.bindir = "bin"
|
||||||
|
end
|
||||||
|
|
||||||
|
Rake::GemPackageTask.new(spec) do |p|
|
||||||
|
p.gem_spec = spec
|
||||||
|
p.need_tar = true
|
||||||
|
p.need_zip = true
|
||||||
|
end
|
||||||
|
|
||||||
|
Rake::RDocTask.new do |rdoc|
|
||||||
|
files =['README', 'LICENSE', 'lib/**/*.rb']
|
||||||
|
rdoc.rdoc_files.add(files)
|
||||||
|
rdoc.main = "README" # page to start on
|
||||||
|
rdoc.title = "qmp-client Docs"
|
||||||
|
rdoc.rdoc_dir = 'doc/rdoc' # rdoc output folder
|
||||||
|
rdoc.options << '--line-numbers'
|
||||||
|
end
|
||||||
|
|
||||||
|
Rake::TestTask.new do |t|
|
||||||
|
t.libs << ["test/unit"]
|
||||||
|
t.test_files = FileList['test/unit/**/test_*.rb']
|
||||||
|
t.options="-v"
|
||||||
|
t.name = "test:unit"
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMA_HOST = 'http://git.kernel.org/'
|
||||||
|
SCHEMA_QUERY = 'p=virt/kvm/qemu-kvm.git;a=blob_plain;f=qapi-schema.json'
|
||||||
|
|
||||||
|
SCHEMA_LOC = [SCHEMA_HOST, SCHEMA_QUERY].join('?')
|
||||||
|
SCHEMA_OUTPUT = File.join(
|
||||||
|
Rake::application.original_dir,
|
||||||
|
'lib', 'qmp_client', 'qapi-schema.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
namespace :dev do
|
||||||
|
desc "Download the QMP schema filelinux-x86_64/pty netbeans"
|
||||||
|
task :fetch_schema do
|
||||||
|
`wget '#{SCHEMA_LOC}' -O '#{SCHEMA_OUTPUT}'`
|
||||||
|
end
|
||||||
|
end
|
17
lib/qmp_client/connectors.rb
Normal file
17
lib/qmp_client/connectors.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
require 'qmp_client/connectors/socket'
|
||||||
|
|
||||||
|
module QMPClient
|
||||||
|
|
||||||
|
# The point of the connectors is to provide a unified interface to the varied
|
||||||
|
# interfaces a QMP server can have. They are:
|
||||||
|
# 1) TCP/UNIX socket
|
||||||
|
# 2) STDIN+STDOUT / arbitrary fds
|
||||||
|
# 3) mock interface (for tests)
|
||||||
|
# 4) Anything else that might pop up!
|
||||||
|
#
|
||||||
|
# @author Nick Thomas <nick@lupine.me.uk>
|
||||||
|
module Connectors
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
143
lib/qmp_client/connectors/socket.rb
Normal file
143
lib/qmp_client/connectors/socket.rb
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
require 'mutex_m'
|
||||||
|
|
||||||
|
module QMPClient
|
||||||
|
module Connectors
|
||||||
|
|
||||||
|
# We yield an instance of this call in the run method of the connectors -
|
||||||
|
# if someone wants to send a message, they call push on it with the message.
|
||||||
|
#
|
||||||
|
# This blocks until the data is on the wire, which isn't necessarily a bad
|
||||||
|
# this.
|
||||||
|
#
|
||||||
|
# @author Nick Thomas <nick@lupine.me.uk>
|
||||||
|
class WriteProxy
|
||||||
|
include Mutex_m
|
||||||
|
|
||||||
|
def initialize(w_socket, serialiser)
|
||||||
|
super()
|
||||||
|
@w_socket = w_socket
|
||||||
|
@serialiser = serialiser
|
||||||
|
end
|
||||||
|
|
||||||
|
def push(msg)
|
||||||
|
synchronize do
|
||||||
|
@w_socket.puts(@serialiser.serialise(msg))
|
||||||
|
@w_socket.flush
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Handles a subclass of BasicSocket, proactively reading data from it and
|
||||||
|
# converting it into a stream of Messages, and writing serialised Messages
|
||||||
|
# to it on request.
|
||||||
|
#
|
||||||
|
# @author Nick Thomas <nick@lupine.me.uk>
|
||||||
|
class Socket
|
||||||
|
|
||||||
|
# @param[#serialise] serialiser Object to convert message instances to
|
||||||
|
# the wire format
|
||||||
|
# @param[#deserialise] deserialiser Object to convert wire data to message
|
||||||
|
# instances.
|
||||||
|
def initialize(serialiser, deserialiser)
|
||||||
|
@serialiser = serialiser
|
||||||
|
@deserialiser = deserialiser
|
||||||
|
|
||||||
|
@receive_queues = []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Call this with an object responding to +push+ and every time a message
|
||||||
|
# is received, we will pass it on. Multiple queues can be registered.
|
||||||
|
# Messages are pushed onto the queue as hashes.
|
||||||
|
def register_receive_queue(q)
|
||||||
|
@receive_queues.push(q)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unregister_receive_queue(q)
|
||||||
|
@receive_queues.delete(q)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Starts reading from / writing to the socket as appropriate, yields the
|
||||||
|
# block, and stops reading/writing the socket once the block returns.
|
||||||
|
# @param[IO] read_socket Socket to read data from
|
||||||
|
# @param[IO] write_socket Socket to write data to. Use read_socket if nil
|
||||||
|
# @yields[WriteProxy] writer Push messages to this to get them on the wire
|
||||||
|
def run(read_socket, write_socket=nil, &blk)
|
||||||
|
write_socket ||= read_socket
|
||||||
|
write_proxy = WriteProxy.new(write_socket, @serialiser)
|
||||||
|
begin
|
||||||
|
stop_r, stop_w = IO.pipe
|
||||||
|
r_thread = read_thread(read_socket, stop_r)
|
||||||
|
yield(write_proxy)
|
||||||
|
ensure
|
||||||
|
stop_w.write("*") if stop_w # Stop signal for the r_thread
|
||||||
|
stop_w.close if stop_w && !stop_w.closed?
|
||||||
|
stop_r.close if stop_r && !stop_w.closed?
|
||||||
|
r_thread.join if r_thread && r_thread.alive?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
attr_reader :deserialiser
|
||||||
|
attr_reader :serialiser
|
||||||
|
attr_reader :receive_queues
|
||||||
|
|
||||||
|
# Reads from r_sock and turns the data into messages (which get pushed
|
||||||
|
# out via +register_receive_queue+) until either the socket raises an
|
||||||
|
# error, or the stop socket becomes readable.
|
||||||
|
def read_thread(r_sock, stop_socket)
|
||||||
|
Thread.new do
|
||||||
|
buf = ""
|
||||||
|
socks = [r_sock, stop_socket]
|
||||||
|
loop do
|
||||||
|
readables, _, errored = begin
|
||||||
|
Kernel.select(socks, nil, socks)
|
||||||
|
rescue IOError # socket closed between run and this call
|
||||||
|
break
|
||||||
|
end
|
||||||
|
# exit conditions
|
||||||
|
break if readables.include?(stop_socket)
|
||||||
|
break unless errored.empty?
|
||||||
|
|
||||||
|
# Should never happen, but anything's possible
|
||||||
|
next unless readables.include?(r_sock)
|
||||||
|
|
||||||
|
# Pull the data from the socket, taking care of errors
|
||||||
|
begin
|
||||||
|
buf << r_sock.read_nonblock(8192)
|
||||||
|
rescue IO::WaitReadable
|
||||||
|
next # socket isn't ready to read
|
||||||
|
# TODO: other possible errors
|
||||||
|
end
|
||||||
|
|
||||||
|
buf = dispatch_messages_from_buffer(buf)
|
||||||
|
break if buf.nil? # Dispatching raised an error of some sort!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Converts each message in +buf+ into an instance, and pushes it to each
|
||||||
|
# of the receive queues.
|
||||||
|
# @param[String] buf String buffer
|
||||||
|
# @return[nil,String] Either the unparsed component of the data, or nil
|
||||||
|
# if an error has occured
|
||||||
|
def dispatch_messages_from_buffer(buf)
|
||||||
|
lines = buf.split("\n")
|
||||||
|
|
||||||
|
r_buf = if buf[-1..-1] == "\n" # Every element is a message
|
||||||
|
""
|
||||||
|
else # The last element is a partial message, so put it back
|
||||||
|
lines.pop
|
||||||
|
end
|
||||||
|
|
||||||
|
messages = lines.collect {|line| deserialiser.deserialise(line) }
|
||||||
|
messages.each {|msg| receive_queues.each {|q| q.push(msg)}}
|
||||||
|
|
||||||
|
r_buf
|
||||||
|
rescue => err # Any error dispatching means we need to shut up shop
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
35
test/unit/helper.rb
Normal file
35
test/unit/helper.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
ROOT = File.expand_path(File.join(File.basename(__FILE__), '..'))
|
||||||
|
|
||||||
|
$: << File.join(ROOT, 'lib')
|
||||||
|
|
||||||
|
require 'minitest/unit'
|
||||||
|
require 'minitest/autorun'
|
||||||
|
require 'mocha'
|
||||||
|
|
||||||
|
require 'timeout' # assert_doesnt_time_out
|
||||||
|
|
||||||
|
module EnvHelpers
|
||||||
|
def silence_warnings
|
||||||
|
old = $VERBOSE
|
||||||
|
$VERBOSE = nil
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
$VERBOSE = old
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class BaseTestCase < MiniTest::Unit::TestCase
|
||||||
|
include EnvHelpers
|
||||||
|
|
||||||
|
def assert_doesnt_time_out(n, reason = "", &blk)
|
||||||
|
|
||||||
|
Timeout::timeout(n, &blk)
|
||||||
|
rescue TimeoutError => err
|
||||||
|
r_full = [reason, "(timed out after #{n}s)", "\n" + err.backtrace.join("\n")].join(" ")
|
||||||
|
raise MiniTest::Assertion.new(r_full)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class QMPClientTestCase < BaseTestCase
|
||||||
|
|
||||||
|
end
|
125
test/unit/qmp_client/connectors/test_socket.rb
Normal file
125
test/unit/qmp_client/connectors/test_socket.rb
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
require 'helper'
|
||||||
|
require 'qmp_client/connectors/socket'
|
||||||
|
|
||||||
|
module TestQMPClient
|
||||||
|
module TestConnectors
|
||||||
|
class TestSocket < QMPClientTestCase
|
||||||
|
def setup
|
||||||
|
@serialiser = mock("(serialiser)")
|
||||||
|
@deserialiser = mock("(deserialiser)")
|
||||||
|
|
||||||
|
@connector = QMPClient::Connectors::Socket.new(@serialiser, @deserialiser)
|
||||||
|
@msg_received_q = Queue.new
|
||||||
|
@connector.register_receive_queue(@msg_received_q)
|
||||||
|
|
||||||
|
@read_r, @read_w = IO.pipe
|
||||||
|
@write_r, @write_w = IO.pipe
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
[@read_r, @read_w, @write_r, @write_w].each {|s| s.close unless s.closed? }
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_connector(reason="Run block", t=1, &blk)
|
||||||
|
assert_doesnt_time_out(t, reason) do
|
||||||
|
@connector.run(@read_r, @write_w) do |writer|
|
||||||
|
|
||||||
|
@write_interface = writer
|
||||||
|
yield @connector, writer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_from_server(symbol)
|
||||||
|
@deserialiser.expects(:deserialise).with(symbol.to_s).once.
|
||||||
|
returns(symbol)
|
||||||
|
@read_w.puts symbol.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_server_saw(symbol)
|
||||||
|
assert_doesnt_time_out(1, "Checking message #{symbol.inspect} was sent to server") do
|
||||||
|
assert_equal(symbol.to_s + "\n", @write_r.gets)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_from_client(symbol)
|
||||||
|
@serialiser.expects(:serialise).with(symbol).once.returns(symbol.to_s)
|
||||||
|
@write_interface.push(symbol)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_client_saw(symbol, discard = 0)
|
||||||
|
assert_doesnt_time_out(1, "checking message #{symbol.inspect} was received from server") do
|
||||||
|
discard.times { @msg_received_q.pop }
|
||||||
|
assert_equal(symbol, @msg_received_q.pop)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_fires_up_read_and_write_sides_and_operates_correctly
|
||||||
|
with_connector do
|
||||||
|
message_from_client(:test_1)
|
||||||
|
assert_server_saw(:test_1)
|
||||||
|
|
||||||
|
message_from_server(:test_2)
|
||||||
|
assert_client_saw(:test_2)
|
||||||
|
end
|
||||||
|
|
||||||
|
# After shutting down like this, all our sockets should be open
|
||||||
|
refute(@read_r.closed?, "read side of read socket closed")
|
||||||
|
refute(@read_w.closed?, "write side of read socket closed")
|
||||||
|
refute(@write_r.closed?, "read side of write socket closed")
|
||||||
|
refute(@write_w.closed?, "write side of write socket closed")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_writes_are_threadsafe
|
||||||
|
with_connector do |connector, writer|
|
||||||
|
(1..5).each {|n| Thread.new { message_from_client(:"Message #{n}")}}
|
||||||
|
|
||||||
|
data = (1..5).collect {|n| @write_r.gets }
|
||||||
|
assert_equal(
|
||||||
|
["Message 1\n", "Message 2\n", "Message 3\n", "Message 4\n", "Message 5\n"],
|
||||||
|
data.sort
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_register_unregister_receive_queue
|
||||||
|
with_connector do |connector, writer|
|
||||||
|
rq1 = Queue.new # We expect both of these to get both messages
|
||||||
|
rq2 = Queue.new
|
||||||
|
urq = Queue.new # We're going to unregister this one after one msg
|
||||||
|
connector.register_receive_queue(rq1)
|
||||||
|
connector.register_receive_queue(rq2)
|
||||||
|
connector.register_receive_queue(urq)
|
||||||
|
|
||||||
|
message_from_server(:test_1)
|
||||||
|
assert_client_saw(:test_1)
|
||||||
|
connector.unregister_receive_queue(urq)
|
||||||
|
message_from_server(:test_2)
|
||||||
|
assert_client_saw(:test_2)
|
||||||
|
|
||||||
|
[rq1, rq2].each do |q|
|
||||||
|
assert_equal(2, q.size, "receive q missed a message")
|
||||||
|
assert_equal(:test_1, q.pop)
|
||||||
|
assert_equal(:test_2, q.pop)
|
||||||
|
end
|
||||||
|
assert_equal(1, urq.size, "Unregister queue has wrong # of messages")
|
||||||
|
assert_equal(:test_1, urq.pop, "Unregister queue has wrong message")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This closes the socket before Kernel#select can get to it
|
||||||
|
def test_closing_socket_finishes_run_block_cleanly
|
||||||
|
with_connector { @read_r.close }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_block_finishes_cleanly_if_socket_reports_error
|
||||||
|
with_connector do |connector, writer|
|
||||||
|
Kernel.expects(:select).once.returns([[], [], [@read_r]])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Reference in New Issue
Block a user