The Interweb Isn't Magic Skip to main content
samwho logo, a small keyboard arranged in a 3x2 grid, with 'sam' at the top, and 'who' at the bottom. A cable runs from the top of the keyboard to off the top of the screen.

The Interweb Isn't Magic

It's quite easy to get scared by large systems of things that seem like magic. The Internet is pretty remarkable. It's probably among the largest and most complicated systems ever designed by human beings. All those hundreds of web pages we view each month arrive at our computers unscathed a large percentage of the time. Some of them will have crossed the Pacific! That small percentage of the time we have a page "hang" while loading is often resolved by simply hitting the refresh button. Ace, right?

But how do those pages actually get to us? In this post (and maybe more, I might split them out, we'll see how it goes) I want to show how these things work with practical examples and real commands that you can run to inspect what's going on.

Prerequisite knowledge: Basic Ruby should do it. If you've built a simple web application with Sinatra or Rails or something similar, you should be able to follow along without much issue.

NOTE: I'm a Mac user. Some of the examples may be Mac specific. I will try, where possible, to give equivalent commands you can run on a Linux machine but I may miss something out. If you spot something that doesn't work on your platform, get in touch. Windows users: I'm sorry.

# Humble beginnings: the Socket.

Our most basic building block is going to be the Socket. Originating in BSD back in the 80s, sockets provide a very simple API for interprocess communication (IPC) between processes that may or may not be on the same machine. The idea is that a socket allows one process to send data to another process. The data can be whatever you want it to be and you can send as much of it as you want. There are different ways of doing this that give you difference performance characteristics and guarantees.

If you're reading around the Internet about sockets, you may see references to things called "UNIX domain sockets". These follow the same API but are used only for IPC between processes on the same machine ("local" IPC) and don't access or require a network connection. Instead, they are represented by a special kind of file on the filesystem. They're unimportant to what we're going to discuss in the rest of the post but it's worth knowing about them to save confusion when looking things up for yourself.

Right, straight to business! Here's a trivial example of socket-based communicating in a single process:

require 'socket'

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
send.bind(Addrinfo.tcp("0.0.0.0", 7777))
send.listen(1)

recv = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
recv.connect(Addrinfo.tcp("0.0.0.0", 7777))

client_socket, client_addr = send.accept

client_socket.puts "Hello, world!"
client_socket.close

puts recv.gets
recv.close

send.close

Yikes! Look at all those strange constants and strings and things. I went all low-level APIs on you from the get-go, sorry about that. Let's break it down line by line and figure out what all of that means.

# Creating a socket

Remember earlier when I said that sockets are all about sending data from one place to another and there are a variety of protocols for doing that? You need to specify what protocols you want to use when you create your socket.

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)

Have you ever seen the acronym "TCP/IP" floating around? It stands for Transmission Control Protocol over Internet Protocol and it's probably one of the most important bits of technology in the modern Internet era. These protocols are what allow computers to talk to each other in a reliable manner using simple, unique addresses.

The "TCP" bit of TCP/IP ensures that messages we send to another computer actually arrive there and arrive in the right order. Yup, that stuff isn't guaranteed by default. The ordering is important because if you send a lot of data, it doesn't all go there in one big block. It gets broken up into small "packets" and reassembled at the other end. TCP is quite a complicated beast (this book is 1,600 pages long!) but for now you can just assume it works and it's the reason all of your bytes arrive safe and sound.

The "IP" bit of TCP/IP is how we identify machines and route traffic to them. If you've never seen an IP address, check this out:

$ ping google.com
PING google.com (212.56.71.166): 56 data bytes
64 bytes from 212.56.71.166: icmp_seq=0 ttl=58 time=17.826 ms

Note for Mac users: You might have to run /sbin/ping instead of just ping. Give ping a try first, though.

The "212.56.71.166" bit of that output is the IP address of "google.com". You may get a different value, and that's fine. Google have a lot of computers and they'll do their best to connect you to one that's physically close to you in order to reduce latency (the time it takes for packets to physically travel to and from their destination). Computers aren't too fussed on fancy human-readable names like "google.com", though, so they ask a service called the Domain Name Server (DNS) to translate "google.com" into something sensible like "212.56.71.166".

