Combined Failure Simulator: Break a Layer, Watch It Propagate
Application → kernel → network → remote: exhaust descriptors, fill a buffer, block a thread, drop packets, add latency, kill the process, restart the server — and follow each failure across the layers to the symptom the user sees and the tool that proves it.
The problem
The model: four layers, two machines
The simulator models a client application, its kernel (descriptor table, socket buffers, TCP state), the network between (loss, delay), and a server with the same three layers in reverse. Every control injects one fault into one layer; the display shows the fault propagating as state changes — queue depths, window sizes, thread states, error codes — until it reaches the user as a symptom. The numbers are simulated: the timings and sizes are chosen to make the propagation visible, not measured from a real system. The propagation *paths* are real, and they are what to learn.
The lesson is in the direction of travel. Faults in the application propagate down and across: a blocked thread stops draining a buffer, the buffer’s fullness becomes a window, the window crosses the network as an ACK field, and it stops a thread on the other machine. Faults in the network propagate up: a lost packet becomes a retransmit timer, then a stalled send buffer, then a blocked write(), then a request timeout in user code. Learning to run these chains in both directions is the skill the Capstone: Three Seconds from Warsaw tests.
The seven controls
Each row of the matrix is one control: where the fault lands, how it propagates, what the user experiences, and the command that confirms it on a Linux host. The confirming tool is chosen to answer one question — "is the fault in this layer?" — because that is how a real diagnosis proceeds (see The OS Debugging Playbook: Four Symptoms, Fourteen Causes and The Tools, and Which Layer Each One Answers). Read the rows as chains, not as facts.
Two of the controls deserve a closer look because their propagation crosses domains twice. Block thread on the server: the handler thread stops calling read(); the server kernel’s receive buffer for that connection fills; the window advertised to the client shrinks to zero; the client’s kernel stops sending and starts zero-window probes; the client’s send buffer fills; the client’s write() blocks (or returns EAGAIN, or its stream returns false); the client’s request times out at the application layer. Six state changes, three layers, two machines — from one thread doing CPU work. Drop packets: the client’s segment is lost; the server never ACKs; the client’s retransmission timer (RTO, minimum 200 ms on Linux) fires and resends with cwnd collapsed; on repeated loss the RTO doubles each time; the send buffer fills behind the unacknowledged bytes; write() blocks; the request slows from milliseconds to seconds with no error until a timeout — the classic "the network is fine, ping works" incident (see Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO and the challenge A 1 Gbit/s link, and the backup crawls at 9 Mbit/s).
| Control | Failing layer | Propagation | Symptom the user sees | Tool that confirms it |
|---|---|---|---|---|
| Exhaust file descriptors | Server kernel: per-process fd table | accept() → EMFILE; connections stay in accept queue; loop may spin; backlog overflows; client SYNs dropped | connect hangs or times out; server CPU 100% doing nothing | ls /proc/<pid>/fd | wc -l vs ulimit -n; strace shows EMFILE; ss -ltn Recv-Q at limit |
| Fill socket buffer | Server kernel: receive buffer | app not reading → Recv-Q at max → rwnd 0 → client send buffer fills → client write() blocks/EAGAIN | client request hangs; sender "waiting on network" | ss -tmi: Recv-Q full on server, Send-Q full and snd_wnd 0 on client |
| Block thread | Server app: handler thread / event loop | no read() → receive buffer fills → rwnd 0 → sender stalls; in an event loop, every connection stalls | all requests slow or hung; timeouts | top shows R (CPU) or D/S (blocked); py-spy/jstack/perf on the thread; ss shows Recv-Q |
| Drop packets | Network | lost segment → no ACK → RTO (≥200 ms) → cwnd collapse → send buffer backs up → write() blocks | requests slow, no errors, then timeout; ping may look fine | ss -ti retrans counter; nstat TcpRetransSegs; tcpdump shows retransmissions; mtr shows loss |
| Add latency | Network | RTT up → handshake, TLS, every round trip up → throughput capped at buffer/RTT → cwnd growth slow | everything slower proportionally; downloads capped | ping/mtr RTT; ss -ti rtt; curl -w timings show connect and TTFB scaling with RTT |
| Kill process | Server app | kernel closes all fds → FIN (or RST if data unread) to every client → listen socket gone → new connects get RST | ECONNRESET on in-flight requests; ECONNREFUSED on new ones | ss -ltn shows nothing listening; dmesg/journal for OOM-kill or signal; client sees RST in tcpdump |
| Restart server | Server app + kernel | listen socket reappears; bind may fail with EADDRINUSE without SO_REUSEADDR; backlog refills; cold caches; connection storm | brief ECONNREFUSED then a slow first minute; thundering-herd retries | ss -ltn shows LISTEN again; strace bind; latency graph shows the cold-start hump |
Reading a propagation chain
The ladder below is the "Block thread" chain end to end, with the state that changes at each rung. Notice that every rung is observable with a different tool on a different machine, and that an engineer who only has one of those tools will name the wrong layer: the client team sees a network stall; the network team sees a zero window from the server and a healthy path; the server team sees a busy thread and a healthy socket. All three are right about their layer and wrong about the cause unless they read the chain.
The general procedure: find the rung where the state is at its limit — a buffer at its size, a window at zero, a descriptor count at ulimit, a queue at its backlog — then ask what drains that state and why it stopped. The drain of a receive buffer is a thread; the drain of a send buffer is the network and the far window; the drain of an accept queue is accept(); the drain of a retransmit queue is an ACK. Whatever should be draining it and is not is the next rung up.
- Server handler thread runs a 30 s CPU loopstate: thread R (running), not in read()↓
- Server receive buffer fillsstate: Recv-Q → SO_RCVBUF↓
- Server advertises rwnd 0state: window field in every ACK = 0↓
- Client TCP stops sending; zero-window probesstate: snd_wnd 0; probes with backoff↓
- Client send buffer fillsstate: Send-Q → SO_SNDBUF↓
- Client write() blocks / EAGAIN / falsestate: client thread S (sleeping) or stream paused↓
- Client request deadline firessymptom: timeout error; "network is slow"
Confirming, not guessing
The transcript below is the same chain seen from the two hosts with the tools from the matrix. The habit to build is to run both sides before naming a cause: a stalled connection has a sender and a receiver, and the kernel on each records which queue is full. When the queues are empty on both sides and the request is still slow, the fault is above the kernel (the application is slow to respond, not slow to read) or in a layer the kernel cannot see (DNS, TLS, a proxy).
The Break the OS: Predict, Break, Diagnose and Network Failure Lab: Predict, Inject, Observe, Diagnose simulators inject faults in one domain each; this one exists because production incidents do not respect the boundary. The capstones for each domain — Capstone: A Server With 50,000 Concurrent Connections and Capstone: What Happens When You Visit https://example.com — and the Capstone: Three Seconds from Warsaw ask you to do this without the simulator’s labels.
server$ top -H -p $(pidof api) PID USER %CPU S COMMAND 8813 app 99.7 R api <- one thread running, not in read() server$ ss -tmi sport = :8080 | head -3 ESTAB 262144 0 10.0.0.9:8080 10.0.0.5:51120 <- Recv-Q at SO_RCVBUF: app not reading skmem:(r262144,rb262144,...) client$ ss -tmi dst 10.0.0.9 ESTAB 0 4194304 10.0.0.5:51120 10.0.0.9:8080 <- Send-Q at SO_SNDBUF ... snd_wnd:0 ... probes:7 ... <- zero window, probing with backoff client$ strace -p $(pidof worker) -e write write(7, "...", 65536 <- blocked; never returns
Key points
- Application faults propagate down and across: a thread that stops reading becomes a zero window on the wire and a blocked
write()on the other machine. - Network faults propagate up: a lost segment becomes an RTO, a collapsed
cwnd, a full send buffer, a blockedwrite(), a request timeout — with no error until the deadline. - Every rung of a chain has a state that can be observed with a specific tool on a specific host; diagnosis is finding the rung whose state is at its limit and asking what should drain it.
- Descriptor exhaustion shows as
EMFILEinstraceand a full accept queue inss -ltn; a killed server shows asECONNRESETfor in-flight andECONNREFUSEDfor new connections; a restart shows as a cold-start latency hump and a retry storm. - Run the tools on both ends before naming a layer; each team’s tool is right about its layer and wrong about the cause.
- The simulator’s numbers are simulated; its propagation paths are real.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does a CPU-bound thread on the server look like a network problem to the client?
Because the only thing the client can observe is the wire, and on the wire the server’s inability to read appears as a zero receive window — a flow-control message. Flow control is designed to make a slow application look like "please wait" to the peer; it succeeds, and the peer cannot tell why.
▸Why does packet loss produce slowness rather than errors?
TCP’s contract is reliable delivery, so it retransmits silently with exponential backoff rather than reporting loss. The application sees delay, and only its own deadline turns delay into an error. This is by design and is why "slow, no errors" should prompt ss -ti for retransmits.
▸Why is a restart a failure mode at all?
Because the listen socket disappears and reappears (refused connections in between, or EADDRINUSE if the old socket lingers), every client reconnects at once (a connection storm into an empty backlog), and every cache — page cache, JIT, connection pools, DNS — is cold. A restart is several small faults in sequence.
OS + network failure simulator
All four layers healthy. Choose a fault.
How it fails
What the failure looks like from inside real software.
- Descriptor exhaustion on a server whose accept loop retries on
EMFILEwithout sleeping: 100% CPU, no connections accepted, and the backlog overflows so new clients time out instead of getting an error. - An event-loop server with one blocked handler: every client’s
write()stalls at once; the client fleet’s connection pools drain; a retry storm arrives when the handler finishes. - 5% packet loss on one path: transfers slow 10× with zero errors; the dashboard shows healthy servers and healthy links because the loss is on a middlebox nobody graphs.
- Server killed by the OOM killer mid-response: clients see
ECONNRESETwith a partial body; those that retried non-idempotent requests duplicated writes. - Restart without
SO_REUSEADDR:bind: Address already in usefor up to 60 s ofTIME_WAIT; the service is down for a minute after every deploy.