The Nine Bugs That Never Threw an Error
lol hi. So these past few weeks, I wrote a BitTorrent client from scratch in Go — no libraries, no shortcuts. Three weeks, 98 commits, and a long education in how quietly networked code can fail.
It’s interesting what got me here but first let me tell you about something. The choking algorithm I wrote. The algo compiled on the first try. Its unit tests passed. I ran the client with -seed, pointed a second client at it, and watched absolutely nothing happen.
No error. No panic. No angry log line. Two processes sat there, connected, politely exchanging silence.
That afternoon turned into an audit that found nine separate bugs in the seeding path. Not one of them had produced an error message. Not one had a failing test pointing at it. I’ll come back to those, because they’re the most interesting thing I learned in this whole project — but first, some context on how I got there.
It started with a word I didn’t know
So it all started when I was discussing torrents with my friends on Discord. Someone said it completely casually — yeah, my PC, it’s seeding — and I asked what’s that. Everbody went mute lmao. It’s obvious none of us actually knew what that meant. I had been downloading torrents for years and I couldn’t have explained it either.
It was the curiosity — the kind you only get at 1 AM, when the only thing you know is that you want to dive deep into it.
So I researched, and found that BitTorrent is a good teacher for exactly that. It has a real specification, so there’s an answer key. The next thing I found was that it’s a lot about concurrency, and that really excites me. Dozens of peers, all unreliable, all talking at once, some of them actively hostile. Nothing arrives in order, and nothing is guaranteed to arrive at all.
And it has a verification step built right in — every piece is hash-checked — so “did I get this right” isn’t a matter of opinion. Either the SHA-1 matches or it doesn’t.
Most importantly, when it works, you can see it work. A real file shows up on disk, downloaded from strangers, byte-for-byte identical to what the Debian project published. That’s a much better feedback loop than a toy project where correctness is a vibe.
I gave myself one rule before writing a single line.
Zero dependencies
No third-party runtime dependencies. At all. go.mod has no require block in it to this day.
That means bencode — BitTorrent’s serialization format — by hand. Tracker HTTP client, by hand. The peer wire protocol, framing and all, by hand. Piece geometry, SHA-1 verification, the concurrent download engine, disk I/O, resume logic. All of it.
I want to be honest about why: it wasn’t a principled stance about supply chains. It was that importing a bencode library would have deleted the exact thing I was there to learn. Every library you pull in is a thing you now don’t understand. For a production service that’s a fantastic trade. For a project whose entire purpose is understanding, it’s backwards.
The rule had a side effect I didn’t anticipate. It forced me to actually read Go’s standard library instead of skimming it, and the standard library is good. net/netip gives you comparable, zero-allocation IP addresses that work as map keys — I would never have found it if I'd grabbed some peer-address struct off GitHub. net.Pipe turned out to be the single most useful testing tool in the project. log/slog, crypto/rand, httptest — all right there.
I also structured the code as seven packages with dependencies pointing strictly in one direction. bencode at the bottom, which has never heard of a torrent. metainfo above it, which never opens a socket. peer, which speaks the wire protocol but has no opinion on which piece to request. download at the top, the only package that sees the whole system. That layering felt like bureaucracy in week one and saved me constantly by week three, because when something broke I could rule out entire layers instead of staring at all of it.
Starting at the very bottom
First real code was the bencode decoder. Integers, byte strings, lists, dictionaries. It’s a boring format — deliberately so — and it took me two sittings, five days apart.
The part that mattered wasn’t parsing. It was the info hash.
A torrent’s info hash is the SHA-1 of its info dictionary, re-encoded in canonical form, and it's the identity of the entire torrent. Get it wrong by one byte and every tracker on earth politely tells you it's never heard of this torrent. The failure mode is indistinguishable from "the tracker is down" or "this torrent is dead," which is a horrible place to be debugging from.
So I computed it two independent ways. Method A re-encodes the parsed dictionary through my own encoder and hashes the result. Method B hashes the original bytes directly, located through byte offsets my decoder records while parsing. Parse refuses to return unless both agree.
Here’s the thing — right now that check can only ever pass. My decoder already rejects dictionaries whose keys aren’t in canonical order, so the two methods are structurally guaranteed to match. I left it in anyway. The day I relax key ordering to accept real-world torrents that don’t quite follow spec, that check becomes the only thing standing between me and a silently wrong info hash. It’s there for a crash that hasn’t happened yet, but might.
I also pinned every parsed field against transmission-show output. Not against my own program's results — against a completely different implementation's. It is the one test in the suite that can catch a bug my own reasoning would reproduce and miss, which is exactly the kind of bug you can't catch just by being careful.
The first handshake
September 2nd. Dialed a real IP address that Debian’s tracker had handed me, sent 68 bytes, and got 68 bytes back with a matching info hash.
I genuinely sat there grinning at my terminal. Some stranger’s qBittorrent had just agreed to talk to 137 lines of Go I’d written that morning.
The reality check came immediately after: most of the addresses didn’t answer at all. On one live run only 9 out of 37 tracker-returned addresses were reachable — a 24% success rate. My first instinct was that my dialing code was broken. It wasn’t. Peers go offline, NAT eats connections, tracker lists go stale. That’s just what a public swarm looks like.
Which taught me something I now think is a general principle: I’d been collapsing every failure into one generic “connection failed” error, and that made a perfectly healthy dead-swarm situation look identical to a real bug in my code. Connection-refused, handshake timeout, protocol mismatch, and info-hash mismatch are four completely different events. Once I split them apart, “the swarm is quiet today” stopped looking like an emergency.
A gigabyte of RAM to download 755 megabytes
The first working concurrent download did something embarrassing. It assembled every verified piece in memory and wrote the whole file out at the end.
For the test torrents I’d been using — a couple of megabytes — this was invisible. Then I pointed it at a 755 MiB Debian ISO and watched the process climb past a gigabyte of RSS.
Obvious in hindsight. The fix is that each verified piece gets written to its exact byte offset with WriteAt and immediately released. Peak memory went from around 1.0 GiB to 9.4 MiB, roughly a hundredfold drop.
The number I actually find more convincing is a different one: a 1.5 MB download peaks at 3.2 MiB. The file is 500 times smaller and the memory barely moves. That’s the real proof that usage tracks the pieces in flight rather than the size of the file, which is the property you actually want.
There was a nice bonus in WriteAt I didn't see coming. Because every piece owns a disjoint byte range, concurrent positional writes to non-overlapping regions need no lock at all. No mutex, no write queue, no coordination. The data layout basically made the concurrency problem go away instead of me having to solve it.
The refactor whose benchmark went the wrong way
My first concurrency design was a plain work queue: one buffered channel pre-filled with every piece, one goroutine per peer pulling from it. It worked. It was simple. Duplicate requests were impossible by construction, since a piece taken off the channel was gone.
It also had a hard ceiling. No component ever saw more than its own connection, which makes rarest-first selection, swarm-wide duplicate prevention, and endgame mode structurally impossible to build on top of. Not hard — impossible. You can’t prioritize the rarest piece in the swarm when nobody can see the swarm.
So I rebuilt it around a coordinator: one goroutine owning every piece’s status, its availability across all peers, the peer registry, and every in-flight assignment. Workers reach it only through channels. No mutex guards any of that state, because nothing outside that one goroutine ever touches it.
I was pretty pleased with myself. Then I measured it.
Get asmanya’s stories in your inbox
Join Medium for free to get updates from this writer.
Wall-clock time got worse. The work queue’s median was 6m33s at about 1,970 KiB/s; the coordinator came in at 10m32s and 1,224 KiB/s.
I sat with that for a while, and then I put it in the README exactly as measured.
The honest reading is that three runs against a public swarm can’t support any conclusion about throughput. Each design’s three runs individually spanned over a minute of spread. Peer counts and connection success rates swung by double digits run to run, entirely because of which addresses happened to be alive that hour. That noise is far louder than a piece-selection policy could plausibly be on a single 755 MiB download.
But I couldn’t honestly claim a win either. So the case for the coordinator rests on the one number that isn’t confounded: duplicate piece requests dropped from a median of 50 to 13 after I fixed two bugs the refactor had introduced. Same code, same day, nothing else changed.
Both of those bugs were in endgame mode — the phase where you request the last few pieces from every peer that has them. The first one assigned each idle peer the first incomplete piece it had, so every peer converged on the same single piece while the rest of the endgame set sat untouched. I had built a feature to parallelize the tail of a download and it was serializing it instead. The second triggered endgame too early, because the condition counted only strictly-unassigned pieces and ignored ones already in flight.
If there’s one thing I’d want someone to take from this post, it’s this: I could have quietly dropped that comparison table. Nobody would have known. But a benchmark you only publish when it agrees with you isn’t really a benchmark anymore, it’s just marketing.
Back to the nine
So: seeding.
The one word that started this whole project, and it turned out to be the part I got most wrong. Nine bugs, zero errors. Here are the ones worth your time.
It never sent its own bitfield. After the handshake, a client announces which pieces it has. Mine didn’t. So no peer had any reason to ever request anything from it. Everything downstream of that was dead code that happened to be correct.
It waited for the other side to unchoke it. Left over from the download path — the connection loop blocked waiting for the peer’s permission to download. But a peer that connected purely to download from me has no reason to ever unchoke me. So it’d sit blocked until the timeout, then drop a connection it should have been serving the whole time.
Incoming requests were dropped during a piece download. I had message handling in two places: the idle loop, and the in-flight download loop. The second silently ignored incoming block requests and choke commands. Consolidating to a single handler wasn’t a style preference, it was the fix.
readLoop leaked a goroutine per connection. Closing a socket does not wake a goroutine that's already blocked sending its last message into a channel nobody's reading anymore. One leak per finished connection, multiplied by a swarm.
A duplicate bitfield double-counted availability. Same peer sends its bitfield twice, and now every piece it holds is counted twice in swarm-wide rarity. Rarest-first quietly starts making decisions on fiction.
The pattern is what got me. Every single one of these was a piece of code that ran, returned normally, and did the wrong thing. There is no exception to catch. There is no test to fail, because the test you’d need is the one that never occurred to you to write.
What finally exposed them wasn’t more unit tests. It was building one honest end-to-end scenario: a real coordinator, a real connection, and a fake leecher that connects, asks for pieces, and never reciprocates. That’s a permanent regression test now. It’s the only test in the suite that would have caught all nine.
The timer that could never fire
My favorite bug of the whole project, because it’s so stupid and so invisible.
I had stall detection: if no piece completes for 30 seconds, re-announce to the tracker and get a fresh peer list. The code looked fine. It had been there since the work-queue days.
select {
case <-ticker.C: // fires every 1 second
// update progress display
case <-time.After(stallTimeout): // 30 seconds
// re-announce
}time.After creates a new timer on every trip through the loop. The 1-second progress ticker fires first, every single time, and the loop starts over with a brand new 30-second timer that will also never survive to fire.
Stall re-announce had never worked. Not once. Not in either concurrency design. There was no symptom — the client just occasionally took longer than it should have, which is indistinguishable from a slow swarm.
A related one in the same commit: Progress.Rate() only pruned its sample window when a new sample arrived. So during an actual stall, when no samples were arriving, it cheerfully kept reporting the last speed it had computed. The display was lying to me about the exact condition I was trying to detect. Both fixed by moving the stall check onto the ticker that was already firing, and having Rate() return zero when its newest sample is older than its own window.
What testing actually earned its keep
I ended up with slightly more test code than production code — 5,040 lines against 4,733 — and 77% statement coverage. But the coverage number is close to meaningless. What mattered was the shape of the tests.
net.Pipe was the best tool I found. It hands you two connected net.Conn ends in memory, which means you can write a hostile peer: one that sends a bad handshake, one that splits a single message across three writes, one that merges two messages into one read, one that claims a message is four gigabytes long. Those split/merge cases are the classic bugs that never reproduce on fast local networks and then happen constantly against a real peer.
(One trap worth knowing: net.Pipe's two ends aren't independent the way real TCP sockets are. Closing one end makes I/O on the other end start failing too. I lost an evening to a test that closed its fake peer right after writing a response, racing code that was still finishing up on the other side. The fix was for the test to stop closing early — not to add synchronization my production code didn't need.)
The other decision that paid off was keeping piece-selection policy completely separate from socket mechanics. Because the coordinator is a pure state machine — events in, commands out — I can test rarest-first selection, duplicate prevention, peer churn, assignment timeouts and endgame cancellation by feeding it events directly. No network, no fake peers, no timing. The entire coordinator suite runs in milliseconds and is perfectly deterministic.
Then, at the end, I profiled an actual download with pprof. 62% of wall-clock time was the process blocked on the OS. Of the CPU that was actually spent, over half went to the socket syscall boundary and 14.5% to fsync. SHA-1 — the one place this client does real computation — was about 2.5%. My coordinator's selection and bookkeeping logic didn't register as a hot path at all.
Which is exactly right, and I’m glad I checked instead of assuming. I had vaguely worried that scanning every piece on every assignment was too naive. So I benchmarked it at 50,000 pieces — around 15x the largest torrent I’ve actually downloaded — and it came in at 140 microseconds. The O(n) design I’d hand-waved past is fine by a wide margin. Now it’s a measurement instead of a guess.
What I’d do differently
Build the coordinator first. I wrote the work queue, then replaced it. Though — having built both is exactly what turned “the coordinator is better” from an assertion into something I could measure, badly-behaved benchmark and all.
Drive a real end-to-end path after every subsystem, not after every phase. Every worst bug in this project compiled cleanly and passed its own unit tests. Seeding that did nothing. A timer that could never fire. Each one needed a real connection with a real peer on the other end before it became visible at all.
Trust the deterministic tests over the live swarm sooner. Public swarm numbers are noisy single samples, confounded by whoever happened to be online. The network-free tests caught every real regression in this project. The live runs mostly just confirmed what those tests had already told me.
Pin your tooling on day one. My globally-installed golangci-lint auto-upgraded itself at one point and started passing things CI rejected, which is a uniquely annoying way to lose an hour. It's pinned through the Makefile now, to the exact version CI uses.
The part that actually felt like finishing
Not the last commit. It was running Transmission on one side and my client on the other, and watching a complete file go both directions — Transmission downloading from mine, mine downloading from Transmission — with checksums matching the original exactly, both ways.
A client tested only against itself can’t catch a bug that’s wrong the same way on both ends of the wire. Two independent implementations agreeing, byte for byte, is a much harder thing to fake.
Three days after I tagged v1.0.1, I found another bug — a torrent name containing a colon could slip past my path validation, which on NTFS means someone could aim the output at an alternate data stream. Fixed it, tagged it, moved on.
That’s roughly the honest ending. The thing works, it’s been verified against a real client and a real swarm, and there are certainly bugs left that haven’t thrown an error yet.
And now when someone on Discord says their PC is seeding, I can actually explain what’s happening. Probably in more detail than they want.
The code is on GitHub at asmanya/p2p-file-distribution — 7 packages, zero dependencies, and an architecture doc that’s longer than it has any right to be. Not implemented: BitTorrent v2, multi-file torrents, DHT and magnet links.






