2.1 TCP Socket Lifecycle
The previous chapter traced data to a destination host. A socket is the handle a program can actually operate. It is neither another protocol nor an alias for a port; a process uses it to ask the kernel network stack to connect, accept, send, and recv.
This lesson begins with a loopback experiment, then uses real return values to explain short reads, EOF, half-closure, and errors. Once the connection closes correctly, the next lesson tackles the harder fact that a TCP byte stream has no application-message boundaries.
1. The Basic States of Server and Client
A typical TCP server call flow:
socket
↓
bind local address
↓
listen
↓
accept ──> connected socket ──> recv/send
│
└──────> next acceptA typical TCP client call flow:
socket
↓
connect remote address
↓
send/recv
↓
shutdown/closeaccept returns a new connected socket; the listening socket keeps its role. Closing one accepted socket leaves the listener available, and closing the listener does not automatically close previously accepted sessions.
2. Complete the First Communication over Loopback
import socket
import threading
def receive_until_eof(connection):
chunks = []
calls = 0
while True:
chunk = connection.recv(7)
calls += 1
if chunk == b"":
return b"".join(chunks), calls
chunks.append(chunk)
def serve(listener, result):
try:
connection, peer = listener.accept()
with connection:
connection.settimeout(2)
request, calls = receive_until_eof(connection)
result["peer"] = peer
result["recv_calls"] = calls
connection.sendall(request.upper())
except Exception as error:
result["error"] = error
def receive_all(connection):
chunks = []
while True:
chunk = connection.recv(4096)
if chunk == b"":
return b"".join(chunks)
chunks.append(chunk)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
listener.listen()
listener.settimeout(2)
address = listener.getsockname()
observed = {}
thread = threading.Thread(
target=serve,
args=(listener, observed),
)
thread.start()
try:
with socket.create_connection(address, timeout=2) as client:
client.sendall(b"hello ")
client.sendall(b"socket")
client.shutdown(socket.SHUT_WR)
response = receive_all(client)
finally:
thread.join(timeout=3)
assert not thread.is_alive()
if "error" in observed:
raise observed["error"]
assert response == b"HELLO SOCKET"
print(response.decode())
print(f"server recv calls: {observed['recv_calls']}")Save the complete block as socket-lifecycle.py and run it with Python 3. It listens only on 127.0.0.1; port 0 asks the system to choose an available port:
python3 socket-lifecycle.py
# HELLO SOCKET
# The server recv call count can vary between runs.The example writes twice and requests at most 7 bytes per recv. Its 12 bytes need at least two nonempty reads plus one EOF read, so the printed count is at least 3 but can vary. The server replies only after EOF. Remove the client’s shutdown(SHUT_WR) and both sides wait until an operation times out. Each blocking operation has a 2-second timeout; that is not a deadline for the whole experiment.
3. TCP Provides a Byte Stream, Not Message Boundaries
Client:
client.sendall(b"hello ")
client.sendall(b"socket")With a receive limit of 4096 bytes instead, the server might see:
"hello " + "socket"
"hel" + "lo sock" + "et"
"hello socket"These splits are all valid when byte order is preserved. On a connected TCP socket, recv(4096) may:
- Return between 1 and 4096 bytes;
- Return partial data without waiting for a complete business message;
- Be interrupted by a signal or retried via language-level retry mechanisms;
- Throw an exception after a timeout;
- Return an empty byte array, indicating that the remote endpoint has cleanly signaled EOF on the send side.
Thus, the pattern "send once, receive once" only appears to hold in rare, local, small-data test scenarios. Applications must define their own message framing: using a length prefix, delimiter, fixed-length format, or self-describing structure.
The EOF rule assumes a positive requested length: recv(0) returns an empty result without testing peer shutdown. Since Python 3.5, methods such as recv retry interrupted system calls when the signal handler does not raise. A nonblocking socket with no available data raises BlockingIOError, which is also different from EOF.
4. sendall and short writes
The underlying send/write may accept only part of the supplied buffer. This may happen because the socket send buffer temporarily lacks space, or because a nonblocking socket can only accept a partial amount immediately.
On a blocking or timeout-mode socket, Python sendall keeps sending until the local network stack accepts all bytes or an exception occurs. It does not switch a nonblocking socket into blocking mode or replace an event loop’s pending-write state. Handling short writes does not guarantee:
- The receiving application has read the data;
- The data has been durably persisted;
- The receiving end will send a business acknowledgment;
- The call will never block;
- Messages from multiple threads won't interleave.
A C program must manage its own offset and handle EINTR, short writes, and EAGAIN. When sending large responses, it must also implement backpressure to prevent each slow client from indefinitely consuming memory.
If sendall raises, its return value does not tell you how many bytes were sent. Resending the whole message can duplicate an operation. Since Python 3.5, its timeout covers the complete call rather than restarting after each successful partial write; the request as a whole still needs its own deadline.
5. EOF, close, and Half-Close
The client finishes sending its request before waiting for the uppercase response. Half-close leaves the other direction usable, which suits this example’s EOF-delimited request.
Formally, shutdown(SHUT_WR) closes the sender side of the socket while preserving the receiver side. In the experiment, the client uses this to signal the server that the request data has ended, and it can still read the response.
In the usual Unix descriptor model, close releases this fd’s reference. A duplicate fd in the same process, or one inherited through fork, can still refer to the socket. Releasing the last reference triggers the underlying close, while the kernel may retain TCP state afterward. Closing an fd does not confirm delivery of pending data.
For TCP:
recv == b""indicates that the remote end has gracefully closed its send direction;- timeout means the operation did not finish within its time budget, but does not imply the remote end has closed;
- connection reset signifies an abnormal termination of the connection;
- connection refused typically means the destination is reachable, but either no listener is running on that endpoint or the connection was actively rejected;
- broken pipe/reset during send means further transmission over the original connection is no longer possible.
Different languages map errno values to distinct exceptions. Production code must handle errors based on operation context and protocol state, rather than retrying every failure indiscriminately.
6. Resolve and Try Candidate Addresses
getaddrinfo accepts a host, service, and socket constraints, then returns candidate addresses. This minimal serial connector tries alternatives instead of taking only the first IPv4 result:
import socket
import math
def connect_any(host, service, timeout):
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
errors = []
candidates = socket.getaddrinfo(
host,
service,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
for family, socktype, protocol, _, address in candidates:
connection = None
try:
connection = socket.socket(family, socktype, protocol)
connection.settimeout(timeout)
connection.connect(address)
return connection
except OSError as error:
errors.append(error)
if connection is not None:
connection.close()
raise OSError(f"all addresses failed: {errors}")This timeout applies to each candidate, excludes the getaddrinfo wait, and remains set on the returned socket, which the caller must close. Multiple failures can accumulate. Mature dual-stack connectors can use Happy Eyeballs to stagger overlapping attempts and cancel the others after one succeeds. A plain resolver provides addresses; that alone does not implement a TCP connection race. Check the client library in use.
Server binding to 0.0.0.0 or :: indicates a wildcard for the corresponding address family, not that the server is listening to "all machines on the internet." Actual reachability is still governed by routing tables, firewalls, namespaces, cloud security groups, and NAT rules.
7. listen backlog and accept queue
listen(backlog) requests a queue for connections awaiting acceptance. Its precise meaning and effective limit depend on the operating system. It does not set the maximum number of clients served concurrently:
- Connections that have been accepted no longer occupy the same position in the backlog;
- The application's concurrency ceiling is determined by worker/event loop capacity and system resource constraints;
- The implementation details for SYN packet processing and completed handshake queues may be decoupled;
- The kernel may truncate excessively large backlog values;
- If the application is too slow to process accepts, new connections may be delayed, retransmitted, or fail entirely.
When troubleshooting connection establishment failures, monitor SYN arrival rates, handshake completion rates, accept throughput, file descriptor limits, and application-side queue depth simultaneously.
8. SO_REUSEADDR Is Not a Port Sharing Switch
After a development server restarts, bind can fail because an address is already in use or an earlier connection still constrains address reuse. SO_REUSEADDR can relax some local address reuse constraints in common Unix TCP server scenarios, but the exact semantics vary across platforms.
It does not mean that any two processes can safely bind to the same endpoint. SO_REUSEPORT is another option where the kernel may distribute new flows among multiple listeners; however, load balancing, permission models, and platform support must be verified in the relevant documentation.
Do not use these options to mask issues like "an old process is still running" or "the wrong address is being bound." First, use ss/lsof to verify the actual listener.
9. TCP and UDP Have Different Socket Semantics
A successful UDP recvfrom receives one datagram. A buffer that is too small can yield truncated data or a platform-specific error; a later read cannot recover the remainder. Zero-length UDP datagrams are valid, so receiving b"" does not carry TCP’s EOF meaning.
UDP does not automatically provide:
- Handshake to establish a connection;
- Reliable retransmission;
- In-order delivery;
- Deduplication;
- Flow control;
- Congestion control.
Calling UDP connect typically just sets a fixed default peer and lets the kernel handle peer filtering and report some errors, without establishing TCP-style connections over the network.
After choosing UDP, either accept its semantics or use protocols like QUIC (which already define recovery, congestion control, and security) rather than arbitrarily inventing a "loss-free UDP."
10. Concurrency Models Begin Branching After Accept
Common server-side models:
| Model | Advantages | Primary Boundaries |
|---|---|---|
| One thread per connection | Intuitive control flow | Thread stack, scheduling, and thread count limits |
| Fixed worker pool | Controlled concurrency | Blocking tasks can saturate the pool |
| Event loop / non-blocking | Low overhead for large numbers of idle connections | State machine complexity, CPU-bound tasks blocking the loop |
| Async/await | Syntax resembles sequential flow | Still requires backpressure and cancellation mechanisms |
| Multi-process | Stronger fault and memory isolation | Inter-process communication (IPC), shared listeners, and deployment complexity |
The model itself does not determine throughput. Whether a request is CPU-bound, I/O-bound, or involves long-lived idle connections is what defines the resource bottleneck. Regardless of the model, connection limits, request size, idle time, and in-flight work per client must all be constrained.
11. Resource and Security Boundaries
An external listener must defend against at least the following threats:
- Clients sending incomplete requests after connection establishment;
- Declaring large request sizes but transmitting them slowly;
- Slow response reading that blocks the server’s send buffer;
- Opening excessive connections, exhausting file descriptors, memory, or worker processes;
- Sending malformed framing that causes parsers to go out of bounds;
- Reusing connection state in race conditions during closure or cancellation.
Setting timeouts is only one part of the solution. Additional safeguards are required, including maximum frame and request sizes, per-connection buffer limits, connection concurrency caps, global deadlines, a budget for pre-authentication work, and consistent resource cleanup.
12. Summary
The correct mental model for a TCP socket is a "stateful full-duplex byte stream endpoint":
- A listening socket is different from the connected socket returned by accept;
- TCP guarantees ordering but does not preserve
sendboundaries; recvmust be looped, and an empty result indicates EOF on the send side;sendallhandles short writes, which does not imply success from the peer's application layer;shutdownallows half-closing one direction of the connection;- DNS resolution should support multiple candidates and both IPv4 and IPv6;
- backlog, worker count, file descriptor, and buffer are distinct resources;
- UDP preserves datagram boundaries but does not provide TCP's reliability semantics.
The loopback connection now sends a request, reaches EOF, and returns a response after a half-close. Next, 2.2 Message Boundaries, Deadlines, and Backpressure keeps a connection open for messages with explicit lengths and limits.