At the highest level of abstraction, you can think of DNS as a giant lookup table of domain names (the things you type into your browser's address bar) to IP addresses. In reality, it's quite a large and distributed system of many lookup tables in many geographical locations. If you've ever bought a domain and been annoyed by how long it takes for the name to "propagate" across DNS, the reason is because it's widely distributed.

The layers below TCP/IP know how to ship your data to the right place based on the IP address. Again, for the time being you can ignore those layers and assume that they Just Work. We'll go into some of the gory detail a little later.

The Socket::AF_INET part above means we want an IPv4 socket. Internet Protocol version 4. This means that we are identifying machines with IP addresses that are 32 bits long. You may have read somewhere that we're running out of IPv4 addresses. Yup, we are. What does this mean in practice? It means that fairly soon we won't be able to add more machines to the Internet. That's roughly as bad as it sounds. What are we going to do about it? The current plan is to use IPv6. Same principal as IPv4 except addresses are 128 bits long. That'll learn us for thinking we would never use 4.2 billion addresses, now we have 340,282,366,920,938,463,463,374,607,431,768,211,456 addresses. No more problem \o/

If you wanted to use an IPv6 socket in Ruby you would instead specify Socket::AF_INET6, but we won't do that for the time being. Most of all Internet traffic is still IPv4 and supporting both protocols is a little tricky (but not impossible!) so we'll skip past it for now.

Oh yeah! The "AF" part stands for "address family". The Internet Protocol isn't the only way to address machines, it's just the most popular. It's also the only one that's really relevant to web programming, so we'll stick with it.

# What's up with the dots and numbers notation for IP addresses?

We've seen a few IP addresses now and you'll have noticed a pattern with how they look. It's called dotted-decimal notation and it exists primarily to make addresses easier to remember. For example, which one of the following would you be more likely to remember tomorrow morning:

I know which I'd choose. Each of the four sections of an IP address represents 8 bits, so its value can range from 0 to 255. It's always good fun watching films with hackers in them, the IP addresses are never valid despite some IP valid addresses being reserved. I've always thought programmers would have more respect for a film that knew a standard or two. Or you could have a chuckle and use the IP address of the NSA.

I digress.

# I keep seeing PF_INET on the interwebz, what gives?

I will quote the master on this one. Beej:

In some documentation, you'll see mention of a mystical "PF_INET". This is a weird etherial beast that is rarely seen in nature, but I might as well clarify it a bit here. Once a long time ago, it was thought that maybe a address family (what the "AF" in "AF_INET" stands for) might support several protocols that were referenced by their protocol family (what the "PF" in "PF_INET" stands for). That didn't happen. Oh well. So the correct thing to do is to use AF_INET in your struct sockaddr_in and PF_INET in your call to socket(). But practically speaking, you can use AF_INET everywhere. And, since that's what W. Richard Stevens does in his book, that's what I'll do here.

You may also notice what I'm writing here has a lot of overlap with his well-known Guide to Network Programming except that he focuses on the actual C APIs and probably knows more about it than I do. I wholeheartedly encourage the enthusiastic reader to see if they can follow his guide after reading this post!

# Soo... Socket::SOCK_STREAM?

The TCP bit! This is us telling the socket that we want all of our bytes to arrive at the destination in the correct order. Believe it or not, this isn't always what you want. Or, rather, you aren't always willing to pay the overhead for these guarantees. TCP does a lot of admin work under the hood which is necessary but can slow down communication. Some applications, such as audio and video streaming, aren't too fussed about losing a few bytes here or there in the name of speed, so they instead opt to use the User Datagram Protocol (UDP).

UDP takes all of the reliability and consistency guarantees of TCP, crumples them up and throws them out of the window. It's often called the "fire and forget" protocol. TCP asks each party to acknowledge (ACK) receipt of each packet as it arrives and resends packets if no ACK is received. UDP is the irresponsible little brother who just assumes everything will be fine and the Internet Protocol will get everything there.

Everything won't be fine. Hope is not a strategy. Assume that you should use TCP unless you know 100% that you need to use UDP.

# Binding a socket to an address

A socket is pretty useless on its own. In order to fulfil any dreams it may have, it needs to sign up to communicate via an address on the local machine. This is called "binding" to an address and is achieved like so:

send.bind(Addrinfo.tcp("0.0.0.0", 7777))

In Ruby, the bind method takes an argument of type Addrinfo (anything else will cause it to raise an EAFNOSUPPORT error). In C, all of this addressing is taken care of via a data structure called a sockaddr). The Addrinfo class exists as a nice abstraction for Rubyists that don't want to worry about all the crap they need to set correctly in a sockaddr (which is all of them).

