Client Lifecycle
I mentioned that there are two critical roles that make up a network connection. The server takes the listening role, listening for and processing incoming connections. The client, on the other hand, takes the role of initiating those connections with the server. In other words, it knows the location of a particular server and creates an outbound connection to it.
As I’m sure is obvious, no server is complete without a client.
The client lifecycle is a bit shorter than that of the server. It looks something like this:
- create
- bind
- connect
- close
Step #1 is the same for both clients and servers, so we’ll begin by looking at bind
from the perspective of clients.
Clients Bind
Client sockets begin life in the same way as server sockets, with bind
. In the server section we called bind
with a specific address and port. While it’s rare for a server to omit its call to #bind
, its rare for a client to make a call to bind. If the client socket (or the server socket, for that matter) omit its call to bind, it will be assigned a random port from the ephemeral range.
Clients don’t call bind
because they don’t need to be accessible from a known port number. The reason that servers bind to a specific port number is that clients expect a server to be available at a certain port number.
Take FTP as an example. The well-known port for FTP is 21. Hence FTP servers should bind to that port so that clients know where to find them. But the client is able to connect from any port number. The client port number does not affect the server.
Clients don’t call bind
because no one needs to know what their port number is.
There’s no code snippet for this section because the recommendation is: don’t do it!
Clients Connect
What really separates a client from a server is the call to connect
. This call initiates a connection to a remote socket.
require 'socket'
socket = Socket.new(:INET, :STREAM)
# Initiate a connection to google.com on port 80.
remote_addr = Socket.pack_sockaddr_in(80, 'google.com')
socket.connect(remote_addr)
Again, since we’re using the low-level primitives here we’re needing to pack the address object into its C struct representation.
This code snippet will initiate a TCP connection from a local port in the ephemeral range to a listening socket on port 80 of google.com. Notice that we didn’t use a call to bind
.
Connect Gone Awry
In the lifecycle of a client it’s quite possible for a client socket to connect to a server before said server is ready to accept connections. It’s equally possible to connect to a non-existent server. In fact, both of these situations produce the same outcome. Since TCP is optimistic, it waits as long as it can for a response from a remote host.
So, let’s try connecting to an endpoint that’s not available:
require 'socket'
socket = Socket.new(:INET, :STREAM)
# Attempt to connect to google.com on the known gopher port.
remote_addr = Socket.pack_sockaddr_in(70, 'google.com')
socket.connect(remote_addr)
If you run this bit of code it can take a long time to return from the connect
call. There is a long timeout by default on a connect
.
This makes sense for clients where bandwidth is an issue and it may actually take a long time to establish a connection. Even for bandwidth-rich clients the default behaviour is hopeful that the remote address will be able to accept our connection soon.
Nevertheless, if you wait it out, you’ll eventually see an Errno::ETIMEDOUT
exception raised. This is the generic timeout exception when working with sockets and indicates that the requested operation timed out. If you’re interested in tuning your socket timeouts there’s an entire Timeouts chapter later in the book.
This same behaviour is observed when a client connects to a server that has called bind
and listen
but has not yet called accept
. The only case in which connect
returns successfully is if the remote server accepts the connect.
Ruby Wrappers
Much like the code for creating server sockets, the code for creating client sockets is verbose and low-level. As expected, Ruby has wrappers to make these easier to work with.
Client Construction
Before I show you the nice, rosy Ruby-friendly code I’m going to show the low-level verbose code so we have something to compare against:
require 'socket'
socket = Socket.new(:INET, :STREAM)
# Initiate a connection to google.com on port 80.
remote_addr = Socket.pack_sockaddr_in(80, 'google.com')
socket.connect(remote_addr)
Behold the syntactic sugar:
require 'socket'
socket = TCPSocket.new('google.com', 80)
That feels much better. Three lines, two constructors, and lots of context have been reduced into a single constructor.
There’s a similar client construction method using Socket.tcp
that can take a block form:
require 'socket'
Socket.tcp('google.com', 80) do |connection|
connection.write "GET / HTTP/1.1\r\n"
connection.close
end
# Omitting the block argument behaves the same
# as TCPSocket.new().
client = Socket.tcp('google.com', 80)
System Calls From This Chapter
Socket#bind
-> bind(2)Socket#connect
-> connect(2)