User Tools

Site Tools


A multiclient TCP server for Ruby

Joachim Wuttke 2007-12

Motivation

Writing a reliable client-server system from scratch is difficult. Lots of sample code can be found in the web, but most of it is undercomplex; in particular, many example do not properly handle hangup, loss of connection, and similar anomalous events. Here I present code for a TCP server that has proved actually useful in a spectrometer control system.

Protocol

Client and server should be able to exchange messages of arbitrary length. Therefore messages must conform to a convention that determines how to recognize the end of message. For instance, one could start each message with a header that contains the number of characters that follow. Here, an even simpler protocol is chosen: each message must end with the terminator character sequence \r\n.

The server class

### This is free software
### Placed in the public domain by Joachim Wuttke 2012

require 'socket'

class MulticlientTCPServer

    # A nonblocking TCP server
    # - that serves several clients
    # - that is very efficient thanks to the 'select' system call
    # - that does _not_ use Ruby threads

    def initialize( port, timeout, verbose )
        @port = port        # the server listens on this port
        @timeout = timeout  # in seconds
        @verbose = verbose  # a boolean
        @connections = []
        @server = 
            begin
                TCPServer.new( @port )
            rescue SystemCallError => ex
                raise "cannot initialize tcp server for port #{@port}: #{ex}"
            end
    end

    def get_socket
        # Process incoming connections and messages.

        # When a message has arrived, we return the connection's TcpSocket.
        # Applications can read from this socket with gets(),
        # and they can respond with write().

        # one select call for three different purposes -> saves timeouts
        ios = select( [@server]+@connections, nil, @connections, @timeout ) or
            return nil
        # disconnect any clients with errors
        ios[2].each do |sock|
            sock.close
            @connections.delete( sock )
            raise "socket #{sock.peeraddr.join(':')} had error"
        end
        # accept new clients
        ios[0].each do |s| 
            # loop runs over server and connections; here we look for the former
            s==@server or next 
            client = @server.accept or
                raise "server: incoming connection, but no client"
            @connections << client
            @verbose and
                puts "server: incoming connection no. #{@connections.size} from #{client.peeraddr.join(':')}"
            # give the new connection a chance to be immediately served 
            ios = select( @connections, nil, nil, @timeout )
        end
        # process input from existing client
        ios[0].each do |s|
            # loop runs over server and connections; here we look for the latter
            s==@server and next
            # since s is an element of @connections, it is a client created
            # by @server.accept, hence a TcpSocket < IPSocket < BaseSocket
            if s.eof?
                # client has closed connection
                @verbose and
                    puts "server: client closed #{s.peeraddr.join(':')}"
                @connections.delete(s)
                next
            end
            @verbose and
                puts "server: incoming message from #{s.peeraddr.join(':')}"
            return s # message can be read from this
        end
        return nil # no message arrived
    end

end # class MulticlientTCPServer

A simple server

require 'multiclient_tcp_server'

srv = MulticlientTCPServer.new( 2000, 1, true )

loop do
    if sock = srv.get_socket
        # a message has arrived, it must be read from sock
        message = sock.gets( "\r\n" ).chomp( "\r\n" )
        # arbitrary examples how to handle incoming messages:
        if message == 'quit'
            raise SystemExit
        elsif message =~ /^puts (.*)$/
            puts "message from #{sock.peeraddr.join(':')}: '#{$1}'"
        elsif message =~ /^echo (.*)$/
	    # send something back to the client
            sock.write( "server echo: '#{$1}'\r\n" )
        else
            puts "unexpected message from #{sock.peeraddr}: '#{$1}'"
        end
    else
        sleep 0.01 # free CPU for other jobs, humans won't notice this latency
    end
end

A simple client

require 'socket'
require 'timeout'

# connect to server

sock = begin
           Timeout::timeout( 1 ) { TCPSocket.open( 'localhost', 2000 ) }
       rescue StandardError, RuntimeError => ex
           raise "cannot connect to server: #{ex}"
       end

# send sample messages:

puts "sending one-way message"
sock.write( "puts This is a one-way message\r\n" )
sleep( 2 )

puts "sending a request that should be answered"
sock.write( "echo This message should be echoed\r\n" )
response = begin
               Timeout::timeout( 1 ) { sock.gets( "\r\n" ).chomp( "\r\n" ) }
           rescue StandardError, RuntimeError => ex
               raise "no response from server: #{ex}"
           end
puts "received response: '#{response}'"
sleep( 2 )

puts "sending a goodbye message"
sock.write( "puts bye\r\n" )
sock.close

About

Changelog

08jan12 first published online.

Licence

The code samples are released into the public domain.

This web page is published under the Creative Commons license CC-BY-SA.