Interestingly, you see how we call the tcp method of Addrinfo? That's kinda just a formality in this case. You could just as well use the udp method of Addrinfo, it would make no difference. It's an oddity of the Ruby Sockets code. The bind method makes no use of the protocol information stored in an Addrinfo object, it only cares about the host and port being correctly coerced into a sockaddr. For the truly curious and brave, you can try and follow the C code. Start here.

Okay, okay, I hand-waved past that last part. Here, I'll prove it to you. Open up an irb session and try this:

$ irb -r socket
irb(main):001:0> tcp = Addrinfo.tcp("0.0.0.0", 7777)
=> #<Addrinfo: 0.0.0.0:7777 TCP>
irb(main):002:0> udp = Addrinfo.udp("0.0.0.0", 7777)
=> #<Addrinfo: 0.0.0.0:7777 UDP>
irb(main):003:0> tcp.to_sockaddr == udp.to_sockaddr
=> true

The to_sockaddr part is all that bind cares about. Any other information that may be part of an Addrinfo object is irrelevant to it.

Now, the meat of this section: what do those numbers mean? The "0.0.0.0" and "7777"? They are, respectively, a host IP address and a port number.

# Valid hosts

Despite the vast range of IPv4 addresses we could choose from, there are very few valid ones when we're binding a socket. In general you only really have two choices: "127.0.0.1" or "0.0.0.0". But Sam! What about "localhost"? Yeah, that just translates to "127.0.0.1" under the hood. Check out the /etc/hosts file on your machine. It contains a list of domain name to IP mappings that are local to your machine. You can even add your own! I tend to name my virtual machines in this file.

# 0.0.0.0 vs 127.0.0.1

Both of these addresses mean "your machine", but they mean it in different ways. Your computer has a number of different "network interfaces", you can view them by running the following command:

$ ifconfig

NOTE: This commands ships by default on Mac but depending on your Linux distro it may not be there. I run Arch Linux and had to install the net-tools package to get it. If in doubt, Google is your friend.

A couple of notable ones you might pick out are "eth0" and "wlan0". If you're on a Mac you might see "en0" instead of "wlan0". Because I can't think of a succinct way of explaining a network interface on my own, I'll steal from Wikipedia:

In computing, a network interface is a system's (software and/or hardware) interface between two pieces of equipment or protocol layers in a computer network.

A network interface will usually have some form of network address. This may consist of a node Id and a port number or may be a unique node Id in its own right.

Network interfaces provide standardized functions such as passing messages, connecting and disconnecting, etc.

It doesn't take a genius based on the above description and the names "eth0" and "wlan0" to guess that the former would be an Ethernet port and the latter would be a wireless LAN connection. On Macs, "en0" appears to be my wireless connection. I don't know why.

Using the address "0.0.0.0" when binding a socket tells the operating system that you're interested in listening to all active network interfaces for traffic. In practical terms, this means that the outside world can talk to your program (provided your firewall and ISP is cool with it (they usually aren't (it usually isn't a problem unless you're doing crazy things))).

Conversely, "127.0.0.1" is the address of what's called the "loopback" interface. You'll see it listed in ifconfig as "lo0" or something similar. It's used when you want to do socket programming but only to processes on the same machine. The data you send will never hit your network card or leave your computer at all.

# Valid ports

Ports aren't actual, physical things. Their existence is in the operating system, and there are a maximum of 65535 of them. Why? Because standards (this will be a recurring theme). They are a way of mapping network communications to running processes. This is why binding to a port is important. You're telling the operating system to give you all of the traffic that gets sent to that port.

# When I visit a web page, I don't specify a port. Why not?

An excellent question! It's all to do with the standardisation of port numbers. Have you ever heard of IANA? Of course you haven't, who has? They are the Internet Assigned Numbers Authority and they deal with all of this boring standardisation lark so that things can actually work instead of being a mess of differing and incorrect opinions.

Behold! The full list of standard, assigned port numbers and the protocols that use them. Don't worry if most of the acronyms mean nothing to you, they mean nothing to me as well. The one we're interested in here is the "http" port, which just so happens to be 80. Your web browser knows that if you don't specifically give it a port you probably mean port 80. Or, more recently, port 443. I'll leave figuring that one out as an exercise to the reader.

# Privileged ports

You'll probably hear speak of "privileged ports" if you start working in anything network based. These are the first 1024 ports, and require root access to bind to. The reason is for security. If you're connecting to a privileged port, there's a certain guarantee that someone who knows what they're doing has set it up. You can't just have any old bean running a service on those ports!

# Hello... Is there anybody in there?

send.listen(1)

Now that we have our very own port, it's about time we started accepting traffic on it. This is achieved with the listen method. It takes a single argument: the size of the connection buffer. Somebody could connect while we're servicing someone else, but we don't want to turn them away. Instead, we tell the operating system to put them in a queue and we'll get around to them soon. 1 is probably not a great value for this, but the example is purely illustrative. We'll be more sensible in later posts.

recv = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
recv.connect(Addrinfo.tcp("0.0.0.0", 7777))

We have the "server" end of the connection set up, these two lines set up the "client" side of the connection. Very similar to what we've already seen but instead of calling bind we call connect. If you call connect on a host and port that isn't listening, you'll get an ECONNREFUSED error.

When connect returns, we will have made a request to the target server and the operating system will have added us to its connection queue (the thing we specified the size of in the call to listen). The next step is to pick that connection up on the server side, which we accomplish with accept:

client_socket, client_addr = send.accept

We get back a Socket object and an Addrinfo object. The socket allows us to listen to what the client has to say and reply to it, the address tells us who we're talking to. In our example, we don't much care what the client has to say, we just want to say hello to it and then send it on its way:

client_socket.puts "Hello, world!"
client_socket.close

My, oh my. That looks a lot like working with files, don't'cha think? The reason for that is that sockets sort of are files. The concept is pretty much the same. They open, they close, bytes go in, bytes come out. For this reason, they follow the file API very closely. There are subtle differences, though. With normal files on disk, you can read arbitrary parts of them with a method called seek. You cannot seek a socket. That wouldn't make sense without the ability to alter the passage of time. If you manage that, submit a patch to the kernel immediately.

To round all of this off, we receive the message and print it out for the world to see:

puts recv.gets
recv.close

Of course, let's not forget to be good citizens and clean up after ourselves:

send.close

# Q: I've run the script but I get an "Address already in use" error. Wut?

You're probably running the script for the second time, right?

This is one of the ugly parts of network development. When your application closes the socket it has bound to, the operating system doesn't get rid of the socket immediately. If you're running a real server and you suddenly disappear without warning, the client won't be aware of that straight away. For this reason, the operating system keeps listening on that port and if any clients try and connect or send data, it tells them that the connection is dead and they should stop using it.

During this time, the operating system won't let you reuse the port unless you really really really really want to. This is because you could be picking up connections that weren't meant for you, which would lead to lots of confusion. If you want to throw caution to the wind, though, you would do it like this:

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
send.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
send.bind(Addrinfo.tcp("0.0.0.0", 7777))
send.listen(1)

And we probably will for the sake of quicker development. It's worth avoiding this practice in real systems, though, for the reasons detailed here.

# Communicating across processes

This is all well and good, but we've been missing the point in the name of simplicity. I won't stand for it any more! Let's do this IPC style.

# server.rb
require 'socket'

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
send.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
send.bind(Addrinfo.tcp("0.0.0.0", 7777))
send.listen(1)

client_socket, client_addr = send.accept

client_socket.puts "Hello, world!"
client_socket.close

send.close
# client.rb
require 'socket'

recv = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
recv.connect(Addrinfo.tcp("0.0.0.0", 7777))

puts recv.gets
recv.close

Nothing has been added or removed, the code has just been separated out into two files. To see this in action, open up two terminals and in the first terminal run:

$ ruby server.rb

This will run the server. You'll notice that nothing happens but the program doesn't finish. That's to be expected.

In the other terminal run:

$ ruby client.rb

What happens next is that the client connects to the server, the server sends it the string "Hello, world!" and the client prints it out. Then both processes exit. Neat, right?

# Why does server.rb hang until client.rb runs?

Networking is fundamentally unpredictable. You can't know when clients are going to connect to you. Because of this, lots of networking calls will "block" until something significant happens. The line of code in our example that does that is the one with the accept method in it. accept will put the process to sleep until a client connects, at which point the process gets woken up and accept returns the appropriate connection information.

# Communicating across machines

Taking this example to its logical extreme, you probably want to communicate across machines. I understand, that's the entire point of networking, but this part is not as clear-cut as the rest of this post has been. Router configurations differ, firewalls can be pushy, things just might not work as you would like them to because reasons. As a compromise, I'm going to explain how to communicate between computers inside the same local network. This means two computers connected to the same router. It will demonstrate that you can in fact communicate between computers with the exact same code without going into the pain of router configuration and getting pissed off with the imperfect world we live in.

But first, we need some background knowledge...

# Finding the right IP address

A key thing to understand in this section is that networks are node and edge graphs. Your personal laptop connects to your router, and your router can then connect to other things, and those other things to even more other things and so on. Getting from your router to the website you're looking for involves "hopping" from one location to another until you eventually find what you're looking for. You can visualise it with the traceroute command:

$ traceroute xkcd.com
traceroute to xkcd.com (107.6.106.82), 64 hops max, 52 byte packets
 1  192.168.0.1 (192.168.0.1)  1.496 ms  1.329 ms  0.926 ms
 2  * * *
 3  02780898.bb.sky.com (2.120.8.152)  32.936 ms  31.230 ms  31.942 ms
 4  ae-1.r00.londen01.uk.bb.gin.ntt.net (83.231.199.161)  26.338 ms 26.777 ms  26.356 ms
 5  ae-6.r02.londen03.uk.bb.gin.ntt.net (129.250.3.2)  107.436 ms 107.745 ms  105.124 ms
 6  * * *
 7  ae-4.r22.nycmny01.us.bb.gin.ntt.net (129.250.3.126)  105.298 ms 142.151 ms  114.880 ms
 8  ae-2.r05.nycmny01.us.bb.gin.ntt.net (129.250.4.173)  115.527 ms 107.279 ms  108.187 ms
 9  ae-0.internap.nycmny01.us.bb.gin.ntt.net (129.250.201.26) 106.645 ms  106.486 ms  98.353 ms
10  border4.pc2-bbnet2.ext1.nym.pnap.net (216.52.95.77)  204.241 ms
    border4.pc1-bbnet1.ext1.nym.pnap.net (216.52.95.22)  106.519 ms
    border4.pc2-bbnet2.ext1.nym.pnap.net (216.52.95.77)  97.778 ms
11  inapvoxcust-1661.ext1.nym.net (63.251.26.30)  113.426 ms
    inapvoxcust-1662.border4.ext1.nym.pnap.net (63.251.26.42)  116.321 ms  122.317 ms
12  107.6.106.82 (107.6.106.82)  113.839 ms
    0.te1-2.tsr1.lga11.us.voxel.net (173.231.161.97) 97.906 ms
    107.6.106.82 (107.6.106.82)  108.450 ms

NOTE: traceroute is avaiable by default on Macs, but maybe not on Linux. In Arch Linux, I had to install the traceroute package to get the command.

# Understanding traceroute output

What even does that mean? traceroute cleverly utilises a field in network communication called Time To Live (TTL). Every packet you send somewhere has a TTL associated with it. Whenever that packet meets a hop, the hop will decrement the TTL. When the TTL gets to 0, the hop doesn't forward the packet on. Instead, it sends diagnostic information to wherever the packet came from. This information includes where the connection got to before running out of TTL.

traceroute sends a small amount of data with successively larger TTLs. This is what the number down the left hand side means. By doing this, it can make a note of every step of the journey for your packet.

You'll notice that numbers 2 and 6 are just stars. This means that no data came back for that TTL. This could be because the hop didn't respond in time or it just didn't bother to send you diagnostic information back. Like I said, imperfect world.

You'll also notice that some TTLs have multiple entries. This is rather advanced networky stuff that I don't fully understand, but the basic idea is that there may be multiple paths to the same destination and this is what traceroute is showing you.

The first hop is also quite interesting. You may have seen the address 192.168.0.1 before. By convention, this is the address that home routers use to address themselves on a local network. Addresses starting with 192.168 tend to refer to things inside your network. You can easily find out what IP address your machine has by running ifconfig and looking at the active connections:

$ ifconfig
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
    ether 3c:15:c2:bb:ed:7c
    inet6 fe80::3e15:c2ff:febb:ed7c%en0 prefixlen 64 scopeid 0x4
    inet6 fd0c:d42b:9aef::3e15:c2ff:febb:ed7c prefixlen 64 autoconf
    inet6 fd0c:d42b:9aef::a8f7:981c:1fe3:be50 prefixlen 64
    autoconf temporary
    inet 192.168.0.18 netmask 0xffffff00 broadcast
    192.168.0.255
    nd6 options=1<PERFORMNUD>
    media: autoselect
    status: active

I cut down the output to my only active network interface. The line we're interested in is:

inet 192.168.0.18 netmask 0xffffff00 broadcast

This tells us our address on the local network is 192.168.0.18. This will be important later.

# Routing traffic

Machines decide where to hop to next by using "routing tables". If you want to view the routing tables on your machine, you can run the following command:

$ netstat -nr

NOTE: Again, this command is there by default on Mac but not on Arch. net-tools strikes again.

The routing tables for your local machine are very thin. Your computer doesn't know a whole lot about the world, it leaves the problem of routing traffic to your router (clue was in the name, really). You'll notice entries in the output that are familiar to you, such as 127.0.0.1. You'll also notice that your local IP address is mapped to 127.0.0.1. Why do you think that is?

The Internet is a constantly changing beast, how does your router keep up with it? The truth is that it doesn't. This is why it's useful to have the graph structure. If your router is told to try and find 90.223.224.84, it doesn't have a clue. It does know, however, that its mate in London knows a thing or two about the 90.x.x.x IP address range, so it might send the request over there and trust its friend to route the address appropriately. This is the hopping we've been referring to.

# Network Address Translation

It may have occurred to you earlier that if most private home networks assign addresses that start with 192.168, that means that millions of computers all over the world will have those addresses, so how does traffic get routed to them correctly?

That's an excellent question, and the answer lies in three letters: NAT. Network Address Translation. Different routers may implement this differently but the high level concept is that every connection made that goes from inside the local network to outside the network gets rewritten so that it seems as if it was the router itself that made the connection. The router will have been assigned a more globally reachable IP address by your Internet Service Provider. The router then holds a lookup table of what responses need to go to which IP addresses / ports inside the network when they eventually return.

# Bringing all of this together

Now that we have a reasonably good idea of what moving parts are involved, the modified code that should work across machine is actually trivial.

# server.rb
require 'socket'

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
send.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
send.bind(Addrinfo.tcp("0.0.0.0", 7777))
send.listen(1)

localhost = Socket.ip_address_list.detect(&:ipv4_private?)
puts "Clients should connect to: #{localhost.ip_address}:7777"

client_socket, client_addr = send.accept

client_socket.puts "Hello, world!"
client_socket.close

send.close
# client.rb
require 'socket'

target_ip = nil # SET THIS TO THE OUTPUT FROM server.rb!

recv = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
recv.connect(Addrinfo.tcp(target_ip, 7777))

puts recv.gets
recv.close

If you run server.rb on one machine, note down the IP address it outputs, correctly configure client.rb on another machine and run it, you'll get the exact same result as we did way back in the first example of the script.

Boom. Networks.

# Wait a sec. Servers aren't supposed to die after one connection. What gives?

You're absolutely correct. My bad. Here's a fix for that:

# server.rb
require 'socket'

send = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
send.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
send.bind(Addrinfo.tcp("0.0.0.0", 7777))
send.listen(1)

localhost = Socket.ip_address_list.detect(&:ipv4_private?)
puts "Clients should connect to: #{localhost.ip_address}:7777"

loop do
  client_socket, client_addr = send.accept

  client_socket.puts "Hello, world!"
  client_socket.close
end

Now you can run client.rb as many times as you want and you'll get the same reply from the server each time. You'll have to kill the server manually when you want it to stop, though. You can do that using the usual ctrl + c key combo.

# Wrapping up

We've covered a lot of ground in this post. From the humble beginnings of sockets inside of the same process, we've worked all the way up to sending data from a process on one machine to a process on another machine in the same network. As an added bonus, if you managed to follow all of the information in this post you will have a pretty reasonable idea of how the data gets from one machine to the other as well!

I had planned on working up to a functioning web server in this post but it's gone past 5000 words up to now, so I'll break that part out into a second post.

Thanks for reading! :)