<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Harshith Sunku — Blog</title><description>Systems engineer who lives below the abstraction layer. Networking, kernel internals, performance engineering — I write about the things most people hand-wave past.</description><link>https://harshith.in/</link><language>en-us</language><lastBuildDate>Sun, 12 Jul 2026 11:08:28 GMT</lastBuildDate><managingEditor>harshithsunku@gmail.com (Harshith Sunku)</managingEditor><webMaster>harshithsunku@gmail.com (Harshith Sunku)</webMaster><item><title>An AI Coworker in an LXC Container</title><link>https://harshith.in/blog/an-ai-coworker-in-an-lxc/</link><guid isPermaLink="true">https://harshith.in/blog/an-ai-coworker-in-an-lxc/</guid><description>A dedicated container on my Proxmox host runs a coding agent that writes, reviews, and ships changes to this site — through the same git-push pipeline I use. The interesting engineering isn&apos;t the model; it&apos;s the guardrails around it.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There’s a container on my Proxmox host — Ubuntu 25.04, 4 vCPUs, 8 GB —
whose only job is to run a coding agent with access to this site’s
repository. It has written posts, redesigned pages, fixed SEO bugs, and
shipped the search feature you can use right now. Not as a copilot in my
editor: as a coworker with its own machine.&lt;/p&gt;
&lt;p&gt;The problem this post answers is the one I actually lost sleep over:
&lt;strong&gt;how do you give an autonomous agent commit access and still sleep at
night?&lt;/strong&gt; I didn’t know when I started — that uncertainty is exactly why
the lab got to host the experiment. The answer turned out to be the same
answer as for any junior engineer: constrain the blast radius and make
review cheap.&lt;/p&gt;
&lt;h2 id=&quot;why-the-agent-gets-its-own-container&quot;&gt;Why the agent gets its own container&lt;/h2&gt;
&lt;p&gt;The first decision was isolation. The agent runs &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;in an LXC
container&lt;/a&gt; rather than on my workstation,
which buys exactly what containers buy: a filesystem boundary, a resource
cgroup, and a snapshot story. If an agent-driven &lt;code&gt;npm install&lt;/code&gt; goes
sideways, the damage is confined to a ZFS dataset I can roll back in one
command. It also makes the agent’s environment &lt;em&gt;reproducible&lt;/em&gt; — the same
reason we put CI in containers, &lt;a href=&quot;https://harshith.in/blog/what-is-a-container/&quot;&gt;as I covered in the container
internals post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Sizing turned out to be trivial: the model runs in a datacenter, not
here, so the container is doing text processing and site builds. 4 vCPUs
and 8 GB never break a sweat; the full Astro build with 90 posts and OG
image generation takes about a minute. (If you want to know why local
inference wasn’t an option, &lt;a href=&quot;https://harshith.in/blog/how-llm-inference-works/&quot;&gt;the memory-bandwidth math&lt;/a&gt;
answers it — a GPU-less container with an 8 GB ceiling isn’t in that
conversation, and frontier-model quality wasn’t negotiable for prose.)&lt;/p&gt;
&lt;p&gt;API traffic doesn’t go straight to providers either. It routes through an
LLM proxy on an Oracle free-tier VM, so keys live off my LAN and I get
one choke point for logging and rate limits.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline-is-the-guardrail&quot;&gt;The pipeline is the guardrail&lt;/h2&gt;
&lt;p&gt;The agent inherits the same publishing pipeline
&lt;a href=&quot;https://harshith.in/blog/how-this-blog-runs-for-0/&quot;&gt;the whole site runs on&lt;/a&gt;, and that
pipeline is doing quiet security work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Everything is a git commit.&lt;/strong&gt; There is no path to production that
skips version control. Every change the agent makes is diffable,
attributable (commits are co-signed with the agent’s name), and
revertible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI is the reviewer that never trusts.&lt;/strong&gt; Type-check plus full build on
every push. The content schema is strict — an invalid frontmatter field
or a broken internal link pattern fails the build before deploy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;draft: true&lt;/code&gt; is a staging area.&lt;/strong&gt; Posts can land in the repo without
going live, so “write it” and “publish it” are separate decisions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; is the onboarding doc.&lt;/strong&gt; The repo carries its own
instructions — voice, conventions, schema, do-nots. The agent reads it
the way a new hire reads a runbook, and it works for the same reason
runbooks work: the knowledge lives next to the system, not in someone’s
head.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notice what’s &lt;em&gt;not&lt;/em&gt; in the list: trust in the model. The loop is designed
so that a bad change is cheap to catch and cheap to undo — the same
philosophy as &lt;a href=&quot;https://harshith.in/blog/building-an-agent-loop/&quot;&gt;the agent-loop architecture&lt;/a&gt;
itself, where the model proposes and the harness disposes.&lt;/p&gt;
&lt;h2 id=&quot;what-its-actually-like&quot;&gt;What it’s actually like&lt;/h2&gt;
&lt;p&gt;The honest report: the agent is excellent at the work that has a
verifier. “Make the sitemap emit lastmod and prove it in the build
output” comes back done, with the grep to show it. It’s weaker where
taste is the spec — I still edit for voice, and the
&lt;a href=&quot;https://harshith.in/blog/model-context-protocol-explained/&quot;&gt;MCP integrations&lt;/a&gt; that let it
check its own output (build logs, link checkers, search indexes) matter
more than raw model quality.&lt;/p&gt;
&lt;p&gt;The surprise was workflow, not capability: an agent with its own machine
and a queue beats an agent in your editor, because it turns “help me
type” into “here’s a task, ping me with the diff.” That’s a delegation
model, and delegation needs exactly the guardrails above — which is why
the pipeline came first.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Give an autonomous agent the same thing you’d give a new hire: an
isolated machine, a documented repo, and a review process — not raw
trust.&lt;/li&gt;
&lt;li&gt;LXC + ZFS snapshots make the blast radius a rollback, not an incident.&lt;/li&gt;
&lt;li&gt;Route agent API traffic through a proxy you control: keys off the LAN,
one place to log and limit.&lt;/li&gt;
&lt;li&gt;Make CI strict enough that “the build passed” means something; then let
the agent iterate against it.&lt;/li&gt;
&lt;li&gt;The productivity unlock is delegation with verification — the model
proposes, git and CI dispose.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>linux</category><category>systems</category></item><item><title>Who&apos;s Allowed to Talk to What: Identity and Access in the Homelab</title><link>https://harshith.in/blog/homelab-identity-and-access/</link><guid isPermaLink="true">https://harshith.in/blog/homelab-identity-and-access/</guid><description>VLANs decide which networks can talk. They say nothing about which people can. Part 5 of the homelab series: a central identity service in its own VM, Tailscale as the front door, and why the break-glass path matters more than the lock.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The problem announced itself the day the third self-hosted service asked
me to create its fourth local admin account. Every service wanted its own
users, its own passwords, its own idea of who I am — and every one of
those accounts was an orphan credential I would forget to rotate and
forget to delete. Meanwhile the network side looked &lt;em&gt;done&lt;/em&gt;:
&lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;part 1&lt;/a&gt; had carved everything into VLANs
by trust. But segmentation answers &lt;em&gt;which segments can exchange packets&lt;/em&gt;
and quietly dodges the harder question: &lt;strong&gt;which people and devices are
allowed to do what&lt;/strong&gt;. A VLAN doesn’t know who you are. If your security
model ends at segmentation, anyone with a foothold in the right subnet
inherits everything that subnet can reach — perimeter thinking, and it
fails the same way at home as it does in companies. All shell, no bones.&lt;/p&gt;
&lt;p&gt;What I tried first was what everyone tries first: keep minting local
accounts and keep a mental map of them. That scales to about three
services. The fix the lab taught me is the same one enterprises pay real
money for — identity as its own layer, with one place accounts are born
and one place they die.&lt;/p&gt;
&lt;h2 id=&quot;identity-gets-its-own-machine--and-its-own-segment&quot;&gt;Identity gets its own machine — and its own segment&lt;/h2&gt;
&lt;p&gt;User accounts, groups, and authentication now live in a dedicated
identity appliance. It’s one of the few &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;VMs rather than
containers&lt;/a&gt; in the lab, and it sits in its
own VLAN, because the identity service is the &lt;em&gt;root of trust&lt;/em&gt;: compromise
it and every downstream permission is fiction. The isolation calculus that
felt like overkill for a media server is the bare minimum here — a full
kernel boundary, a segment nothing else shares, and the management plane
unreachable from anything untrusted.&lt;/p&gt;
&lt;p&gt;Centralizing identity sounds like enterprise ceremony for a house, right
up until you’ve lived the local-admin-account treadmill. One directory,
one place accounts are born, one place they die.&lt;/p&gt;
&lt;h2 id=&quot;tailscale-is-the-front-door&quot;&gt;Tailscale is the front door&lt;/h2&gt;
&lt;p&gt;Remote access is where identity and networking actually meet. Nothing in
the lab is port-forwarded; the way in is Tailscale — WireGuard underneath,
but the part that matters here is &lt;em&gt;who&lt;/em&gt; a connection is, not just where
it’s from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nodes authenticate as identities, not addresses.&lt;/strong&gt; A device joins the
mesh by logging in; access follows the login. Lose a laptop and you
revoke the &lt;em&gt;device&lt;/em&gt;, not re-key a VPN and re-distribute configs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ACLs are policy as code.&lt;/strong&gt; “This user’s devices may reach the
hypervisor’s management interface; that tagged node may reach only the
reverse proxy.” It’s a reviewable text file, not a pile of firewall
rules re-derived from memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keys expire.&lt;/strong&gt; Access defaults to decaying unless renewed, which is
the correct default for anything you’ll otherwise forget about.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mental shift: the VPN isn’t a tunnel into a trusted network — it’s a
per-device, per-identity grant. Being “on the network” stops being a
permission by itself.&lt;/p&gt;
&lt;div class=&quot;post-diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 460 530&quot; role=&quot;img&quot; aria-label=&quot;Access flow: devices enter through Tailscale, which checks logins against an identity VM in its own VLAN before granting scoped access to services; a dashed break-glass path goes straight to the hypervisor console&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
  &lt;title&gt;Identity-checked front door, with a break-glass path around it&lt;/title&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;idn-arr&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
    &lt;marker id=&quot;idn-arr-hl&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow-hl&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
  &lt;/defs&gt;
  &lt;rect x=&quot;130&quot; y=&quot;20&quot; width=&quot;200&quot; height=&quot;52&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;42&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;my devices&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;61&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;laptops · phone&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;72&quot; x2=&quot;230&quot; y2=&quot;100&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#idn-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;rect x=&quot;130&quot; y=&quot;104&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;128&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Tailscale&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;147&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;who are you? · ACLs&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;160&quot; x2=&quot;230&quot; y2=&quot;192&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#idn-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;text x=&quot;242&quot; y=&quot;181&quot; class=&quot;d-ta&quot;&gt;per-identity grant&lt;/text&gt;
  &lt;rect x=&quot;130&quot; y=&quot;196&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;lab services&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;239&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;scoped by ACL&lt;/text&gt;
  &lt;!-- authn path to identity VM --&gt;
  &lt;path d=&quot;M 130 132 H 56 V 284&quot; class=&quot;d-line&quot; marker-end=&quot;url(#idn-arr)&quot;&gt;&lt;/path&gt;
  &lt;text x=&quot;70&quot; y=&quot;240&quot; class=&quot;d-t2&quot;&gt;authn&lt;/text&gt;
  &lt;rect x=&quot;20&quot; y=&quot;290&quot; width=&quot;270&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-zone&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;32&quot; y=&quot;312&quot; class=&quot;d-t2&quot;&gt;identity VLAN&lt;/text&gt;
  &lt;rect x=&quot;45&quot; y=&quot;324&quot; width=&quot;220&quot; height=&quot;62&quot; rx=&quot;6&quot; class=&quot;d-box-hl&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;155&quot; y=&quot;349&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;identity VM&lt;/text&gt;
  &lt;text x=&quot;155&quot; y=&quot;368&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;one directory · root of trust&lt;/text&gt;
  &lt;!-- break-glass path --&gt;
  &lt;path d=&quot;M 330 46 H 420 V 424&quot; class=&quot;d-line-hl&quot; stroke-dasharray=&quot;5 5&quot; marker-end=&quot;url(#idn-arr-hl)&quot;&gt;&lt;/path&gt;
  &lt;text x=&quot;412&quot; y=&quot;410&quot; text-anchor=&quot;end&quot; class=&quot;d-ta&quot;&gt;break-glass&lt;/text&gt;
  &lt;rect x=&quot;245&quot; y=&quot;430&quot; width=&quot;195&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;342&quot; y=&quot;454&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;hypervisor console&lt;/text&gt;
  &lt;text x=&quot;342&quot; y=&quot;473&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;local root · off the mesh&lt;/text&gt;
&lt;/svg&gt;&lt;p&gt;&lt;text x=&quot;230&quot; y=&quot;518&quot; text-anchor=&quot;middle&quot; class=&quot;d-ta&quot;&gt;recovery must not depend on the thing being recovered&lt;/text&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Public exposure follows the same scoping logic. The &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;Cloudflare Tunnel
from part 1&lt;/a&gt; publishes &lt;em&gt;specific services&lt;/em&gt;,
not a network: an outbound connection carries named hostnames to a
container behind the reverse proxy, and the blast radius of “public” ends
at those hostnames. Public ≠ LAN. (This blog dodges the question entirely —
it’s &lt;a href=&quot;https://harshith.in/blog/how-this-blog-runs-for-0/&quot;&gt;static files on a CDN&lt;/a&gt; and touches
none of this.)&lt;/p&gt;
&lt;h2 id=&quot;design-for-the-day-identity-is-down&quot;&gt;Design for the day identity is down&lt;/h2&gt;
&lt;p&gt;Here’s the failure I almost built for myself: the identity VM goes down,
and every login in the lab depends on it — including, if you’re careless,
the login you’d use to &lt;em&gt;fix the identity VM&lt;/em&gt;. A circular dependency,
waiting to be discovered at the worst time. This is the same lesson &lt;a href=&quot;https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/&quot;&gt;the
wake-on-LAN gateway&lt;/a&gt; taught me at
the power layer: &lt;strong&gt;recovery paths must not depend on the thing being
recovered.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So the lab keeps deliberate break-glass paths: a local root credential on
the hypervisor that lives in a password manager and nowhere on the
network, and console access that works when SSO doesn’t. The test that
matters isn’t “can I log in” — it’s “can I log in &lt;em&gt;while the directory is
off&lt;/em&gt;.” I ran that drill on purpose before I needed it, and I recommend
the experience: a lock you can’t open in a power cut isn’t security, it’s
a lockout.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Segmentation controls which networks talk; identity controls which
people and devices do. You need both — a VLAN doesn’t know who you are.&lt;/li&gt;
&lt;li&gt;Centralize accounts in one directory, isolate it like the root of trust
it is (own VM, own segment), and stop minting local admins.&lt;/li&gt;
&lt;li&gt;Prefer identity-based mesh access (Tailscale ACLs, expiring keys,
per-device revocation) over network-based trust. “On the VPN” should
not mean “allowed.”&lt;/li&gt;
&lt;li&gt;Expose services, never networks: scope public tunnels to named
hostnames.&lt;/li&gt;
&lt;li&gt;Keep a tested break-glass path that works with identity down — the
recovery rule from part 4, applied to logins. Drill it before you
need it.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>networking</category><category>security</category><category>systems</category></item><item><title>The Fleet: Six Machines, and What the Hypervisor Can&apos;t Do</title><link>https://harshith.in/blog/the-fleet-why-bare-metal-earns-its-keep/</link><guid isPermaLink="true">https://harshith.in/blog/the-fleet-why-bare-metal-earns-its-keep/</guid><description>The plan was to consolidate everything onto one Proxmox box. Then a perf tool needed real PMU counters, a firmware flash needed a real serial port, and testing on ARM meant a phone running a custom kernel. Meet the fleet — and what each machine taught me.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The lab was supposed to be one machine. &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;Part 1&lt;/a&gt;
made the consolidation case — one Proxmox box, twenty containers, everything
snapshotted and segmented. Then I built a performance-sampling tool, asked the
obvious question — &lt;em&gt;does it behave the same on ARM?&lt;/em&gt; — and discovered that the
machine I had consolidated everything onto couldn’t answer it.&lt;/p&gt;
&lt;p&gt;This post is about the fleet that grew around that lesson. Six machines, and
none of them is here for fun: each one exists because I hit a problem the
hypervisor could not solve, and each one taught me something I wouldn’t have
learned from a VM. That’s the whole point of this lab — it’s a classroom that
happens to run services.&lt;/p&gt;
&lt;div class=&quot;post-diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 460 505&quot; role=&quot;img&quot; aria-label=&quot;Fleet map: six machines — Proxmox host, firmware bench, test laptop, ARM phone, daily driver, and MacBook — joined by one Tailscale mesh&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
  &lt;title&gt;The fleet: six machines, one Tailscale mesh&lt;/title&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;fleet-arr&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
  &lt;/defs&gt;
  &lt;!-- spine + row connectors --&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;75&quot; x2=&quot;230&quot; y2=&quot;428&quot; class=&quot;d-line&quot; marker-end=&quot;url(#fleet-arr)&quot;&gt;&lt;/line&gt;
  &lt;line x1=&quot;215&quot; y1=&quot;75&quot; x2=&quot;245&quot; y2=&quot;75&quot; class=&quot;d-line&quot;&gt;&lt;/line&gt;
  &lt;line x1=&quot;215&quot; y1=&quot;205&quot; x2=&quot;245&quot; y2=&quot;205&quot; class=&quot;d-line&quot;&gt;&lt;/line&gt;
  &lt;line x1=&quot;215&quot; y1=&quot;335&quot; x2=&quot;245&quot; y2=&quot;335&quot; class=&quot;d-line&quot;&gt;&lt;/line&gt;
  &lt;!-- Proxmox host --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;20&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box-hl&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;34&quot; y=&quot;47&quot; class=&quot;d-t1&quot;&gt;Proxmox host&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;70&quot; class=&quot;d-t2&quot;&gt;Ultra 9 285K · 96 GB&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;88&quot; class=&quot;d-t2&quot;&gt;NVMe pools + 8 TB HDD&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;112&quot; class=&quot;d-ta&quot;&gt;the always-on lab&lt;/text&gt;
  &lt;!-- Firmware bench --&gt;
  &lt;rect x=&quot;245&quot; y=&quot;20&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;259&quot; y=&quot;47&quot; class=&quot;d-t1&quot;&gt;Firmware bench&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;70&quot; class=&quot;d-t2&quot;&gt;i5-3330 · 16 GB&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;88&quot; class=&quot;d-t2&quot;&gt;real USB + serial&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;112&quot; class=&quot;d-ta&quot;&gt;bare-metal flashing&lt;/text&gt;
  &lt;!-- Test laptop --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;150&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;34&quot; y=&quot;177&quot; class=&quot;d-t1&quot;&gt;Test laptop&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;200&quot; class=&quot;d-t2&quot;&gt;A10-9600P · 16 GB&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;218&quot; class=&quot;d-t2&quot;&gt;risky OS installs&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;242&quot; class=&quot;d-ta&quot;&gt;2nd x86 PMU target&lt;/text&gt;
  &lt;!-- ARM phone --&gt;
  &lt;rect x=&quot;245&quot; y=&quot;150&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;259&quot; y=&quot;177&quot; class=&quot;d-t1&quot;&gt;ARM phone&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;200&quot; class=&quot;d-t2&quot;&gt;Poco F1 · SD 845&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;218&quot; class=&quot;d-t2&quot;&gt;NetHunter kernel&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;242&quot; class=&quot;d-ta&quot;&gt;bare-metal ARM PMU&lt;/text&gt;
  &lt;!-- Daily driver --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;280&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;34&quot; y=&quot;307&quot; class=&quot;d-t1&quot;&gt;Daily driver&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;330&quot; class=&quot;d-t2&quot;&gt;i5-1240P · 32 GB&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;348&quot; class=&quot;d-t2&quot;&gt;Windows 11&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;372&quot; class=&quot;d-ta&quot;&gt;the control plane&lt;/text&gt;
  &lt;!-- MacBook Air --&gt;
  &lt;rect x=&quot;245&quot; y=&quot;280&quot; width=&quot;195&quot; height=&quot;110&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;259&quot; y=&quot;307&quot; class=&quot;d-t1&quot;&gt;MacBook Air&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;330&quot; class=&quot;d-t2&quot;&gt;M4 · 24 GB&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;348&quot; class=&quot;d-t2&quot;&gt;Tailscale everywhere&lt;/text&gt;
  &lt;text x=&quot;259&quot; y=&quot;372&quot; class=&quot;d-ta&quot;&gt;the lab, remotely&lt;/text&gt;
  &lt;!-- mesh bar --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;435&quot; width=&quot;420&quot; height=&quot;48&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;456&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Tailscale mesh&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;474&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;one overlay network, every machine&lt;/text&gt;
&lt;/svg&gt;
&lt;/div&gt;
&lt;h2 id=&quot;the-center-and-one-cheap-trick-worth-stealing&quot;&gt;The center, and one cheap trick worth stealing&lt;/h2&gt;
&lt;p&gt;The Proxmox host from part 1 is still the center of gravity: an Ultra 9 285K,
96 GB of DDR5-6600, and three 2 TB Gen4 NVMe drives carrying separate ZFS
pools, plus an 8 TB mirrored pool on spinning disks for bulk data.&lt;/p&gt;
&lt;p&gt;The HDD pool taught me the first lesson. Spinning disks stream large files
fine, but metadata is where they die — walking a directory tree of a few
hundred thousand files means a seek per lookup, and &lt;code&gt;ls&lt;/code&gt; starts to feel like
an I/O benchmark. The fix was a &lt;strong&gt;ZFS special vdev&lt;/strong&gt;: a mirrored pair of
256 GB SATA SSDs attached to the HDD pool that holds its metadata (and
optionally small blocks). Directory walks hit flash; big sequential reads
still stream off the platters. Two leftover SSDs turned out to be the
cheapest upgrade a spinning pool can get. One caveat that matters: the
special vdev &lt;em&gt;is&lt;/em&gt; the pool — lose it and the pool is gone — which is why it’s
a mirror and not a single drive.&lt;/p&gt;
&lt;h2 id=&quot;the-counters-that-wouldnt-count&quot;&gt;The counters that wouldn’t count&lt;/h2&gt;
&lt;p&gt;The perf tool is where consolidation broke. Sampling profilers lean on the
&lt;strong&gt;PMU&lt;/strong&gt; — the CPU’s performance monitoring unit, the hardware counters behind
&lt;code&gt;perf stat&lt;/code&gt;’s cycles and cache-misses. And a hypervisor is exactly the wrong
place to learn how PMUs behave: virtualized counters are incomplete when
they’re exposed at all, the hypervisor multiplexes them behind your back, and
the emulated ARM VM from part 1 has no real PMU whatsoever — QEMU proves your
code &lt;em&gt;runs&lt;/em&gt; on ARM, not how it &lt;em&gt;performs&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;So the tool got tested where counters tell the truth — on bare metal, across
three genuinely different microarchitectures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;i5-3330&lt;/strong&gt; — Ivy Bridge, Intel’s PMU, 2012 vintage. An old baseline is a
feature: if the tool works here, it isn’t accidentally depending on
anything modern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A10-9600P&lt;/strong&gt; — AMD’s PMU, which is not Intel’s PMU. Same &lt;code&gt;perf&lt;/code&gt; API,
different counters, different quirks. You don’t learn that from
documentation; you learn it when the same code returns different shapes of
data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Snapdragon 845&lt;/strong&gt; — the ARM PMU, big.LITTLE and all, in a phone (more on
that below).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That test matrix is the lab’s thesis in miniature: the hypervisor virtualizes
CPUs well and performance counters badly. Anything that touches hardware this
directly needs real silicon.&lt;/p&gt;
&lt;h2 id=&quot;a-phone-is-the-cheapest-arm-dev-board&quot;&gt;A phone is the cheapest ARM dev board&lt;/h2&gt;
&lt;p&gt;The ARM machine in that matrix is a Poco F1 — Snapdragon 845, 8 GB of RAM,
256 GB of storage — running a NetHunter kernel. NetHunter is security
tooling, but that’s not why it’s here: the point is a &lt;em&gt;rooted device with a
replaceable kernel&lt;/em&gt;. That combination turns a retired phone into an ARM
development board with better specs than most actual dev boards — four
Cortex-A75s and four A55s of real big.LITTLE scheduling, a real ARM PMU, and
a battery-backed power supply built in.&lt;/p&gt;
&lt;p&gt;Every “works on my machine” assumption a career of x86 quietly builds up gets
audited the first time your tool runs on this thing.&lt;/p&gt;
&lt;h2 id=&quot;the-bench-that-has-real-ports&quot;&gt;The bench that has real ports&lt;/h2&gt;
&lt;p&gt;Firmware work was the second thing the hypervisor lost. Flashing embedded
boards — like the &lt;a href=&quot;https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/&quot;&gt;ESP32 wake gateway&lt;/a&gt;
— means serial consoles and USB devices that reset and re-enumerate
mid-operation. USB passthrough into a VM can be forced, but a device that
drops and reattaches during a flash is a great way to end up with half-written
firmware, and the failure is always ambiguous: the board, the cable, or the
passthrough?&lt;/p&gt;
&lt;p&gt;The answer is a machine nobody would miss: an i5-3330 desktop with 16 GB and
an assortment of leftover disks. Real USB controllers, a real serial port, no
virtualization layer to suspect. When a flash fails there, the board is
actually broken — and removing a layer of doubt is worth more than the
electricity it burns. It doubles as the Ivy Bridge test target above.&lt;/p&gt;
&lt;h2 id=&quot;the-machines-that-run-the-machines&quot;&gt;The machines that run the machines&lt;/h2&gt;
&lt;p&gt;The rest of the fleet is about operating the lab rather than being
experimented on. An i5-1240P laptop — 32 GB, a 2 TB and a 512 GB Gen4 NVMe,
Windows 11 — is the daily driver and control plane: every console, dashboard,
and SSH session in the lab happens from here. And a MacBook Air M4 with 24 GB
is the portable half — on the Tailscale mesh from &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;part
1&lt;/a&gt;, so “at the lab” is a network property,
not a physical one. The A10 laptop rounds things out as the sacrificial box:
risky OS installs and one-off servers land there first, where a bad day costs
nothing.&lt;/p&gt;
&lt;p&gt;The deliberate redundancy: the control plane runs Windows, the portable runs
macOS on ARM, the lab runs Linux. Three operating systems and two ISAs in
daily rotation means cross-platform assumptions get caught by breakfast.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Consolidation is right up until the work touches hardware. PMUs, serial
ports, USB quirks — that’s where bare metal earns its keep.&lt;/li&gt;
&lt;li&gt;A hypervisor virtualizes CPUs well and performance counters badly. Test
anything perf-shaped on real silicon, across more than one vendor’s PMU.&lt;/li&gt;
&lt;li&gt;A rooted phone with a replaceable kernel is the cheapest real-ARM test
device you can own — better specs than a dev board, battery included.&lt;/li&gt;
&lt;li&gt;A mirrored special vdev is the cheapest upgrade a ZFS spinning pool can
get: metadata on flash, bulk data on platters. Mirror it — it’s
pool-critical.&lt;/li&gt;
&lt;li&gt;Keep one machine nobody would miss. Risky experiments need a home where
failure is free.&lt;/li&gt;
&lt;li&gt;Next in this series: the strangest tenant on the always-on host — &lt;a href=&quot;https://harshith.in/blog/an-ai-coworker-in-an-lxc/&quot;&gt;the AI
coworker in an LXC container&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>linux</category><category>systems</category><category>performance</category></item><item><title>Waking the Lab: an ESP32, MQTT, and 102 Bytes</title><link>https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/</link><guid isPermaLink="true">https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/</guid><description>Wake-on-LAN only works from inside the LAN — so I put a $5 ESP32 on the LAN. It subscribes to an MQTT topic and broadcasts magic packets on demand. The build, the failure modes, and what a hardware watchdog is really for.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;Proxmox host&lt;/a&gt; in my lab runs 24/7.
The machines around it don’t: the workstation and the bench boxes sleep
when idle, and &lt;a href=&quot;https://harshith.in/blog/wake-on-lan-magic-packets/&quot;&gt;Wake-on-LAN is a layer-2
mechanism&lt;/a&gt;, so waking one remotely
means something &lt;em&gt;inside&lt;/em&gt; the LAN has to send the packet. My first wake
path was the obvious one — a container on the hypervisor that publishes
magic packets — and it worked right up until the day the machine I
needed to wake &lt;em&gt;was&lt;/em&gt; the hypervisor, down for a kernel upgrade. A recovery path that depends on
the thing being recovered isn’t a recovery path. (Port-forwarding UDP
from the internet is the other classic answer, and it violates the rule
from &lt;a href=&quot;https://harshith.in/blog/self-hosting-networking-stack/&quot;&gt;the homelab networking post&lt;/a&gt;:
VPN in, don’t port-forward.)&lt;/p&gt;
&lt;p&gt;So the wake path is its own device — independent of every machine it
might need to rescue, drawing about half a watt, costing less than
lunch: &lt;a href=&quot;https://github.com/harshithsunku/ESP32-MQTT-WOL&quot;&gt;an ESP32&lt;/a&gt; that
holds one MQTT subscription and broadcasts magic packets on command.&lt;/p&gt;
&lt;h2 id=&quot;the-shape-of-the-thing&quot;&gt;The shape of the thing&lt;/h2&gt;
&lt;div class=&quot;post-diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 460 430&quot; role=&quot;img&quot; aria-label=&quot;Wake path: a phone publishes to an MQTT broker, the ESP32 on the LAN receives the command and broadcasts a 102-byte magic packet to the sleeping host&amp;#x27;s NIC&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
  &lt;title&gt;Phone to MQTT broker to ESP32 to sleeping NIC&lt;/title&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;wol-arr-hl&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow-hl&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
  &lt;/defs&gt;
  &lt;rect x=&quot;130&quot; y=&quot;20&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;44&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;phone / laptop&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;63&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;anywhere&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;76&quot; x2=&quot;230&quot; y2=&quot;106&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#wol-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;text x=&quot;242&quot; y=&quot;97&quot; class=&quot;d-ta&quot;&gt;publish wol/wake/…&lt;/text&gt;
  &lt;rect x=&quot;130&quot; y=&quot;110&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;MQTT broker&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;153&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;QoS 1 · no retain&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;166&quot; x2=&quot;230&quot; y2=&quot;212&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#wol-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;rect x=&quot;20&quot; y=&quot;188&quot; width=&quot;420&quot; height=&quot;216&quot; rx=&quot;6&quot; class=&quot;d-zone&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;32&quot; y=&quot;210&quot; class=&quot;d-t2&quot;&gt;the LAN (L2 adjacency)&lt;/text&gt;
  &lt;rect x=&quot;130&quot; y=&quot;224&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box-hl&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;248&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;ESP32 gateway&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;267&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;ESP-IDF · ~0.5 W&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;280&quot; x2=&quot;230&quot; y2=&quot;314&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#wol-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;text x=&quot;242&quot; y=&quot;302&quot; class=&quot;d-ta&quot;&gt;UDP :9 · 102 bytes&lt;/text&gt;
  &lt;rect x=&quot;130&quot; y=&quot;318&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;342&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;sleeping host&apos;s NIC&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;361&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;pattern match → power on&lt;/text&gt;
&lt;/svg&gt;
&lt;/div&gt;
&lt;p&gt;The ESP32 runs ESP-IDF — FreeRTOS underneath, lwIP for the network stack.
The application is honestly small: connect to Wi-Fi, keep a session to the
broker, and on a message to &lt;code&gt;wol/wake/&amp;#x3C;host&gt;&lt;/code&gt;, assemble the 102-byte magic
packet (6×&lt;code&gt;0xFF&lt;/code&gt; + the target MAC sixteen times) and fire it at the
broadcast address on UDP port 9. The packet format and why the NIC’s
pattern-matcher can parse it while the CPU is off is
&lt;a href=&quot;https://harshith.in/blog/wake-on-lan-magic-packets/&quot;&gt;the previous post’s territory&lt;/a&gt;; this
post is about what it takes to make the &lt;em&gt;sender&lt;/em&gt; dependable.&lt;/p&gt;
&lt;p&gt;One wrinkle a segmented network adds: broadcasts stop at VLAN
boundaries. Small embedded gadgets normally belong on the quarantined
IoT segment, but a magic packet sent there dies at the VLAN edge — the
wake gateway needs layer-2 adjacency with its targets. In a network
&lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;carved up by trust&lt;/a&gt;, that placement is
a deliberate, documented exception — the kind of thing you decide once,
on purpose, instead of discovering at 2 AM.&lt;/p&gt;
&lt;h2 id=&quot;mqtt-choices-that-matter&quot;&gt;MQTT choices that matter&lt;/h2&gt;
&lt;p&gt;The protocol details are in &lt;a href=&quot;https://harshith.in/blog/mqtt-protocol-internals/&quot;&gt;the MQTT internals post&lt;/a&gt;;
here’s how they cash out in a real device:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;QoS 1, not 0, not 2.&lt;/strong&gt; A wake command that evaporates is a failed
feature — QoS 0 is out. Duplicate delivery, on the other hand, is
harmless: waking an awake machine is a no-op. That’s precisely the QoS 1
contract (&lt;em&gt;at least once&lt;/em&gt;), and it’s cheaper than QoS 2’s four-way
handshake. Idempotent command + QoS 1 is the sweet spot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No retained messages on the command topic.&lt;/strong&gt; This is the classic trap.
Retain a &lt;code&gt;wake&lt;/code&gt; command and every reconnect replays it — the ESP32
reboots, resubscribes, and helpfully wakes the server you just shut down.
Commands are events, not state; retained messages are for state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Last Will as a health signal.&lt;/strong&gt; The broker publishes
&lt;code&gt;wol/status/gateway = offline&lt;/code&gt; if the ESP32’s session dies. My monitoring
watches that topic — a dead wake-gateway discovered at 2 AM when you need
it is the worst possible discovery time.&lt;/p&gt;
&lt;h2 id=&quot;the-watchdog-is-the-design&quot;&gt;The watchdog is the design&lt;/h2&gt;
&lt;p&gt;The failure mode of an always-on embedded device isn’t crashing — it’s
&lt;em&gt;wedging&lt;/em&gt;: Wi-Fi associated, TCP session zombied, task blocked on a
semaphore that will never post. A crash reboots; a wedge lasts until you
physically walk over, which defeats the entire point of a remote wake
device.&lt;/p&gt;
&lt;p&gt;So the design assumption is “this will wedge eventually,” and the answer
is the ESP32’s hardware task watchdog. The main loop must check in every
few seconds; the check-in is gated on &lt;em&gt;actual liveness&lt;/em&gt; — broker
connection up, subscription alive — not just “the loop is spinning.” If
the session zombies, the check-in stops, the watchdog fires, and the
device is back in a known-good state in about two seconds. Nobody walks
anywhere.&lt;/p&gt;
&lt;p&gt;That inversion — design for recovery, not for uptime — is the one idea
from this build that generalizes to every unattended device.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;WoL’s layer-2 constraint means &lt;em&gt;something&lt;/em&gt; must live on the LAN; make
it the cheapest, lowest-power thing that can hold an MQTT session —
and keep the wake path independent of the machines it recovers.&lt;/li&gt;
&lt;li&gt;VLAN segmentation and broadcast-based protocols fight: place the wake
gateway with L2 adjacency to its targets, as a deliberate exception.&lt;/li&gt;
&lt;li&gt;Idempotent commands + QoS 1 give you reliability without QoS 2’s
overhead. Never retain command messages — retain state, publish events.&lt;/li&gt;
&lt;li&gt;Use MQTT Last Will to monitor the monitor: a wake gateway that fails
silently fails at the worst time.&lt;/li&gt;
&lt;li&gt;For unattended devices, gate the watchdog check-in on end-to-end
liveness and let the reset be your recovery path.&lt;/li&gt;
&lt;li&gt;This device backstops &lt;a href=&quot;https://harshith.in/blog/homelab-proxmox-zfs-lxc/&quot;&gt;the lab&lt;/a&gt; whose
always-on host runs &lt;a href=&quot;https://harshith.in/blog/an-ai-coworker-in-an-lxc/&quot;&gt;the AI coworker&lt;/a&gt;
that maintains &lt;a href=&quot;https://harshith.in/blog/how-this-blog-runs-for-0/&quot;&gt;the site you’re reading&lt;/a&gt;.
Every layer has a recovery story — next up, &lt;a href=&quot;https://harshith.in/blog/homelab-identity-and-access/&quot;&gt;the same rule applied to
logins&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>embedded</category><category>networking</category><category>systems</category></item><item><title>My Homelab: One Proxmox Box, ZFS Pools, and a Properly Segmented Network</title><link>https://harshith.in/blog/homelab-proxmox-zfs-lxc/</link><guid isPermaLink="true">https://harshith.in/blog/homelab-proxmox-zfs-lxc/</guid><description>The lab started as services sprawled across whatever machine was awake — until one bad upgrade took them all down at once. This is what that failure taught me: one Proxmox host, ZFS snapshots as the undo button, and a network segmented by trust.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The lab didn’t start as a lab. It started as services accreting onto
whatever machine happened to be on — a media server here, a script there,
everything sharing one filesystem and one fate. The failure that changed
it was boring and total: one bad upgrade, and everything went down
together, with no isolation between the thing I broke and the things I
cared about, and no undo. I rebuilt from scratch, twice.&lt;/p&gt;
&lt;p&gt;That’s the problem this lab solves. Not “host services” — &lt;em&gt;break things
without breaking everything, and always have a way back&lt;/em&gt;. The solution
became &lt;strong&gt;one Proxmox VE host carrying about twenty LXC containers and a
handful of VMs&lt;/strong&gt;, behind a UniFi network that does the segmentation work.
Every post on this blog is now written, built, and pushed from a container
in that lab. No seven-node cluster blinking in a rack — the interesting
lessons are in how much one box can carry, and where the isolation
boundaries actually earn their cost.&lt;/p&gt;
&lt;div class=&quot;post-diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 460 600&quot; role=&quot;img&quot; aria-label=&quot;Lab topology: a Proxmox host with tiered ZFS pools and LXC/VM guests, a UniFi gateway fanning out into VLANs, with Tailscale in and a Cloudflare Tunnel out&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
  &lt;title&gt;One Proxmox host, tiered ZFS pools, and a VLAN-segmented network&lt;/title&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;lab-arr&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
    &lt;marker id=&quot;lab-arr-hl&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow-hl&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
  &lt;/defs&gt;
  &lt;!-- host --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;20&quot; width=&quot;420&quot; height=&quot;210&quot; rx=&quot;6&quot; class=&quot;d-box-hl&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;34&quot; y=&quot;48&quot; class=&quot;d-t1&quot;&gt;Proxmox host&lt;/text&gt;
  &lt;text x=&quot;34&quot; y=&quot;68&quot; class=&quot;d-t2&quot;&gt;Ultra 9 285K · 96 GB · ZFS&lt;/text&gt;
  &lt;rect x=&quot;32&quot; y=&quot;84&quot; width=&quot;124&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;44&quot; y=&quot;107&quot; class=&quot;d-t2&quot;&gt;NVMe · fast&lt;/text&gt;
  &lt;text x=&quot;44&quot; y=&quot;125&quot; class=&quot;d-t2&quot;&gt;roots + VMs&lt;/text&gt;
  &lt;rect x=&quot;168&quot; y=&quot;84&quot; width=&quot;124&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;180&quot; y=&quot;107&quot; class=&quot;d-t2&quot;&gt;HDD · bulk&lt;/text&gt;
  &lt;text x=&quot;180&quot; y=&quot;125&quot; class=&quot;d-t2&quot;&gt;~12 TiB raw&lt;/text&gt;
  &lt;rect x=&quot;304&quot; y=&quot;84&quot; width=&quot;124&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;316&quot; y=&quot;107&quot; class=&quot;d-t2&quot;&gt;backup pool&lt;/text&gt;
  &lt;text x=&quot;316&quot; y=&quot;125&quot; class=&quot;d-t2&quot;&gt;receives only&lt;/text&gt;
  &lt;rect x=&quot;32&quot; y=&quot;156&quot; width=&quot;396&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;180&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;~20 LXC · a few VMs&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;199&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;one kernel, many userspaces&lt;/text&gt;
  &lt;!-- host to gateway --&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;230&quot; x2=&quot;230&quot; y2=&quot;258&quot; class=&quot;d-line&quot; marker-end=&quot;url(#lab-arr)&quot;&gt;&lt;/line&gt;
  &lt;!-- gateway --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;262&quot; width=&quot;420&quot; height=&quot;46&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;282&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;UniFi gateway · switch · AP&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;300&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;segmentation by trust&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;308&quot; x2=&quot;230&quot; y2=&quot;322&quot; class=&quot;d-line&quot;&gt;&lt;/line&gt;
  &lt;!-- VLANs --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;322&quot; width=&quot;420&quot; height=&quot;150&quot; rx=&quot;6&quot; class=&quot;d-zone&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;32&quot; y=&quot;344&quot; class=&quot;d-t2&quot;&gt;a dozen VLANs · 10.10.x.0/24 each&lt;/text&gt;
  &lt;rect x=&quot;32&quot; y=&quot;356&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;94&quot; y=&quot;382&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;mgmt&lt;/text&gt;
  &lt;rect x=&quot;168&quot; y=&quot;356&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;382&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;IoT · quarantined&lt;/text&gt;
  &lt;rect x=&quot;304&quot; y=&quot;356&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;366&quot; y=&quot;382&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;servers&lt;/text&gt;
  &lt;rect x=&quot;32&quot; y=&quot;412&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;94&quot; y=&quot;438&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;identity&lt;/text&gt;
  &lt;rect x=&quot;168&quot; y=&quot;412&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;438&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;guest&lt;/text&gt;
  &lt;rect x=&quot;304&quot; y=&quot;412&quot; width=&quot;124&quot; height=&quot;44&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;366&quot; y=&quot;438&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;sandbox&lt;/text&gt;
  &lt;!-- exits --&gt;
  &lt;line x1=&quot;117&quot; y1=&quot;472&quot; x2=&quot;117&quot; y2=&quot;498&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#lab-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;line x1=&quot;343&quot; y1=&quot;472&quot; x2=&quot;343&quot; y2=&quot;498&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#lab-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;rect x=&quot;20&quot; y=&quot;502&quot; width=&quot;195&quot; height=&quot;52&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;117&quot; y=&quot;524&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Tailscale&lt;/text&gt;
  &lt;text x=&quot;117&quot; y=&quot;543&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;mesh VPN in&lt;/text&gt;
  &lt;rect x=&quot;245&quot; y=&quot;502&quot; width=&quot;195&quot; height=&quot;52&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;343&quot; y=&quot;524&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;CF Tunnel&lt;/text&gt;
  &lt;text x=&quot;343&quot; y=&quot;543&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;outbound-only publish&lt;/text&gt;
&lt;/svg&gt;&lt;p&gt;&lt;text x=&quot;230&quot; y=&quot;586&quot; text-anchor=&quot;middle&quot; class=&quot;d-ta&quot;&gt;zero port-forwards&lt;/text&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id=&quot;the-host-and-the-undo-button&quot;&gt;The host, and the undo button&lt;/h2&gt;
&lt;p&gt;The box is built around an Intel Core Ultra 9 285K — 24 threads on one
socket — with 96 GB of RAM, running Proxmox VE 9.2 on kernel
&lt;code&gt;7.0.14-4-pve&lt;/code&gt;. Proxmox gives me three primitives behind one API: full
VMs (QEMU/KVM), system containers (LXC), and ZFS storage. At a typical
moment the host sits under 1% CPU with ~18% of memory committed — the
first surprise the lab taught me is that the constraint on a homelab
server is almost never compute. It’s RAM and storage layout.&lt;/p&gt;
&lt;p&gt;Storage is where the two rebuilds paid off. Everything is ZFS, split by
what the disks are good at: a fast NVMe pool for container roots and VM
disks, a big spinning pool (~12 TiB raw between them) for bulk data, and
a separate pool that exists only to receive backups. Each container’s
root is its own dataset (the blog’s workspace lives on a &lt;code&gt;subvol&lt;/code&gt; under
the fast pool), which buys the three things I actually use daily:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Snapshots before anything risky.&lt;/strong&gt; Instantaneous, and rollback is
one command. The “no undo” failure that started this lab can’t happen
anymore — and it has saved me twice since.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-dataset quotas&lt;/strong&gt; instead of pre-carved disk images — the pool is
shared and containers take what they’re allotted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checksums on every block.&lt;/strong&gt; ZFS detects the bit-rot that a plain
RAID mirror silently propagates — the distinction I unpacked in
&lt;a href=&quot;https://harshith.in/blog/raid-what-it-does-and-doesnt/&quot;&gt;RAID: what it does and doesn’t&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-vmcontainer-line-is-a-sizing-lesson&quot;&gt;The VM/container line is a sizing lesson&lt;/h2&gt;
&lt;p&gt;I wrote about &lt;a href=&quot;https://harshith.in/blog/what-is-a-container/&quot;&gt;what a container really is&lt;/a&gt; —
namespaces plus cgroups, no guest kernel. What the lab taught me is that
this definition becomes a &lt;em&gt;budget&lt;/em&gt;. My first instinct was VMs for
everything, because VMs feel like “proper” isolation. Then I did the
arithmetic: a VM pins its allocation — give it 8 GB and that memory is
spoken for, plus a whole guest kernel. Twenty services as VMs would have
blown through 96 GB before doing any work. An LXC container shares the
host kernel; its “8 GB” is a cgroup ceiling, and unused memory stays in
the host page cache where it does something useful.&lt;/p&gt;
&lt;p&gt;So almost everything is LXC: a reverse proxy, the Cloudflare Tunnel
connector, a Tailscale node, a photo library, a vector database, an LLM
chat UI, a code-server, a Docker host for compose-shaped things, some
monitoring, and the container that runs the coding agent maintaining
this site — 4 vCPUs and 8 GB, strange enough to deserve
&lt;a href=&quot;https://harshith.in/blog/an-ai-coworker-in-an-lxc/&quot;&gt;its own post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The VMs are the exceptions that prove the rule, each needing its own
kernel: a Windows 11 guest, an identity/auth appliance I want strongly
isolated, and an emulated ARM machine for cross-architecture testing.
The tell from inside a container: &lt;code&gt;systemd-detect-virt&lt;/code&gt; says &lt;code&gt;lxc&lt;/code&gt;, and
&lt;code&gt;uname -r&lt;/code&gt; shows the &lt;em&gt;host’s&lt;/em&gt; &lt;code&gt;-pve&lt;/code&gt; kernel. One kernel, many userspaces.&lt;/p&gt;
&lt;h2 id=&quot;the-network-does-the-isolation&quot;&gt;The network does the isolation&lt;/h2&gt;
&lt;p&gt;The second lesson came from the network side, and it arrived the way
these lessons do — as a “wait, &lt;em&gt;that&lt;/em&gt; can see &lt;em&gt;this&lt;/em&gt;?” moment. A flat
network means the cheapest, least-trustworthy device on it (a smart
plug, a guest’s phone) sits one hop from the hypervisor’s management
interface. Compute isolation is worthless if the network hands out
adjacency for free.&lt;/p&gt;
&lt;p&gt;So the other half of the lab is a UniFi stack — a UCG Ultra gateway, a
PoE switch, and a WiFi 6 access point — with the flat-network problem
solved the way &lt;a href=&quot;https://harshith.in/blog/self-hosting-networking-stack/&quot;&gt;the homelab networking
post&lt;/a&gt; prescribes: &lt;strong&gt;segmentation
by trust&lt;/strong&gt;. The management network hangs off &lt;code&gt;192.168.0.0/24&lt;/code&gt;, and
everything else lives in its own VLAN and &lt;code&gt;10.10.x.0/24&lt;/code&gt; — IoT gadgets,
guest WiFi, office gear, public-facing servers, NAS traffic, VPN
clients, an experiment sandbox for things I don’t trust yet, and an
identity/auth segment. A dozen VLANs sounds like enterprise cosplay
until the first compromised smart plug can’t see your hypervisor. Then
it sounds like the minimum.&lt;/p&gt;
&lt;p&gt;SSIDs map onto the same segments — the IoT network is 2.4 GHz-only,
which is where small embedded things like
&lt;a href=&quot;https://harshith.in/blog/waking-the-lab-esp32-mqtt-wol/&quot;&gt;my wake-on-LAN gateway&lt;/a&gt; live.&lt;/p&gt;
&lt;p&gt;Nothing is port-forwarded. Remote access is Tailscale (mesh VPN in,
zero inbound holes), and the few deliberately public services go out
through a Cloudflare Tunnel — an outbound connection from a container,
so the LAN never accepts unsolicited traffic. The blog itself doesn’t
touch any of this: it’s &lt;a href=&quot;https://harshith.in/blog/how-this-blog-runs-for-0/&quot;&gt;static files on Cloudflare’s
edge&lt;/a&gt;, because a residential connection
is exactly where a public website should &lt;em&gt;not&lt;/em&gt; live.&lt;/p&gt;
&lt;p&gt;There’s also one machine outside the house: an Oracle Cloud free-tier
VM running an LLM API proxy — keys stay off the LAN, and it survives
local power cuts.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The lab exists to learn on. Its real product isn’t uptime — it’s the
lessons: isolation boundaries, sizing budgets, and recovery paths you
only understand after breaking them.&lt;/li&gt;
&lt;li&gt;One well-specced Proxmox box covers a remarkable amount of homelab;
RAM and storage tiering are the real constraints, not CPU.&lt;/li&gt;
&lt;li&gt;Prefer LXC over VMs for trusted Linux services — shared-kernel density
is the whole game on a single host. Reserve VMs for foreign kernels
and hard isolation boundaries.&lt;/li&gt;
&lt;li&gt;ZFS snapshots before every risky change; quota datasets instead of
fixed images; a dedicated backup pool that does nothing else.&lt;/li&gt;
&lt;li&gt;Segment by trust: VLANs per role, IoT quarantined, management network
unreachable from anything untrusted. VPN in, tunnel out, forward
nothing.&lt;/li&gt;
&lt;li&gt;Next in this series: &lt;a href=&quot;https://harshith.in/blog/the-fleet-why-bare-metal-earns-its-keep/&quot;&gt;the fleet of bare-metal machines around this
box — and what a hypervisor can’t
do&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>linux</category><category>systems</category><category>containers</category></item><item><title>How This Blog Runs for $0 a Month</title><link>https://harshith.in/blog/how-this-blog-runs-for-0/</link><guid isPermaLink="true">https://harshith.in/blog/how-this-blog-runs-for-0/</guid><description>No VPS, no database, no CMS bill. This site is a git repo that compiles to static files — search index, social cards, and fonts included — and Cloudflare Pages serves it for free. Here&apos;s the whole pipeline, with the actual configs.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;People assume a blog with full-text search, per-post social cards, RSS,
and a hundred-ish pages needs a server somewhere. This one has none. The
entire running cost is the domain registration — roughly the price of two
coffees a year — and everything else is $0. Not “free tier that becomes
$40/month when you get traffic” free. Actually free, by construction.&lt;/p&gt;
&lt;p&gt;The trick isn’t a discount. It’s an architectural decision: &lt;strong&gt;nothing
happens at request time&lt;/strong&gt;. If no code runs when you load a page, there’s
nothing to bill.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The pipeline&lt;/h2&gt;
&lt;p&gt;The whole system is one directed graph, and every arrow fires at build
time:&lt;/p&gt;
&lt;div class=&quot;post-diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 460 430&quot; role=&quot;img&quot; aria-label=&quot;Build pipeline: Markdown in git flows into the Astro build, which fans out into HTML, a Pagefind search index, OG social cards, and sitemap/RSS — all served by Cloudflare Pages&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
  &lt;title&gt;Everything fires at build time, Cloudflare Pages serves the result&lt;/title&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;pipe-arr&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
    &lt;marker id=&quot;pipe-arr-hl&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&gt;
      &lt;path d=&quot;M0 0 L8 4 L0 8 Z&quot; class=&quot;d-arrow-hl&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
  &lt;/defs&gt;
  &lt;rect x=&quot;130&quot; y=&quot;20&quot; width=&quot;200&quot; height=&quot;52&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;42&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Markdown in git&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;61&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;one file per URL&lt;/text&gt;
  &lt;line x1=&quot;230&quot; y1=&quot;72&quot; x2=&quot;230&quot; y2=&quot;100&quot; class=&quot;d-line-hl&quot; marker-end=&quot;url(#pipe-arr-hl)&quot;&gt;&lt;/line&gt;
  &lt;text x=&quot;242&quot; y=&quot;93&quot; class=&quot;d-ta&quot;&gt;git push&lt;/text&gt;
  &lt;rect x=&quot;130&quot; y=&quot;104&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box-hl&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;128&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Astro build&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;147&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;schema-checked&lt;/text&gt;
  &lt;path d=&quot;M 230 160 L 68 214&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 230 160 L 176 214&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 230 160 L 284 214&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 230 160 L 392 214&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;rect x=&quot;20&quot; y=&quot;218&quot; width=&quot;96&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;68&quot; y=&quot;242&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;HTML&lt;/text&gt;
  &lt;text x=&quot;68&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;+ CSS&lt;/text&gt;
  &lt;rect x=&quot;128&quot; y=&quot;218&quot; width=&quot;96&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;176&quot; y=&quot;242&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;Pagefind&lt;/text&gt;
  &lt;text x=&quot;176&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;index&lt;/text&gt;
  &lt;rect x=&quot;236&quot; y=&quot;218&quot; width=&quot;96&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;284&quot; y=&quot;242&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;OG cards&lt;/text&gt;
  &lt;text x=&quot;284&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;sharp&lt;/text&gt;
  &lt;rect x=&quot;344&quot; y=&quot;218&quot; width=&quot;96&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;392&quot; y=&quot;242&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;sitemap&lt;/text&gt;
  &lt;text x=&quot;392&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;RSS&lt;/text&gt;
  &lt;path d=&quot;M 68 274 L 226 326&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 176 274 L 228 326&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 284 274 L 232 326&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;path d=&quot;M 392 274 L 234 326&quot; class=&quot;d-line&quot; marker-end=&quot;url(#pipe-arr)&quot;&gt;&lt;/path&gt;
  &lt;rect x=&quot;130&quot; y=&quot;330&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;6&quot; class=&quot;d-box&quot;&gt;&lt;/rect&gt;
  &lt;text x=&quot;230&quot; y=&quot;354&quot; text-anchor=&quot;middle&quot; class=&quot;d-t1&quot;&gt;Cloudflare Pages&lt;/text&gt;
  &lt;text x=&quot;230&quot; y=&quot;373&quot; text-anchor=&quot;middle&quot; class=&quot;d-t2&quot;&gt;static edge · $0&lt;/text&gt;
&lt;/svg&gt;&lt;p&gt;&lt;text x=&quot;230&quot; y=&quot;418&quot; text-anchor=&quot;middle&quot; class=&quot;d-ta&quot;&gt;nothing happens at request time&lt;/text&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Posts are Markdown files in &lt;code&gt;src/content/blog/&lt;/code&gt; — one file per URL. There
is no CMS and no database; the git history &lt;em&gt;is&lt;/em&gt; the edit history, and a
&lt;code&gt;git revert&lt;/code&gt; is my rollback story. Astro reads the collection at build
time, type-checks the frontmatter against a schema, and emits plain HTML.&lt;/p&gt;
&lt;h2 id=&quot;hosting-cloudflare-pages&quot;&gt;Hosting: Cloudflare Pages&lt;/h2&gt;
&lt;p&gt;Cloudflare Pages watches the GitHub repo. Every push to &lt;code&gt;main&lt;/code&gt; triggers a
build (&lt;code&gt;astro build&lt;/code&gt;, about a minute) and atomically swaps the deployment.
The free plan serves static assets with &lt;strong&gt;unmetered bandwidth&lt;/strong&gt; from their
edge — the same CDN story I dug into in
&lt;a href=&quot;https://harshith.in/blog/http-caching-contract/&quot;&gt;the HTTP caching contract&lt;/a&gt;, except here
every response is cacheable forever because nothing is dynamic. TLS is
included; the &lt;a href=&quot;https://harshith.in/blog/tls-handshake-mechanics/&quot;&gt;handshake&lt;/a&gt; terminates at
their edge, not on any machine I pay for.&lt;/p&gt;
&lt;p&gt;The limits that matter on the free plan: 500 builds per month (I use
maybe 30) and a 25 MiB per-file cap (largest file here is a font subset,
~50 KiB). Traffic spikes are Cloudflare’s problem, which is exactly where
I want that problem to live.&lt;/p&gt;
&lt;h2 id=&quot;search-without-a-server&quot;&gt;Search without a server&lt;/h2&gt;
&lt;p&gt;Full-text search is the feature everyone assumes needs a backend. It
doesn’t. &lt;a href=&quot;https://pagefind.app&quot;&gt;Pagefind&lt;/a&gt; runs &lt;em&gt;after&lt;/em&gt; the build, crawls
&lt;code&gt;dist/&lt;/code&gt;, and writes a chunked, compressed index as static files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;build&quot;: &quot;astro build &amp;#x26;&amp;#x26; pagefind --site dist&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The browser lazy-loads only the index fragments a query touches — a
search costs a few tens of kilobytes, not a round-trip to a search
service. The index is scoped with &lt;code&gt;data-pagefind-body&lt;/code&gt; so navigation
chrome doesn’t pollute results. Total infrastructure: zero.&lt;/p&gt;
&lt;h2 id=&quot;social-cards-fonts-ci--all-build-time&quot;&gt;Social cards, fonts, CI — all build-time&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;OG images.&lt;/strong&gt; Every post gets a 1200×630 social card at &lt;code&gt;/og/&amp;#x3C;slug&gt;.png&lt;/code&gt;,
rendered during the build by an Astro endpoint that feeds an SVG template
through sharp. When a post’s title changes, the next build regenerates its
card. No image service, no on-demand rendering λ.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fonts.&lt;/strong&gt; The three typefaces are self-hosted woff2 files with
&lt;code&gt;@font-face&lt;/code&gt; and preload hints — no Google Fonts request, no third party
in the critical path, and one less DNS lookup (a cost I’ve
&lt;a href=&quot;https://harshith.in/blog/dns-resolution-path/&quot;&gt;measured before&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CI.&lt;/strong&gt; GitHub Actions runs &lt;code&gt;tsc --noEmit&lt;/code&gt; and a full build on every
push. Free for public repos. It’s the reviewer that never gets tired: a
broken frontmatter field or a dead import fails the check before
Cloudflare ever sees the commit.&lt;/p&gt;
&lt;h2 id=&quot;the-bill&quot;&gt;The bill&lt;/h2&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Component&lt;/th&gt;&lt;th&gt;Provider&lt;/th&gt;&lt;th&gt;Cost&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Hosting + CDN + TLS&lt;/td&gt;&lt;td&gt;Cloudflare Pages&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Search&lt;/td&gt;&lt;td&gt;Pagefind (static)&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Social cards&lt;/td&gt;&lt;td&gt;sharp at build time&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;CI&lt;/td&gt;&lt;td&gt;GitHub Actions&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Analytics&lt;/td&gt;&lt;td&gt;none (deliberately)&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Domain&lt;/td&gt;&lt;td&gt;registrar&lt;/td&gt;&lt;td&gt;~$10/yr&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The domain is the only line item, and it’s the one thing that can’t be
static.&lt;/p&gt;
&lt;h2 id=&quot;what-this-architecture-costs-you-instead&quot;&gt;What this architecture costs you instead&lt;/h2&gt;
&lt;p&gt;Honesty section: you pay in constraints, not dollars. No comments without
a third-party embed. No personalization, ever. Content changes require a
build (~90 seconds from push to live), so this is the wrong shape for
anything real-time. And build-time work scales with content — at
thousands of posts, the OG-image step would dominate and I’d need to cache
between builds.&lt;/p&gt;
&lt;p&gt;For a technical blog, none of those bite. The failure mode of a $6 VPS is
a 3 AM page; the failure mode of this stack is that I wait a minute for a
deploy.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Move all computation to build time.&lt;/strong&gt; If nothing runs at request
time, hosting is free and outages need Cloudflare-sized incompetence.&lt;/li&gt;
&lt;li&gt;Full-text search does not require a server — Pagefind ships a static,
lazily-loaded index that costs kilobytes per query.&lt;/li&gt;
&lt;li&gt;Social cards, fonts, sitemaps, RSS: all derivable from content at build
time. Derive them; don’t serve them.&lt;/li&gt;
&lt;li&gt;Git as CMS gives you review, rollback, and history for free — and lets
CI reject broken content before it deploys.&lt;/li&gt;
&lt;li&gt;The next post in this series covers where the &lt;em&gt;rest&lt;/em&gt; of my
infrastructure lives: &lt;a href=&quot;https://harshith.in/series/homelab/&quot;&gt;the homelab&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>web</category><category>systems</category><category>tools</category></item><item><title>How LLM Inference Actually Runs: Tokens, KV Cache, and Why It&apos;s Memory-Bound</title><link>https://harshith.in/blog/how-llm-inference-works/</link><guid isPermaLink="true">https://harshith.in/blog/how-llm-inference-works/</guid><description>A language model generates one token at a time, and each token re-reads gigabytes of weights and a growing cache. Understanding the KV cache and the memory-bandwidth wall explains latency, context limits, and every serving optimization from the last two years.</description><pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I came to LLMs from the systems side, and the framing that finally made
them legible wasn’t the math of attention — it was treating inference
as what it is: a memory-bound serving workload with an unusual cache.
Once you see that a model generating text is &lt;em&gt;bandwidth-limited, not
compute-limited&lt;/em&gt;, the whole landscape of confusing behavior (why the
first token is slow but the rest are steady, why context length costs
so much memory, why batching is everything) falls into place. This is
that systems-eye view.&lt;/p&gt;
&lt;h2 id=&quot;generation-is-a-loop-and-each-step-re-reads-the-model&quot;&gt;Generation Is a Loop, and Each Step Re-Reads the Model&lt;/h2&gt;
&lt;p&gt;An autoregressive LLM produces one &lt;strong&gt;token&lt;/strong&gt; at a time (tokens are
sub-word chunks — a tokenizer splits text into ~4-characters-ish
pieces from a fixed vocabulary). Each step: feed the whole sequence so
far through the network, get a probability distribution over the
vocabulary, sample one token, append it, repeat.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  &quot;The capital of France is&quot; → [forward pass] → &quot;Paris&quot; (prob 0.8...)
  &quot;The capital of France is Paris&quot; → [forward pass] → &quot;.&quot; → ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The systems-critical fact: &lt;strong&gt;every single token requires reading all
of the model’s weights.&lt;/strong&gt; A 70-billion-parameter model at 16-bit is
140 GB; at 4-bit quantization ~35 GB. To generate one token, the
hardware streams those tens of gigabytes from GPU memory through the
compute units. To generate 500 tokens, it does that 500 times. The
arithmetic per token is modest; the &lt;em&gt;data movement&lt;/em&gt; is enormous —
which means inference speed is gated by &lt;strong&gt;memory bandwidth&lt;/strong&gt;, not
FLOPs. A GPU rated for petaflops sits mostly idle during single-stream
decode, waiting on memory. This one fact is why an H100’s headline
compute number tells you almost nothing about its tokens-per-second.&lt;/p&gt;
&lt;h2 id=&quot;the-kv-cache-the-optimization-everything-depends-on&quot;&gt;The KV Cache: The Optimization Everything Depends On&lt;/h2&gt;
&lt;p&gt;Naively, re-processing the entire growing sequence each step is
O(n²) — token 500 reprocesses 499 predecessors. The fix that makes
inference tractable is the &lt;strong&gt;KV cache&lt;/strong&gt;: attention computes a “key”
and “value” vector per token, and &lt;em&gt;those don’t change&lt;/em&gt; once computed.
So cache them; each new token computes its own K/V and attends over
the cached ones, turning per-step work from “reprocess everything” to
“process one new token against the cache.”&lt;/p&gt;
&lt;p&gt;This splits inference into two phases with completely different
performance characteristics — the distinction behind every latency
number you’ll see quoted:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prefill&lt;/strong&gt;: process the entire prompt at once, building the KV
cache. Parallel across all prompt tokens, compute-heavy, fast per
token. This is your &lt;strong&gt;time-to-first-token&lt;/strong&gt;, and it scales with
prompt length.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decode&lt;/strong&gt;: generate tokens one at a time, each reading the whole
model + the whole KV cache. Sequential, memory-bound, one token per
forward pass. This sets your &lt;strong&gt;tokens-per-second&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;“Long prompts are slow to start, then generate steadily” is exactly
this: a big prefill, then bandwidth-limited decode.&lt;/p&gt;
&lt;p&gt;The KV cache also explains the &lt;strong&gt;context-length memory wall&lt;/strong&gt;. The
cache grows linearly with sequence length and is often the dominant
memory consumer at long context — potentially many gigabytes for one
long conversation, &lt;em&gt;per request&lt;/em&gt;. This is why long context is
expensive, why serving frameworks obsess over it, and why &lt;strong&gt;PagedAttention&lt;/strong&gt;
(the idea behind vLLM, ~2023) mattered so much: it manages the KV
cache in fixed pages like an OS manages virtual memory, ending the
fragmentation that used to waste most of it — a page table for
attention, which is a deeply satisfying convergence for anyone who’s
read this blog’s OS posts.&lt;/p&gt;
&lt;h2 id=&quot;why-serving-looks-the-way-it-does&quot;&gt;Why Serving Looks the Way It Does&lt;/h2&gt;
&lt;p&gt;Every production inference optimization is an answer to “we’re
memory-bandwidth-bound, so amortize the weight reads”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Batching is the core lever.&lt;/strong&gt; Reading the weights to serve one
request costs the same as reading them to serve fifty &lt;em&gt;concurrent&lt;/em&gt;
requests — the weights stream once, the matmul does more work per
byte read. So throughput scales dramatically with batch size until
compute or KV-cache memory becomes the limit. &lt;strong&gt;Continuous batching&lt;/strong&gt;
(swap finished sequences out and new ones in each step, rather than
waiting for a whole batch to finish) is why modern servers hit high
utilization. This is also the fundamental tension: latency (small
batch, dedicated bandwidth) vs throughput/cost (big batch).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quantization&lt;/strong&gt; (16-bit → 8/4-bit weights) directly attacks the
bottleneck: fewer bytes per weight = less to stream = faster decode
and less memory, at some accuracy cost. It’s a bandwidth
optimization first, a memory one second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speculative decoding&lt;/strong&gt; breaks the one-token-per-pass sequential
floor: a small cheap model drafts several tokens, the big model
&lt;em&gt;verifies&lt;/em&gt; them in one parallel pass, accepting the run that
matches. Multiple tokens per expensive forward pass when the draft
is good.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this requires understanding transformers deeply — it requires
understanding that you’re moving a lot of memory repeatedly, which is
the most systems-engineering problem imaginable. Profiling LLM serving
is bandwidth accounting, queueing, and cache management: familiar
tools pointed at a new workload.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;LLMs decode one token at a time, re-reading all model weights per
token — inference is memory-bandwidth-bound, so GPU FLOP ratings
mislead.&lt;/li&gt;
&lt;li&gt;The KV cache turns O(n²) decode into per-token work by caching
unchanging key/value vectors; prefill (TTFT) and decode (tok/s) are
separate performance regimes.&lt;/li&gt;
&lt;li&gt;KV cache grows with context length and is the long-context memory
wall; PagedAttention manages it like OS virtual memory.&lt;/li&gt;
&lt;li&gt;Batching amortizes the fixed weight-read cost across requests —
continuous batching is why servers stay utilized; latency vs
throughput is the central trade.&lt;/li&gt;
&lt;li&gt;Quantization cuts bytes-per-weight (bandwidth), speculative decoding
cuts passes-per-token — both attack the real bottleneck.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>performance</category><category>systems</category></item><item><title>Reading Flame Graphs: Where Your CPU Time Actually Goes</title><link>https://harshith.in/blog/flame-graphs-and-perf/</link><guid isPermaLink="true">https://harshith.in/blog/flame-graphs-and-perf/</guid><description>A flame graph turns thousands of stack samples into one picture where width is time and the widest boxes are your problem. How sampling profilers work, how to read the graph in ten seconds, and the traps — inlining, wait time, broken stacks — that mislead beginners.</description><pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;“I trace, profile, and prove it” is the tagline of this whole site, and
the flame graph is the single most efficient tool for the &lt;em&gt;prove it&lt;/em&gt;
part. I lean on it enough that I &lt;a href=&quot;https://harshith.in/projects&quot;&gt;built a profiler around live flame
graphs&lt;/a&gt; streamed to a browser. But the tool only helps if
you can read it correctly — and the common misreadings (mistaking depth
for cost, missing that the profiler only sees on-CPU time) send people
optimizing the wrong function. Here’s how sampling works and how to
read the picture in ten seconds flat.&lt;/p&gt;
&lt;h2 id=&quot;sampling-statistics-not-surveillance&quot;&gt;Sampling: Statistics, Not Surveillance&lt;/h2&gt;
&lt;p&gt;A sampling profiler like &lt;code&gt;perf&lt;/code&gt; doesn’t trace every function — that
would be ruinously slow and would distort the thing it measures.
Instead it interrupts the program at a fixed frequency (say 99 Hz —
99, not 100, to avoid lockstep with periodic timers) and records the
&lt;strong&gt;call stack&lt;/strong&gt; at that instant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  99 times/sec:  freeze → walk the stack → log it → resume
  after 30s:  ~3000 stacks, each a snapshot of &quot;what was running&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The statistical bet is simple and sound: &lt;strong&gt;a function that appears in
50% of samples was on-CPU ~50% of the time.&lt;/strong&gt; You don’t need to catch
every call; you need enough samples that the proportions are
trustworthy — which is why short profiles of rare events are unreliable
and why 99 Hz over 30 seconds beats 4000 Hz over one. Low overhead
(a few percent) is the whole reason sampling beats instrumentation for
production profiling.&lt;/p&gt;
&lt;h2 id=&quot;reading-the-graph-in-ten-seconds&quot;&gt;Reading the Graph in Ten Seconds&lt;/h2&gt;
&lt;p&gt;Thousands of stacks are unreadable as a list. The flame graph
(Brendan Gregg’s invention, ~2011) aggregates them: identical stacks
merge, and the result is drawn as stacked boxes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   ┌──────────────────────────────────────────────┐  ← full profile
   │ main                                          │
   ├───────────────────────┬──────────────────────┤
   │ handle_request        │ background_flush      │
   ├───────────┬───────────┼──────────────────────┤
   │ parse     │ db_query ▓▓▓▓▓▓ (wide = HOT)      │
   └───────────┴───────────┴──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two rules that are the entire skill:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Width = time.&lt;/strong&gt; A box’s width is the fraction of samples
containing that frame. &lt;strong&gt;Wide boxes are where the CPU time goes.&lt;/strong&gt;
This is the only thing you’re hunting for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Y-axis = stack depth, NOT importance.&lt;/strong&gt; Boxes stack because one
function called another. A tall tower is just deep call nesting; it
says &lt;em&gt;nothing&lt;/em&gt; about cost. The #1 beginner error is chasing tall
spires — look for &lt;strong&gt;wide plateaus&lt;/strong&gt;, especially wide boxes near the
&lt;em&gt;top&lt;/em&gt; (leaf functions doing actual work, not just calling down).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s it: scan across the top for the widest boxes, and those are your
targets. Colors are usually just for visual separation (warm random
palette); ignore them unless the graph specifically uses color to
encode something (some tools tint by language or by whether frames are
kernel vs user). Interactive versions let you click a box to zoom into
that subtree — the fast path to “what is &lt;code&gt;db_query&lt;/code&gt; spending its time
on.”&lt;/p&gt;
&lt;h2 id=&quot;the-traps-that-mislead&quot;&gt;The Traps That Mislead&lt;/h2&gt;
&lt;p&gt;A flame graph is honest about what it measures and silent about what it
doesn’t — and the gaps are exactly where people get fooled:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It only shows on-CPU time.&lt;/strong&gt; A program blocked on disk, network, a
lock, or &lt;code&gt;sleep&lt;/code&gt; is &lt;em&gt;not running&lt;/em&gt;, so it generates no samples —
time spent waiting is &lt;em&gt;invisible&lt;/em&gt; on a standard CPU flame graph. If
your service is slow but the flame graph looks idle or unremarkable,
your bottleneck is &lt;strong&gt;off-CPU&lt;/strong&gt; (I/O or lock contention), and you
need an off-CPU flame graph (from scheduler tracing) instead. This
single misunderstanding accounts for most “the profiler says
nothing’s wrong but it’s slow” confusion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inlining hides functions.&lt;/strong&gt; At -O2 the compiler inlines aggressively
(see my optimization post), so a hot function may not appear as its
own frame — its time is attributed to the caller it was folded into.
Confusing until you remember the optimizer erased the boundary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broken stacks need frame pointers.&lt;/strong&gt; Reliable stack walking wants
frame pointers, which optimized builds often omit (&lt;code&gt;-fno-omit-frame-pointer&lt;/code&gt;
to keep them), or DWARF/LBR-based unwinding as the fallback. Symptoms
are truncated towers or &lt;code&gt;[unknown]&lt;/code&gt; frames — a collection problem,
not a code problem. (Fedora and others flipped frame pointers back on
by default in 2023 largely to make production profiling trustworthy
again.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sampling misses the rare.&lt;/strong&gt; A function called once for 5 ms in a
30 s profile may not appear at all. Flame graphs answer “where does
the &lt;em&gt;bulk&lt;/em&gt; go,” not “did this specific thing happen.”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Used with those caveats in mind, it’s the fastest path from “the
system is slow” to a specific function and line — capture with
&lt;code&gt;perf record -F 99 -g -- ./app&lt;/code&gt;, fold, and render, or use any of the
tools that do it live. The discipline is always the same: measure,
read width not height, and check whether your time is even on-CPU
before you trust the picture.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Sampling profilers snapshot the stack at fixed frequency; frame
frequency ≈ time spent — low overhead, statistically sound over
enough samples.&lt;/li&gt;
&lt;li&gt;Read width, not height: wide boxes (especially wide leaves at the
top) are the cost; tall towers are just deep call chains.&lt;/li&gt;
&lt;li&gt;Standard flame graphs show only on-CPU time — I/O, lock, and sleep
waits are invisible; use off-CPU graphs when the CPU view looks
innocent but the service is slow.&lt;/li&gt;
&lt;li&gt;Inlining folds hot functions into callers; missing/&lt;code&gt;[unknown]&lt;/code&gt;
frames mean you need frame pointers or DWARF unwinding, not a code
fix.&lt;/li&gt;
&lt;li&gt;Capture with &lt;code&gt;perf record -F 99 -g&lt;/code&gt;; the workflow turns “it’s slow”
into “it’s this function” in minutes — the proof step behind
trace-and-prove.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>performance</category><category>linux</category><category>observability</category><category>tools</category></item><item><title>mmap vs read(): When Mapping a File Actually Wins</title><link>https://harshith.in/blog/mmap-vs-read-large-files/</link><guid isPermaLink="true">https://harshith.in/blog/mmap-vs-read-large-files/</guid><description>mmap is sold as the fast way to read files, but it loses as often as it wins. The deciding factor is page faults versus syscalls — and whether you touch the data once or many times.</description><pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;“Just &lt;code&gt;mmap&lt;/code&gt; it, it’s faster” is one of those pieces of advice that’s right often
enough to be dangerous. Memory mapping can be a big win or a quiet loss, and the
difference comes down to two costs you can reason about directly: &lt;strong&gt;syscalls&lt;/strong&gt; and
&lt;strong&gt;page faults&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-read-actually-does&quot;&gt;What read() Actually Does&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;read()&lt;/code&gt; copies bytes from the kernel page cache into your buffer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;char buf[64 * 1024];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) &gt; 0) {
    process(buf, n);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every call is a syscall — a user→kernel transition — plus a memcpy from the page
cache into your buffer. For a large file you pay that crossing many times, and
you pay for the copy every time.&lt;/p&gt;
&lt;h2 id=&quot;what-mmap-does-instead&quot;&gt;What mmap Does Instead&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;mmap&lt;/code&gt; maps the file’s pages into your address space. There’s no per-chunk
syscall and no copy into a user buffer — you read the page cache directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct stat st;
fstat(fd, &amp;#x26;st);
char *data = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

for (size_t i = 0; i &amp;#x3C; st.st_size; i++)
    process_byte(data[i]);   // first touch of each page faults it in

munmap(data, st.st_size);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The cost moves from syscalls to &lt;strong&gt;page faults&lt;/strong&gt;. The first access to each 4 KB
page traps into the kernel, which maps the cached page into your tables. No copy,
but a fault per page.&lt;/p&gt;
&lt;h2 id=&quot;the-tradeoff-in-one-picture&quot;&gt;The Tradeoff in One Picture&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   read():   [syscall][copy] [syscall][copy] [syscall][copy] ...
             └─ fixed cost per chunk, data copied into your buffer

   mmap():   [fault] . . . . [fault] . . . . [fault] . . . .
             └─ one fault per page, zero copy, paid lazily on touch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the question is just: which is cheaper for &lt;em&gt;your&lt;/em&gt; access pattern?&lt;/p&gt;
&lt;h2 id=&quot;when-mmap-wins&quot;&gt;When mmap Wins&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Random access&lt;/strong&gt; into a large file. &lt;code&gt;read&lt;/code&gt;/&lt;code&gt;lseek&lt;/code&gt; per location is brutal;
mapped memory makes it a pointer dereference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repeated passes&lt;/strong&gt; over the same data. Pages fault in once and stay warm; no
repeated copies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sharing&lt;/strong&gt; the same file read-only across processes — they share the cached
pages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;when-read-wins&quot;&gt;When read() Wins&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Streaming once, front to back.&lt;/strong&gt; You fault every page exactly once and never
reuse it — that’s the same work as a copy, minus the prefetch friendliness of a
plain sequential read. &lt;code&gt;read()&lt;/code&gt; with a decent buffer (or &lt;code&gt;readahead&lt;/code&gt;) is simpler
and often faster.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small files&lt;/strong&gt;, where &lt;code&gt;mmap&lt;/code&gt;/&lt;code&gt;munmap&lt;/code&gt; setup and TLB churn outweigh any savings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pipes, sockets, and other non-seekable fds&lt;/strong&gt;, which you can’t map at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;dont-forget-the-failure-modes&quot;&gt;Don’t Forget the Failure Modes&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;read()&lt;/code&gt; returns &lt;code&gt;-1&lt;/code&gt; and an &lt;code&gt;errno&lt;/code&gt; you can handle. A truncated &lt;code&gt;mmap&lt;/code&gt; region
hands you a &lt;strong&gt;&lt;code&gt;SIGBUS&lt;/code&gt;&lt;/strong&gt; when you touch a page past the file’s real end — an
async failure that’s far easier to get wrong. If the file can change size under
you, that risk is real.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;read()&lt;/code&gt; trades &lt;strong&gt;syscalls + a copy&lt;/strong&gt;; &lt;code&gt;mmap&lt;/code&gt; trades &lt;strong&gt;page faults + no copy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Map it for &lt;strong&gt;random access or repeated passes&lt;/strong&gt;; stream it with &lt;code&gt;read()&lt;/code&gt; for a
&lt;strong&gt;single sequential pass&lt;/strong&gt; or &lt;strong&gt;small files&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mmap&lt;/code&gt; can’t map pipes/sockets and turns I/O errors into &lt;code&gt;SIGBUS&lt;/code&gt; — handle that
before reaching for it.&lt;/li&gt;
&lt;li&gt;Measure with your real access pattern; the winner flips depending on whether you
touch each byte once or many times.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>linux</category><category>performance</category><category>systems</category></item><item><title>Anatomy of a Context Switch: What the Kernel Does Between Two Threads</title><link>https://harshith.in/blog/context-switch-anatomy/</link><guid isPermaLink="true">https://harshith.in/blog/context-switch-anatomy/</guid><description>A context switch looks free until you profile a thread-heavy workload and watch it vanish into the kernel. Here is everything the scheduler actually does to swap one thread for another.</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Schedulers feel like magic until you profile a thread-heavy workload and watch
real time disappear into &lt;code&gt;__schedule&lt;/code&gt;. A context switch is not free, and the cost
is not where most people think. Let’s trace exactly what happens between the
instant one thread stops running and the next one starts.&lt;/p&gt;
&lt;h2 id=&quot;what-counts-as-a-context-switch&quot;&gt;What Counts as a Context Switch&lt;/h2&gt;
&lt;p&gt;Two very different things wear the same name:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Voluntary&lt;/strong&gt; — a thread blocks (I/O, a lock, &lt;code&gt;sleep&lt;/code&gt;) and hands the CPU back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Involuntary&lt;/strong&gt; — the scheduler preempts a thread that still wanted to run.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both end up in the same kernel routine, but the trigger matters: involuntary
switches cluster under contention and are the ones that wreck tail latency.&lt;/p&gt;
&lt;h2 id=&quot;the-three-things-the-kernel-saves&quot;&gt;The Three Things the Kernel Saves&lt;/h2&gt;
&lt;p&gt;When &lt;code&gt;schedule()&lt;/code&gt; decides thread B should run instead of thread A, it has to make
A resumable later and B runnable now. Three pieces of state move:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// Simplified from kernel/sched/core.c
static void context_switch(struct rq *rq,
                           struct task_struct *prev,
                           struct task_struct *next)
{
    prepare_task_switch(rq, prev, next);
    switch_mm_irqs_off(prev-&gt;active_mm, next-&gt;mm, next); // address space
    switch_to(prev, next, prev);                         // registers + stack
    finish_task_switch(prev);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CPU registers&lt;/strong&gt; — general-purpose registers, the stack pointer, and the
instruction pointer get parked in thread A’s kernel stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The address space&lt;/strong&gt; — if B belongs to a different process, &lt;code&gt;switch_mm&lt;/code&gt;
installs B’s page tables by writing &lt;code&gt;CR3&lt;/code&gt; on x86.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bookkeeping&lt;/strong&gt; — runqueue accounting, scheduler statistics, and FPU state
(saved lazily on most CPUs).&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;where-the-cycles-actually-go&quot;&gt;Where the Cycles Actually Go&lt;/h2&gt;
&lt;p&gt;The register save/restore is cheap — tens of nanoseconds. The expensive part is
the address-space change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;        thread A                 thread B
   ┌──────────────┐         ┌──────────────┐
   │ registers    │  fast   │ registers    │
   │ kernel stack │ ──────▶ │ kernel stack │
   └──────────────┘         └──────────────┘
          │  write CR3 (only if mm differs)
          ▼
   ┌─────────────────────────────────────────┐
   │  TLB entries for A may now be invalid    │  ← the real tax
   └─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Writing &lt;code&gt;CR3&lt;/code&gt; can flush the TLB. The next thread then takes a burst of TLB
misses as it re-walks page tables for code and data it touches. That refill —
not the register copy — is what you see in a flame graph.&lt;/p&gt;
&lt;p&gt;This is why &lt;strong&gt;thread switches within the same process are cheaper than process
switches&lt;/strong&gt;: same &lt;code&gt;mm&lt;/code&gt;, no &lt;code&gt;CR3&lt;/code&gt; write, TLB stays warm. It’s also why &lt;code&gt;PCID&lt;/code&gt;
(process-context identifiers) exists — tagging TLB entries so a switch doesn’t
have to flush everything.&lt;/p&gt;
&lt;h2 id=&quot;the-path-end-to-end&quot;&gt;The Path, End to End&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
    A[Thread A running] --&gt; B{schedule called}
    B --&gt; C[pick next: Thread B]
    C --&gt; D[switch_mm: write CR3]
    D --&gt; E[switch_to: swap registers + stack]
    E --&gt; F[Thread B running]
    F --&gt; G[TLB refills on first accesses]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;measuring-it-yourself&quot;&gt;Measuring It Yourself&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;perf&lt;/code&gt; makes the cost visible without guesswork:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Count context switches for a command
perf stat -e context-switches,cpu-migrations ./your_workload

# See who is switching and why
perf sched record ./your_workload
perf sched latency --sort max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;context-switches&lt;/code&gt; scales with load while useful work stalls, you’re paying
the scheduler tax — usually from lock contention or oversubscribed threads.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A context switch saves registers, may swap the address space, and updates
scheduler bookkeeping — in that order of cost.&lt;/li&gt;
&lt;li&gt;The register copy is cheap; the &lt;strong&gt;TLB refill after a &lt;code&gt;CR3&lt;/code&gt; write&lt;/strong&gt; is the real
expense. Same-process thread switches avoid it.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PCID&lt;/code&gt;/tagged TLBs exist specifically to soften that cost.&lt;/li&gt;
&lt;li&gt;Reach for &lt;code&gt;perf stat -e context-switches&lt;/code&gt; and &lt;code&gt;perf sched latency&lt;/code&gt; before
blaming the scheduler — most “scheduler overhead” is contention in disguise.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>kernel</category><category>performance</category><category>linux</category></item><item><title>Floating Point: Why 0.1 + 0.2 Isn&apos;t 0.3</title><link>https://harshith.in/blog/floating-point-explained/</link><guid isPermaLink="true">https://harshith.in/blog/floating-point-explained/</guid><description>Every language prints 0.30000000000000004 and every newcomer files a bug report. It&apos;s not a bug — it&apos;s binary fractions doing exactly what the IEEE 754 standard says. Here&apos;s the why, and what to do about it.</description><pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Type &lt;code&gt;0.1 + 0.2&lt;/code&gt; into almost any language and you get &lt;code&gt;0.30000000000000004&lt;/code&gt;.
This is the most reported non-bug in computing. The floating-point unit is working
perfectly; the problem is that you asked it to store a number it physically
cannot represent.&lt;/p&gt;
&lt;h2 id=&quot;computers-count-in-binary-fractions&quot;&gt;Computers Count in Binary Fractions&lt;/h2&gt;
&lt;p&gt;A float stores numbers as binary fractions — sums of powers of two:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   0.5    = 1/2           = 0.1   in binary
   0.75   = 1/2 + 1/4     = 0.11  in binary
   0.625  = 1/2 + 1/8     = 0.101 in binary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any value that’s a sum of powers of two fits exactly. The trouble: &lt;strong&gt;0.1 is not&lt;/strong&gt;.
In binary it’s a repeating fraction, like 1/3 is in decimal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   0.1 (decimal) = 0.0001100110011001100110011… (binary, forever)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hardware has finite bits (53 of significand in a 64-bit double), so it stores
the closest representable value — slightly off. Add two slightly-off numbers and
the errors surface in the last digits.&lt;/p&gt;
&lt;h2 id=&quot;ieee-754-in-one-picture&quot;&gt;IEEE 754 in One Picture&lt;/h2&gt;
&lt;p&gt;A double splits its 64 bits like scientific notation in binary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   ┌─┬───────────┬────────────────────────────────────┐
   │S│ exponent  │            significand             │
   │1│   11 bits │              52 bits               │
   └─┴───────────┴────────────────────────────────────┘
    sign  scale (×2^e)     the fractional digits

   value = (-1)^S × 1.significand × 2^(exponent − bias)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Roughly 15–17 significant decimal digits of precision. Plenty — until you assume
it’s exact.&lt;/p&gt;
&lt;h2 id=&quot;the-rules-that-follow&quot;&gt;The Rules That Follow&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Never test floats for equality.&lt;/strong&gt; &lt;code&gt;0.1 + 0.2 == 0.3&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. Compare within
a tolerance instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;if (fabs(a - b) &amp;#x3C; 1e-9)   // &quot;close enough&quot;, not exact
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Real code should scale the tolerance to the magnitude of the values — an absolute
epsilon is wrong for very large or very small numbers.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Precision degrades with magnitude.&lt;/strong&gt; The 53 bits are relative, so big numbers
have coarse spacing. Past 2^53, integers themselves stop being representable —
&lt;code&gt;9007199254740993.0&lt;/code&gt; rounds to an even neighbor.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don’t use floats for money.&lt;/strong&gt; Repeated rounding accumulates. Use integer cents,
or a decimal type that represents base-10 fractions exactly.&lt;/p&gt;
&lt;h2 id=&quot;when-you-need-exactness&quot;&gt;When You Need Exactness&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Money / accounting&lt;/strong&gt; → integers (cents) or a decimal library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comparisons&lt;/strong&gt; → tolerance-based, scaled to magnitude.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact rationals&lt;/strong&gt; → a big-rational type that stores numerator/denominator.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Floats are a deliberate trade: enormous range and speed in exchange for exactness.
For physics and graphics that’s perfect; for a ledger it’s a liability.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Floats store binary fractions; decimals like 0.1 don’t fit, so they’re rounded
to the nearest representable value.&lt;/li&gt;
&lt;li&gt;IEEE 754 gives ~15–17 significant digits — relative precision, so large numbers
are spaced coarsely (integers break past 2^53).&lt;/li&gt;
&lt;li&gt;Never compare floats with &lt;code&gt;==&lt;/code&gt;; use a magnitude-scaled tolerance.&lt;/li&gt;
&lt;li&gt;Use integers or a decimal type for money — never binary floats.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>fundamentals</category><category>systems</category></item><item><title>Bloom Filters: Probably Present, Definitely Useful</title><link>https://harshith.in/blog/bloom-filters/</link><guid isPermaLink="true">https://harshith.in/blog/bloom-filters/</guid><description>A Bloom filter answers &apos;have I seen this before?&apos; using a fraction of the memory a real set would need — by allowing one specific kind of wrong answer. It&apos;s the data structure behind databases skipping disk reads they don&apos;t need.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You want to know if a key has been seen before, but storing every key costs more
memory than you have. A Bloom filter answers the membership question in a tiny
fixed-size bit array — at the price of occasionally saying “yes” when the true
answer is “no.” That one-sided error turns out to be exactly the trade real
systems want.&lt;/p&gt;
&lt;h2 id=&quot;a-bit-array-and-a-few-hashes&quot;&gt;A Bit Array and a Few Hashes&lt;/h2&gt;
&lt;p&gt;A Bloom filter is an array of bits, all zero to start, plus &lt;code&gt;k&lt;/code&gt; independent hash
functions. To &lt;strong&gt;add&lt;/strong&gt; an element, hash it &lt;code&gt;k&lt;/code&gt; ways and set those &lt;code&gt;k&lt;/code&gt; bits:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   add(&quot;cat&quot;):  h1→3  h2→7  h3→11
   bits: [0 0 0 1 0 0 0 1 0 0 0 1 0 0]
              3       7       11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To &lt;strong&gt;check&lt;/strong&gt; membership, hash the same way and look at those bits:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   check(&quot;cat&quot;): bits 3,7,11 all set?  → &quot;probably present&quot;
   check(&quot;dog&quot;): any of its bits 0?    → &quot;definitely absent&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If any required bit is 0, the element was &lt;em&gt;never&lt;/em&gt; added — a guaranteed answer. If
all are 1, it’s &lt;em&gt;probably&lt;/em&gt; present.&lt;/p&gt;
&lt;h2 id=&quot;the-asymmetry-that-makes-it-useful&quot;&gt;The Asymmetry That Makes It Useful&lt;/h2&gt;
&lt;p&gt;This is the key property:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   &quot;definitely not in the set&quot;   ← always correct (no false negatives)
   &quot;probably in the set&quot;         ← may be wrong   (false positives possible)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A false positive happens when other elements collectively set all of a missing
element’s bits. You never get a false negative — if it was added, its bits are
set. So a Bloom filter never &lt;em&gt;misses&lt;/em&gt; something it has seen; it only occasionally
&lt;em&gt;imagines&lt;/em&gt; something it hasn’t.&lt;/p&gt;
&lt;h2 id=&quot;tuning-the-error&quot;&gt;Tuning the Error&lt;/h2&gt;
&lt;p&gt;The false-positive rate is governed by the array size &lt;code&gt;m&lt;/code&gt;, the number of elements
&lt;code&gt;n&lt;/code&gt;, and the hash count &lt;code&gt;k&lt;/code&gt;. More bits per element → fewer false positives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   ~10 bits per element, optimal k  →  ~1% false positives
   add more bits                    →  exponentially fewer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You pick the rate you can tolerate and size the array accordingly. Note: you can’t
&lt;strong&gt;delete&lt;/strong&gt; from a basic Bloom filter — clearing a bit might unset one shared with
another element (a counting Bloom filter solves this).&lt;/p&gt;
&lt;h2 id=&quot;where-they-earn-their-keep&quot;&gt;Where They Earn Their Keep&lt;/h2&gt;
&lt;p&gt;The classic use is skipping expensive work. An LSM-tree database keeps a Bloom
filter per on-disk SSTable. Before reading a file from disk to look for a key, it
checks the filter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   lookup(key):
     for each SSTable:
       filter says &quot;absent&quot;?  → skip the file entirely (no disk read)
       filter says &quot;present&quot;? → read it and check for real
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 1% false-positive rate means 99% of pointless disk reads are avoided for a few
bits per key. The same pattern shows up in caches (“is this worth a lookup?”),
CDNs, and spell-checkers.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Bloom filter stores set membership in a bit array using &lt;code&gt;k&lt;/code&gt; hash functions —
far smaller than holding the elements themselves.&lt;/li&gt;
&lt;li&gt;It never returns a false negative but can return a false positive: “definitely
not” is exact, “probably yes” is not.&lt;/li&gt;
&lt;li&gt;Size and hash count trade memory for a lower false-positive rate; basic versions
can’t delete.&lt;/li&gt;
&lt;li&gt;They shine as a cheap pre-check to avoid expensive work — like an LSM-tree
skipping disk reads for absent keys.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>algorithms</category><category>databases</category></item><item><title>Git Internals: Your Repo Is a Content-Addressed Database</title><link>https://harshith.in/blog/git-internals/</link><guid isPermaLink="true">https://harshith.in/blog/git-internals/</guid><description>Git feels like a pile of special commands until you see the data model underneath: four object types in a key-value store keyed by hash. Learn the model and every confusing command suddenly makes sense.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Git’s command line is famously confusing. The data model underneath is not. It’s
a small content-addressed key-value store with exactly four object types. Once you
can see the objects, commands like &lt;code&gt;reset&lt;/code&gt;, &lt;code&gt;checkout&lt;/code&gt;, and &lt;code&gt;rebase&lt;/code&gt; stop being
incantations and become obvious manipulations of a graph.&lt;/p&gt;
&lt;h2 id=&quot;content-addressing-the-hash-is-the-key&quot;&gt;Content Addressing: The Hash Is the Key&lt;/h2&gt;
&lt;p&gt;Git stores everything by the hash of its content. Hash the bytes, and that hash
&lt;em&gt;is&lt;/em&gt; the address:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &apos;hello&apos; | git hash-object --stdin
# ce013625030ba8dba906f756967f9e9ca394464a
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same content always produces the same hash, so identical files are stored once.
Change one byte and you get a completely different address — which is how Git
detects any change and why history is tamper-evident.&lt;/p&gt;
&lt;h2 id=&quot;the-four-objects&quot;&gt;The Four Objects&lt;/h2&gt;
&lt;p&gt;Everything in &lt;code&gt;.git/objects&lt;/code&gt; is one of four types:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   blob    → file contents (no name, no metadata, just bytes)
   tree    → a directory: names + modes → blob/tree hashes
   commit  → a snapshot: one tree + parent(s) + author + message
   tag     → an annotated pointer to an object
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They compose into the snapshot of your project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   commit ──▶ tree ──▶ blob   (README.md)
                  └──▶ tree ──▶ blob   (src/main.c)
              parent
                │
                ▼
            commit ──▶ tree ──▶ ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A commit doesn’t store a diff — it points at a &lt;strong&gt;tree&lt;/strong&gt;, a full snapshot of every
file at that moment. Unchanged files reuse the same blob hashes across commits, so
snapshots are cheap despite being complete.&lt;/p&gt;
&lt;h2 id=&quot;commits-are-a-linked-list&quot;&gt;Commits Are a Linked List&lt;/h2&gt;
&lt;p&gt;Each commit records its parent(s), forming a directed graph back through history:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   A ◀── B ◀── C ◀── D   (main)
               ▲
               └── E ◀── F   (feature, branched at C)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;strong&gt;merge&lt;/strong&gt; is just a commit with two parents. There’s no special “branch
history” structure — branches are derived by walking parent pointers.&lt;/p&gt;
&lt;h2 id=&quot;refs-branches-are-just-pointers&quot;&gt;Refs: Branches Are Just Pointers&lt;/h2&gt;
&lt;p&gt;A branch isn’t a copy of anything. It’s a 40-character file containing the hash of
one commit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat .git/refs/heads/main
# d4f7a... (the tip commit of main)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Creating a branch writes one tiny file. That’s why branching is instant. &lt;code&gt;HEAD&lt;/code&gt;
points at the current branch; committing just advances the branch’s pointer to the
new commit. Now the commands fall out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;git reset&lt;/code&gt; moves a branch pointer to a different commit.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git checkout&lt;/code&gt;/&lt;code&gt;switch&lt;/code&gt; moves &lt;code&gt;HEAD&lt;/code&gt; and updates your working tree to that
tree’s blobs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git rebase&lt;/code&gt; replays commits onto a new parent, creating new commit objects.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Git is a content-addressed store: an object’s address is the hash of its
content, so identical content is deduplicated and history is tamper-evident.&lt;/li&gt;
&lt;li&gt;Four object types — blob, tree, commit, tag — compose into full snapshots, not
diffs.&lt;/li&gt;
&lt;li&gt;Commits link to parents to form the history graph; a merge is a two-parent
commit.&lt;/li&gt;
&lt;li&gt;Branches and &lt;code&gt;HEAD&lt;/code&gt; are just pointers to commits, which is why branching and
reset are instant.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>git</category><category>tools</category><category>systems</category></item><item><title>eBPF: Running Your Code in the Kernel Without a Module</title><link>https://harshith.in/blog/ebpf-explained/</link><guid isPermaLink="true">https://harshith.in/blog/ebpf-explained/</guid><description>For decades, changing kernel behavior meant writing a kernel module and risking a panic. eBPF lets you load sandboxed programs into a running kernel safely — and it&apos;s quietly become the backbone of modern observability and networking.</description><pubDate>Thu, 04 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Traditionally, two options existed for kernel-level work: read what the kernel
chose to expose, or write a kernel module and accept that a bug means a panic.
eBPF adds a third: load your own program &lt;em&gt;into&lt;/em&gt; the running kernel, attach it to
an event, and trust that the kernel won’t let it crash anything. It’s the
technology under &lt;code&gt;bpftrace&lt;/code&gt;, Cilium, and most of the new observability stack.&lt;/p&gt;
&lt;h2 id=&quot;a-virtual-machine-inside-the-kernel&quot;&gt;A Virtual Machine Inside the Kernel&lt;/h2&gt;
&lt;p&gt;eBPF is a small register-based virtual machine built into Linux. You compile a
restricted C program to eBPF bytecode, load it, and the kernel runs it in a
sandbox when a chosen event fires:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   your .c  ──clang──▶  eBPF bytecode  ──load──▶  [ verifier ]
                                                       │ pass
                                                       ▼
                                     attached to an event, JIT-compiled,
                                     runs in kernel context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is not a module. It can’t call arbitrary kernel functions, can’t loop
unboundedly, and can’t touch memory it wasn’t handed.&lt;/p&gt;
&lt;h2 id=&quot;the-verifier-is-the-whole-trick&quot;&gt;The Verifier Is the Whole Trick&lt;/h2&gt;
&lt;p&gt;Before anything runs, the &lt;strong&gt;verifier&lt;/strong&gt; statically analyzes every possible path
through the program and rejects it unless it can prove safety:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   - all loops bounded (no infinite loops)
   - no out-of-bounds memory access
   - no uninitialized reads
   - bounded total instruction count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why eBPF is safe where modules aren’t: a program that &lt;em&gt;might&lt;/em&gt; misbehave
never loads. The cost is a constrained programming model — you write to please the
verifier, which can be maddening, but it’s what makes running untrusted code in
ring 0 acceptable.&lt;/p&gt;
&lt;h2 id=&quot;maps-talking-to-user-space&quot;&gt;Maps: Talking to User Space&lt;/h2&gt;
&lt;p&gt;An eBPF program is event-driven and short-lived, so it needs somewhere to keep
state and a way to report results. &lt;strong&gt;Maps&lt;/strong&gt; are kernel-resident key-value stores
shared between the eBPF program and user space:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   kernel: eBPF program ──writes──▶ [ map ] ◀──reads── user-space tool
           (e.g. count syscalls)     (hash, array, ring buffer, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Counters, histograms, per-PID stats — all live in maps. A user-space program
polls them to draw the flame graph or latency histogram you actually see.&lt;/p&gt;
&lt;h2 id=&quot;where-it-attaches&quot;&gt;Where It Attaches&lt;/h2&gt;
&lt;p&gt;The power is the breadth of attach points:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   kprobes / tracepoints → trace any kernel function or event
   uprobes               → trace user-space functions too
   XDP                   → process packets at the NIC driver, pre-stack
   tc / cgroup hooks     → shape and filter traffic, enforce policy
   LSM hooks             → make security decisions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same mechanism powers &lt;code&gt;bpftrace&lt;/code&gt; one-liners, Cilium’s networking, and runtime
security tools — one kernel feature, three industries.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Count system calls by process, live, no kernel module:
sudo bpftrace -e &apos;tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;eBPF runs sandboxed programs inside the kernel, attached to events — a safe
alternative to kernel modules.&lt;/li&gt;
&lt;li&gt;The verifier statically proves each program is bounded and memory-safe before it
loads; that guarantee is the core of eBPF.&lt;/li&gt;
&lt;li&gt;Maps are shared key-value stores that pass state and results between kernel and
user space.&lt;/li&gt;
&lt;li&gt;Broad attach points (kprobes, XDP, LSM) make one mechanism the basis of modern
tracing, networking, and security tooling.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>observability</category><category>linux</category></item><item><title>What a Container Actually Is: Namespaces and cgroups</title><link>https://harshith.in/blog/what-is-a-container/</link><guid isPermaLink="true">https://harshith.in/blog/what-is-a-container/</guid><description>A container isn&apos;t a lightweight VM — there&apos;s no guest kernel, no virtual hardware. It&apos;s just a normal Linux process wearing two kernel features as a disguise. Strip away Docker and the magic is surprisingly small.</description><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;People picture a container as a tiny virtual machine. It isn’t. There’s no guest
OS, no hypervisor, no emulated hardware. A container is an ordinary process on the
host kernel that has been lied to about what it can see and limited in what it can
use. Two kernel mechanisms do all the work: &lt;strong&gt;namespaces&lt;/strong&gt; and &lt;strong&gt;cgroups&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;a-container-is-just-a-process&quot;&gt;A Container Is Just a Process&lt;/h2&gt;
&lt;p&gt;Run a container and look on the host — you’ll find its processes in the normal
process list, scheduled by the normal scheduler, sharing the host kernel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   VM:                          Container:
   ┌─────────────┐              ┌─────────────┐
   │ guest kernel│              │   process   │
   ├─────────────┤              └──────┬──────┘
   │ hypervisor  │                     │ syscalls
   ├─────────────┤              ┌──────▼──────┐
   │ host kernel │              │ host kernel │  ← shared, no guest
   └─────────────┘              └─────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That shared kernel is why containers start in milliseconds and cost almost
nothing — and also why isolation is weaker than a VM’s.&lt;/p&gt;
&lt;h2 id=&quot;namespaces-lying-about-what-exists&quot;&gt;Namespaces: Lying About What Exists&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;namespace&lt;/strong&gt; gives a process its own private view of one kind of global
resource. The kernel has several:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   pid     → its own process tree; PID 1 inside is some PID on the host
   mnt     → its own filesystem mounts (the &quot;container&apos;s filesystem&quot;)
   net     → its own interfaces, routes, ports
   uts     → its own hostname
   ipc     → its own shared memory / semaphores
   user    → its own UID range; root inside ≠ root on host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put a process in fresh namespaces and it sees a pristine system: PID 1, its own
network stack, its own root filesystem. It &lt;em&gt;believes&lt;/em&gt; it’s alone on the machine.
Nothing is virtualized — the kernel just filters what the process can perceive.&lt;/p&gt;
&lt;h2 id=&quot;cgroups-capping-what-it-uses&quot;&gt;cgroups: Capping What It Uses&lt;/h2&gt;
&lt;p&gt;Namespaces control visibility; &lt;strong&gt;control groups (cgroups)&lt;/strong&gt; control consumption.
They cap and account for CPU, memory, I/O, and process count:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   cgroup: web-app
     memory.max   = 512M     → OOM-killed if exceeded
     cpu.max      = 50%      → throttled past its share
     pids.max     = 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is what stops one container from eating the whole host. &lt;code&gt;docker run -m 512m --cpus 0.5&lt;/code&gt; is just writing these cgroup limits for you.&lt;/p&gt;
&lt;h2 id=&quot;docker-is-the-convenience-layer&quot;&gt;Docker Is the Convenience Layer&lt;/h2&gt;
&lt;p&gt;Strip it back and a container is: a process started in new namespaces, confined by
cgroups, with its root filesystem swapped (&lt;code&gt;pivot_root&lt;/code&gt;) to an unpacked image.
You can build one by hand with &lt;code&gt;unshare&lt;/code&gt; and &lt;code&gt;cgcreate&lt;/code&gt;. Docker/containerd add
the image format, layered filesystem, networking setup, and registry — enormous
convenience, but not the isolation itself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# A crude container by hand: new namespaces + a different root
sudo unshare --pid --net --mount --uts --fork --mount-proc \
    chroot /path/to/rootfs /bin/sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;why-it-matters&quot;&gt;Why It Matters&lt;/h2&gt;
&lt;p&gt;Because the kernel is shared, a kernel exploit from inside a container can reach
the host — unlike a VM, where you’d also have to break the hypervisor. That’s the
core security tradeoff, and the reason sandboxes like gVisor and Kata exist to put
some of the VM boundary back.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A container is a host process, not a VM — same kernel, no guest OS, hence the
millisecond startup.&lt;/li&gt;
&lt;li&gt;Namespaces give it a private view of PIDs, mounts, network, etc.; it only
&lt;em&gt;thinks&lt;/em&gt; it’s alone.&lt;/li&gt;
&lt;li&gt;cgroups cap its CPU, memory, and I/O so it can’t starve the host.&lt;/li&gt;
&lt;li&gt;Shared kernel means weaker isolation than a VM — the tradeoff behind container
security tooling.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>containers</category><category>linux</category><category>systems</category></item><item><title>Interrupts: How Hardware Taps the Kernel on the Shoulder</title><link>https://harshith.in/blog/interrupts-top-and-bottom-halves/</link><guid isPermaLink="true">https://harshith.in/blog/interrupts-top-and-bottom-halves/</guid><description>A packet arrives, a key is pressed, a timer fires — and the CPU drops everything, mid-instruction-stream, to run kernel code. How interrupts work, why handlers are split in half, and how NAPI killed the interrupt storm.</description><pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Polling is simple: ask the device “anything yet?” in a loop. It’s also how you
turn a CPU into a space heater that checks an empty mailbox a billion times a
second. Interrupts invert control — the device signals, the CPU responds — and
that inversion is load-bearing for everything: your keyboard, your NVMe drive,
your 100 Gbit NIC, and the scheduler tick that makes multitasking preemptive
rather than cooperative. It also creates one of the kernel’s hardest constraint
environments, which is why interrupt handling is split into halves.&lt;/p&gt;
&lt;h2 id=&quot;the-mechanism&quot;&gt;The Mechanism&lt;/h2&gt;
&lt;p&gt;A device raises an interrupt (legacy IRQ line, or — for anything modern — an
MSI-X message write, which is how one NVMe drive gets a separate vector per
queue per CPU). The interrupt controller (APIC on x86) routes it to a CPU,
which finishes the current instruction, saves state, and vectors through the
&lt;strong&gt;IDT&lt;/strong&gt; to the registered handler. Whatever was running — your process,
another interrupt’s tail — is suspended mid-thought.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  NIC ──MSI-X──▶ APIC ──▶ CPU7: save state
                              └─▶ IDT[vector] ─▶ driver ISR   &quot;top half&quot;
                                     └─ raise NET_RX softirq  &quot;bottom half, later&quot;
                              ┌─▶ restore state
  interrupted task resumes ◀──┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/proc/interrupts&lt;/code&gt; shows the whole routing table live — one row per vector,
one column per CPU. A single column growing much faster than the rest is the
classic “all my packets interrupt CPU0” smell; &lt;code&gt;irqbalance&lt;/code&gt; or manual
&lt;code&gt;smp_affinity&lt;/code&gt; masks are the knob.&lt;/p&gt;
&lt;h2 id=&quot;why-handlers-are-split-in-half&quot;&gt;Why Handlers Are Split in Half&lt;/h2&gt;
&lt;p&gt;Interrupt context is a hostile place to run code. You can’t sleep (there’s no
task to put to sleep — you’re borrowing someone’s stack), so no mutexes, no
kmalloc(GFP_KERNEL), no page faults. And while you run, you delay everything —
including other interrupts and any latency-sensitive task on that CPU. The rule
is: do almost nothing.&lt;/p&gt;
&lt;p&gt;So Linux splits the work. The &lt;strong&gt;top half&lt;/strong&gt; (the ISR proper) does only what
must happen at interrupt priority: acknowledge the device, grab the perishable
data (read a status register, note which queue has work), schedule the rest,
return. Microseconds. The &lt;strong&gt;bottom half&lt;/strong&gt; does everything else — TCP
processing, block completion — in &lt;strong&gt;softirq&lt;/strong&gt; context, which runs with
interrupts enabled, either on the tail of interrupt exit or, under sustained
load, in the per-CPU &lt;code&gt;ksoftirqd&lt;/code&gt; thread. That name is worth recognizing:
&lt;code&gt;ksoftirqd/3&lt;/code&gt; eating CPU in top means “CPU 3 is drowning in deferred interrupt
work” — usually network — and it’s the scheduler’s way of making that load
visible and preemptible instead of invisible and latency-destroying. (Threaded
IRQs and workqueues extend the same idea further for drivers that need to
sleep.)&lt;/p&gt;
&lt;h2 id=&quot;napi-knowing-when-to-stop-listening&quot;&gt;NAPI: Knowing When to Stop Listening&lt;/h2&gt;
&lt;p&gt;Interrupts have a failure mode at the high end: &lt;strong&gt;livelock&lt;/strong&gt;. At a million
packets per second, a per-packet interrupt means the CPU does nothing but enter
and exit handlers — 100% busy, zero packets actually delivered to userspace.
The fix, NAPI, is beautifully self-tuning: on the first packet, the driver’s
top half &lt;em&gt;disables&lt;/em&gt; RX interrupts and schedules a poll; the softirq then
harvests packets in batches (budgeted, ~64 at a time) for as long as they keep
coming; when a poll drains the queue, re-enable interrupts.&lt;/p&gt;
&lt;p&gt;Light load: behaves like pure interrupts, minimum latency. Heavy load: behaves
like pure polling, minimum overhead — one interrupt per &lt;em&gt;burst&lt;/em&gt;, not per
packet. The system slides between modes automatically, driven by the traffic
itself. Hardware interrupt coalescing (&lt;code&gt;ethtool -c&lt;/code&gt;) stacks on top of this,
trading microseconds of latency for fewer vector deliveries; storage went the
same direction, and io_uring’s polled mode drops interrupts entirely for
latency-critical NVMe.&lt;/p&gt;
&lt;p&gt;The last mile is placement: RSS spreads flows across NIC queues, each with its
own MSI-X vector pinned to a CPU, so softirq work lands where the consuming
application runs (RPS/RFS do it in software). Getting a 100 Gbit NIC to line
rate is largely the craft of arranging &lt;em&gt;which CPU gets tapped on the shoulder,
and how often&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Interrupts preempt anything, anywhere; top halves therefore ack, stash, and
defer — never sleep, never dawdle.&lt;/li&gt;
&lt;li&gt;Bottom halves (softirqs) do the real protocol/completion work preemptibly;
ksoftirqd burning CPU means deferred-interrupt overload.&lt;/li&gt;
&lt;li&gt;NAPI flips interrupt→poll under load and back, defeating receive livelock
with one elegant rule.&lt;/li&gt;
&lt;li&gt;MSI-X gives modern devices per-queue vectors; /proc/interrupts +
IRQ affinity decide which cores pay the tax.&lt;/li&gt;
&lt;li&gt;Latency tuning is interrupt placement: coalescing, RSS/RPS, pinning — the
shoulder-tap schedule is the performance model.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>kernel</category><category>linux</category><category>systems</category><category>hardware</category></item><item><title>Journaling Filesystems: How ext4 Survives a Crash</title><link>https://harshith.in/blog/journaling-filesystems/</link><guid isPermaLink="true">https://harshith.in/blog/journaling-filesystems/</guid><description>Pull the power mid-write and a naive filesystem corrupts. A journal is the write-ahead log that lets ext4 come back consistent — by promising to do work before actually doing it.</description><pubDate>Mon, 25 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A single file write isn’t one operation to the disk — it’s several: update the
data blocks, the inode, the free-space bitmap, maybe a directory entry. Lose
power between them and the filesystem is left in a contradictory state. A journal
is how ext4 turns “several steps” back into something that’s all-or-nothing.&lt;/p&gt;
&lt;h2 id=&quot;why-a-crash-corrupts&quot;&gt;Why a Crash Corrupts&lt;/h2&gt;
&lt;p&gt;Appending to a file touches multiple structures that must stay consistent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   1. write the new data blocks
   2. mark those blocks used in the bitmap
   3. update the inode&apos;s size and block pointers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crash after step 2 but before 3, and the bitmap says “used” while no inode points
there — leaked space. Crash between 1 and 2, and the inode may point at blocks the
bitmap thinks are free, to be handed to another file later — &lt;strong&gt;corruption&lt;/strong&gt;. The
old fix was &lt;code&gt;fsck&lt;/code&gt; scanning the entire disk on boot, which takes minutes to hours
on a large volume.&lt;/p&gt;
&lt;h2 id=&quot;write-ahead-say-it-before-you-do-it&quot;&gt;Write-Ahead: Say It Before You Do It&lt;/h2&gt;
&lt;p&gt;A journal is a reserved on-disk area where the filesystem first records &lt;em&gt;what it
is about to do&lt;/em&gt;, then does it. The sequence:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   1. write the intended changes to the journal    (&quot;transaction&quot;)
   2. mark the transaction committed                (a commit record)
   3. apply the changes to their real locations     (&quot;checkpoint&quot;)
   4. mark the journal entry free
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The commit record in step 2 is the atomic switch. After a crash, recovery just
replays the journal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   transaction committed?  ──yes──▶ replay it (redo the changes)
                           ──no───▶ discard it (it never happened)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Either the operation fully happened or it fully didn’t. Recovery reads a small
journal, not the whole disk — seconds, not hours.&lt;/p&gt;
&lt;h2 id=&quot;the-three-modes-of-ext4&quot;&gt;The Three Modes of ext4&lt;/h2&gt;
&lt;p&gt;Journaling everything is safe but doubles writes — data goes to the journal &lt;em&gt;and&lt;/em&gt;
its final home. ext4 lets you choose what’s protected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   journal    metadata + data both journaled   safest, slowest
   ordered    metadata journaled; data written
              before the metadata commit         default, good balance
   writeback  metadata journaled; data unordered fastest, can show stale
                                                  data after a crash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Ordered&lt;/strong&gt; mode (the default) is the clever compromise: it doesn’t journal file
data, but it forces data blocks to disk &lt;em&gt;before&lt;/em&gt; committing the metadata that
points to them. So metadata never references garbage — you won’t see another
file’s old contents — without paying to journal data twice.&lt;/p&gt;
&lt;h2 id=&quot;the-limit&quot;&gt;The Limit&lt;/h2&gt;
&lt;p&gt;A journal guarantees the &lt;em&gt;filesystem&lt;/em&gt; is consistent, not that your application’s
data is. A committed transaction can still represent a half-written file from the
app’s point of view. That’s what &lt;code&gt;fsync()&lt;/code&gt; is for — and why databases call it
religiously before reporting a write durable.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Filesystem operations touch several structures; a crash between them corrupts
the volume.&lt;/li&gt;
&lt;li&gt;A journal is a write-ahead log: record the intent, commit atomically, then
apply — so recovery replays or discards whole transactions.&lt;/li&gt;
&lt;li&gt;ext4’s ordered mode protects metadata and orders data before the commit,
balancing safety and speed without journaling data twice.&lt;/li&gt;
&lt;li&gt;Journaling protects filesystem consistency, not application durability — that’s
still on &lt;code&gt;fsync()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>filesystems</category><category>systems</category></item><item><title>False Sharing: The Cache Line That Kills Your Parallelism</title><link>https://harshith.in/blog/false-sharing/</link><guid isPermaLink="true">https://harshith.in/blog/false-sharing/</guid><description>You split work across eight threads with zero shared state, and it runs slower than one thread. The culprit is false sharing — independent variables that happen to live on the same cache line, fighting over it.</description><pubDate>Sun, 17 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You parallelize a counter-per-thread loop. No locks, no shared variables — each
thread writes its own slot. It should scale linearly. Instead, eight threads are
slower than one. Welcome to false sharing: the performance bug where memory that
&lt;em&gt;isn’t&lt;/em&gt; shared behaves as if it is.&lt;/p&gt;
&lt;h2 id=&quot;cores-dont-trade-bytes-they-trade-cache-lines&quot;&gt;Cores Don’t Trade Bytes, They Trade Cache Lines&lt;/h2&gt;
&lt;p&gt;A CPU never moves a single byte between cores. The unit of coherence is the
&lt;strong&gt;cache line&lt;/strong&gt; — 64 bytes on most x86. When a core writes any byte in a line, the
cache-coherence protocol (MESI) must invalidate that line in every other core’s
cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   one 64-byte cache line
   ┌───────────────────────────────────────┐
   │ counter[0] counter[1] ... counter[7]  │   ← all on ONE line
   └───────────────────────────────────────┘
   thread 0 writes counter[0]  → invalidates the line everywhere
   thread 1 writes counter[1]  → invalidates it again
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The variables are independent, but they ride the same line. Every write by any
thread evicts the line from every other core, forcing a re-fetch across the cache
hierarchy. The line ping-pongs between cores at memory speed.&lt;/p&gt;
&lt;h2 id=&quot;seeing-it&quot;&gt;Seeing It&lt;/h2&gt;
&lt;p&gt;The classic reproduction — an array of per-thread counters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct { long count; } counters[8];   // 8 longs = 64 bytes = ONE line

// thread i hammers counters[i].count
for (long j = 0; j &amp;#x3C; 100000000; j++)
    counters[i].count++;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will scale &lt;em&gt;negatively&lt;/em&gt;. The fix is to push each counter onto its own line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct {
    long count;
    char pad[64 - sizeof(long)];   // pad out to a full cache line
} counters[8];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now each thread owns a private line — no invalidation traffic, and the loop
scales the way you expected.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   before:  [c0 c1 c2 c3 c4 c5 c6 c7]          one contested line
   after:   [c0 ......][c1 ......][c2 ...] ... one line each
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;doing-it-cleanly&quot;&gt;Doing It Cleanly&lt;/h2&gt;
&lt;p&gt;Hand-padding is error-prone. C++ gives you the line size and an alignment tool:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;#x3C;new&gt;
struct alignas(std::hardware_destructive_interference_size) Counter {
    long count;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In C, &lt;code&gt;alignas(64)&lt;/code&gt; plus padding does the same job.&lt;/p&gt;
&lt;h2 id=&quot;how-to-catch-it&quot;&gt;How to Catch It&lt;/h2&gt;
&lt;p&gt;You can’t see false sharing in the code — only in the hardware counters. &lt;code&gt;perf&lt;/code&gt;
exposes the coherence traffic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;perf c2c record ./your_program     # cache-to-cache transfer analysis
perf c2c report                    # points at the contended cache lines
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A workload that should scale but flatlines, with high cache-coherence or
HITM events, is the signature.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Cores exchange whole 64-byte cache lines, not individual variables.&lt;/li&gt;
&lt;li&gt;False sharing happens when independent data lands on one line, so each write
invalidates the line in every other core — turning parallel code serial.&lt;/li&gt;
&lt;li&gt;Pad or align hot per-thread data to its own cache line to fix it.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;perf c2c&lt;/code&gt; to find it; it’s invisible in source but obvious in coherence
counters.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>performance</category><category>concurrency</category></item><item><title>How malloc Works: Arenas, Bins, and Fragmentation</title><link>https://harshith.in/blog/how-malloc-works/</link><guid isPermaLink="true">https://harshith.in/blog/how-malloc-works/</guid><description>malloc looks like one function, but underneath it&apos;s a memory allocator juggling free lists, size classes, and the kernel. Knowing how it works explains fragmentation, allocator contention, and why your RSS never goes down.</description><pubDate>Sat, 09 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You call &lt;code&gt;malloc(32)&lt;/code&gt; and get a pointer. That simplicity hides an allocator
making a series of decisions: where the memory comes from, how free space is
tracked, how it avoids locking on every call, and whether freeing it gives
anything back to the operating system. Usually it doesn’t, and that surprises
people.&lt;/p&gt;
&lt;h2 id=&quot;the-allocator-sits-between-you-and-the-kernel&quot;&gt;The Allocator Sits Between You and the Kernel&lt;/h2&gt;
&lt;p&gt;The kernel deals in pages (4 KB) via &lt;code&gt;brk&lt;/code&gt; (grow the heap) and &lt;code&gt;mmap&lt;/code&gt; (map a
fresh region). Asking it for every small object would be absurdly slow. So
&lt;code&gt;malloc&lt;/code&gt; grabs memory in big chunks and hands out small pieces itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   your code  ──malloc(32)──▶  allocator  ──brk/mmap──▶  kernel
                               (manages chunks, free lists)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The allocator’s whole job is to satisfy small requests from big slabs and reuse
freed space without a syscall.&lt;/p&gt;
&lt;h2 id=&quot;bins-free-lists-by-size&quot;&gt;Bins: Free Lists by Size&lt;/h2&gt;
&lt;p&gt;When you &lt;code&gt;free()&lt;/code&gt; a chunk, it isn’t returned to the OS — it goes onto a &lt;strong&gt;free
list&lt;/strong&gt; so the next &lt;code&gt;malloc&lt;/code&gt; of a similar size can reuse it instantly. glibc
groups these by size class into &lt;strong&gt;bins&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   fastbins:  [16][16]        small, recently freed, LIFO, no coalescing
   smallbins: [32][48][64]    exact size classes
   largebins: sorted ranges   for big allocations
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;malloc(32)&lt;/code&gt; checks the matching bin first. Hit → O(1), no kernel involvement.
This caching is why allocation is fast on average, and why a freed pointer used
again (use-after-free) often still “works” — the memory’s still there, just
re-handed-out.&lt;/p&gt;
&lt;h2 id=&quot;fragmentation-the-space-you-cant-use&quot;&gt;Fragmentation: The Space You Can’t Use&lt;/h2&gt;
&lt;p&gt;Free memory that’s the wrong shape is useless. &lt;strong&gt;External fragmentation&lt;/strong&gt; is
scattered free chunks too small to satisfy a larger request even though the total
is plenty:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   [used][free 16][used][free 16][used]   ← 32 bytes free total,
                                            but no contiguous 32-byte slot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Allocators fight this by &lt;strong&gt;coalescing&lt;/strong&gt; adjacent free chunks back together and by
carving from size classes that minimize waste, but it can’t be eliminated.&lt;/p&gt;
&lt;h2 id=&quot;why-rss-doesnt-drop-after-free&quot;&gt;Why RSS Doesn’t Drop After free()&lt;/h2&gt;
&lt;p&gt;Memory freed back to the allocator generally stays in the process. Returning it
to the OS requires the &lt;em&gt;top&lt;/em&gt; of the heap to be free (to shrink &lt;code&gt;brk&lt;/code&gt;), or the
chunk to have come from &lt;code&gt;mmap&lt;/code&gt;. A free list full of holes in the middle can’t be
returned. So your resident set stays high even after freeing — it’s reserved for
reuse, not leaked.&lt;/p&gt;
&lt;h2 id=&quot;arenas-why-threads-dont-serialize&quot;&gt;Arenas: Why Threads Don’t Serialize&lt;/h2&gt;
&lt;p&gt;If every thread shared one heap, every &lt;code&gt;malloc&lt;/code&gt; would contend on one lock. glibc
gives each thread its own &lt;strong&gt;arena&lt;/strong&gt; (a separate heap with its own bins and lock),
so concurrent allocations mostly don’t collide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   thread 1 ─▶ arena A (lock A)
   thread 2 ─▶ arena B (lock B)     parallel, no contention
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why allocator-heavy multithreaded code can still scale — and why
replacing the allocator (jemalloc, tcmalloc, mimalloc) is a common performance
win when the default’s arena strategy doesn’t fit your workload.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;malloc&lt;/code&gt; buys memory from the kernel in big chunks and hands out small pieces
from its own free lists.&lt;/li&gt;
&lt;li&gt;Freed chunks go into size-classed bins for fast reuse — they’re not returned to
the OS, which is why RSS stays high after &lt;code&gt;free()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;External fragmentation wastes space the wrong shape; coalescing helps but can’t
fix it entirely.&lt;/li&gt;
&lt;li&gt;Per-thread arenas avoid lock contention; swapping allocators is a real tuning
lever.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>memory</category><category>systems</category><category>performance</category></item><item><title>The TLS 1.3 Handshake: What Those Milliseconds Buy</title><link>https://harshith.in/blog/tls-handshake-mechanics/</link><guid isPermaLink="true">https://harshith.in/blog/tls-handshake-mechanics/</guid><description>One round trip now negotiates keys, proves identity, and encrypts everything after the first message. What actually happens in a TLS 1.3 handshake, why it&apos;s structurally safer than 1.2, and where the remaining sharp edges live.</description><pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every HTTPS connection begins with a negotiation that has to solve an
absurd-sounding problem: two parties who’ve never met, over a wire an
attacker fully controls, must agree on a secret key &lt;em&gt;and&lt;/em&gt; verify who
they’re talking to — in one round trip, because milliseconds are money.
TLS 1.3 (RFC 8446, 2018) is the cleanest answer cryptography has shipped,
mostly because of what it &lt;em&gt;removed&lt;/em&gt;. Knowing its moving parts is the
difference between debugging certificate errors by ritual and by reading.&lt;/p&gt;
&lt;h2 id=&quot;one-round-trip-three-jobs&quot;&gt;One Round Trip, Three Jobs&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  ClientHello ──▶   supported versions/ciphers, SNI,
                    + ECDHE key share (a guess, sent upfront)
              ◀── ServerHello (chosen cipher, server&apos;s key share)
                  ── everything below already encrypted ──
              ◀── EncryptedExtensions, Certificate,
                  CertificateVerify, Finished
  Finished    ──▶   application data flows
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Job 1 — key agreement&lt;/strong&gt; is ephemeral Diffie-Hellman (X25519 in practice):
each side contributes a fresh keypair, both derive the same shared secret,
an eavesdropper recording everything learns nothing. The 1.3 trick for
speed: the client sends its key share &lt;em&gt;speculatively&lt;/em&gt; in the first flight,
gambling the server will accept X25519 (it will), collapsing 1.2’s two
round trips into one. Note what’s gone: RSA key transport — 1.2’s mode
where the client encrypted the secret to the server’s long-term key —
which meant one leaked server key retroactively decrypted every recorded
session. Ephemeral-only means &lt;strong&gt;forward secrecy is mandatory&lt;/strong&gt;, and an
entire class of both attacks and passive-decryption middleboxes died with
it (enterprises now run explicit MITM proxies instead — at least the
model is honest).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Job 2 — authentication&lt;/strong&gt; rides the certificate chain: the server sends
its cert plus intermediates; the client builds a path to a trusted root,
checks names (SNI in, SAN out — CN has been dead for years), validity
windows, and signatures; then — the step people forget exists —
&lt;code&gt;CertificateVerify&lt;/code&gt;: the server &lt;em&gt;signs the handshake transcript&lt;/em&gt; with the
certificate’s private key, proving it actually holds it. Without that, a
certificate is just a public document anyone could replay.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Job 3 — integrity of the negotiation itself&lt;/strong&gt;: both &lt;code&gt;Finished&lt;/code&gt; messages
MAC the entire transcript, so a middlebox stripping strong ciphers from
the ClientHello (the downgrade attacks that plagued 1.2 — FREAK, Logjam)
breaks the handshake. 1.3 also encrypts everything after the ServerHello,
so certificates travel encrypted — network observers get SNI (until ECH
lands widely) and little else.&lt;/p&gt;
&lt;h2 id=&quot;resumption-and-the-0-rtt-footgun&quot;&gt;Resumption, and the 0-RTT Footgun&lt;/h2&gt;
&lt;p&gt;Reconnecting clients get session tickets (PSKs) and skip certificate
work — cheap and uncontroversial. The controversial gift is &lt;strong&gt;0-RTT&lt;/strong&gt;:
sending application data &lt;em&gt;in the first flight&lt;/em&gt;, encrypted under the
resumed key, before any server response. Free latency — and structurally
&lt;strong&gt;replayable&lt;/strong&gt;: an attacker can capture the flight and deliver it again;
no handshake-completion proof protects it. Safe only for idempotent
requests, which is why serious deployments restrict it (CDNs allow GETs
without bodies and reject the rest with 425 Too Early) and why “we enabled
0-RTT globally” belongs in a risk review, not a performance PR.&lt;/p&gt;
&lt;h2 id=&quot;where-it-actually-breaks&quot;&gt;Where It Actually Breaks&lt;/h2&gt;
&lt;p&gt;Production TLS failures cluster in unglamorous places, none of them the
cryptography: &lt;strong&gt;incomplete chains&lt;/strong&gt; (server sends leaf without
intermediate — works in browsers that cache/fetch intermediates, fails in
curl/openssl/your language runtime; the source of “works in Chrome,
fails in prod” forever; &lt;code&gt;openssl s_client -connect host:443 -servername host&lt;/code&gt; shows exactly what’s sent), &lt;strong&gt;expiry&lt;/strong&gt; (monitor &lt;code&gt;-enddate&lt;/code&gt;
programmatically; outages by calendar are self-inflicted), &lt;strong&gt;name
mismatches&lt;/strong&gt; (SNI-less clients hitting multi-cert servers get the default
cert), and &lt;strong&gt;trust-store drift&lt;/strong&gt; (containers FROM scratch, old CA bundles
— ask the box, not the spec: &lt;code&gt;curl -v&lt;/code&gt; names the failing step). The
debugging kit is three commands deep: &lt;code&gt;openssl s_client&lt;/code&gt; (handshake
transcript + chain), &lt;code&gt;openssl x509 -text&lt;/code&gt; (read the cert), and your
load balancer’s config, because TLS terminates there and the backend hop
is a separate security decision people forget to make.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;1.3 = one round trip: speculative ECDHE share, chain + transcript
signature for identity, transcript MAC against downgrade.&lt;/li&gt;
&lt;li&gt;Forward secrecy is mandatory now — key compromise no longer decrypts
history; passive-decrypt middleboxes are extinct by design.&lt;/li&gt;
&lt;li&gt;CertificateVerify is why possession is proven, not just presented;
everything after ServerHello travels encrypted.&lt;/li&gt;
&lt;li&gt;0-RTT data is replayable by construction — idempotent requests only,
enforced, or leave it off.&lt;/li&gt;
&lt;li&gt;Real-world failures: chains, expiry, SNI/SAN, trust stores — all
legible via openssl s_client before anyone blames the crypto.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>security</category><category>networking</category><category>systems</category></item><item><title>Hash Tables: The Real Cost of a Collision</title><link>https://harshith.in/blog/hash-table-collisions/</link><guid isPermaLink="true">https://harshith.in/blog/hash-table-collisions/</guid><description>Hash tables promise O(1) lookups, and then a benchmark shows them losing to a plain array. The gap between the textbook and reality is collisions, cache misses, and the resize you forgot about.</description><pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;“Hash tables are O(1)” is true in the same way “the average human has one
testicle” is true. The constant factor and the worst case are where programs
actually live, and both are governed by how you handle collisions — two keys
landing in the same slot.&lt;/p&gt;
&lt;h2 id=&quot;why-collisions-are-inevitable&quot;&gt;Why Collisions Are Inevitable&lt;/h2&gt;
&lt;p&gt;A hash function maps a huge key space into a small array of buckets. By the
pigeonhole principle, distinct keys &lt;em&gt;will&lt;/em&gt; share buckets. With even a decent hash,
the birthday paradox means collisions start appearing far earlier than intuition
suggests — you don’t need the table to be full.&lt;/p&gt;
&lt;p&gt;The fraction of slots used is the &lt;strong&gt;load factor&lt;/strong&gt; (&lt;code&gt;α = entries / buckets&lt;/code&gt;), and
it’s the single number that governs collision rate.&lt;/p&gt;
&lt;h2 id=&quot;two-ways-to-resolve-them&quot;&gt;Two Ways to Resolve Them&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Chaining&lt;/strong&gt; — each bucket holds a linked list of entries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   [0] → (key1) → (key4)
   [1] →
   [2] → (key2) → (key7) → (key9)   ← collisions chain here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Simple and degrades gracefully, but each chain hop is a pointer chase to a random
address — a likely &lt;strong&gt;cache miss&lt;/strong&gt;. A “found in 3 comparisons” lookup can mean
three cache misses, which dwarfs the comparisons.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Open addressing&lt;/strong&gt; — entries live in the array itself; on collision you probe to
the next slot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   [0][k1][k4][ ][k2][ ][ ]   ← k4 wanted slot 1, probed to slot 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything stays in contiguous memory, so probes are cache-friendly. The price:
performance falls off a cliff as the table fills, because probe sequences get
long. Open addressing needs a lower load factor (often kept under ~0.7).&lt;/p&gt;
&lt;h2 id=&quot;the-cost-you-forgot-resizing&quot;&gt;The Cost You Forgot: Resizing&lt;/h2&gt;
&lt;p&gt;When the load factor crosses a threshold, the table doubles and &lt;strong&gt;every entry is
rehashed&lt;/strong&gt; into the new array — an O(n) operation. It’s rare, so amortized cost
stays O(1), but any single insert can stall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   insert, insert, insert, ... , INSERT(resize: rehash all n) , insert, ...
                                  └─ one slow op hidden in the average
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For latency-sensitive code, that occasional spike matters. Preallocate with the
expected size to dodge it.&lt;/p&gt;
&lt;h2 id=&quot;what-actually-makes-them-fast&quot;&gt;What Actually Makes Them Fast&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keep the load factor sane.&lt;/strong&gt; High &lt;code&gt;α&lt;/code&gt; means long chains or long probes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mind cache behavior.&lt;/strong&gt; Open addressing usually beats chaining in practice
purely because it stays in contiguous memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use a good hash.&lt;/strong&gt; A weak hash clusters keys into the same buckets and turns
your O(1) table into an O(n) linked list — the basis of algorithmic-complexity
denial-of-service attacks.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Collisions are unavoidable; the load factor controls how often they happen.&lt;/li&gt;
&lt;li&gt;Chaining degrades gracefully but pays cache misses; open addressing is
cache-friendly but needs a lower load factor.&lt;/li&gt;
&lt;li&gt;Resizing rehashes everything — amortized O(1) hides a real per-op spike, so
preallocate when latency matters.&lt;/li&gt;
&lt;li&gt;A bad hash collapses O(1) into O(n); pick a strong one.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>algorithms</category><category>performance</category></item><item><title>HTTP/2 Multiplexing: One Connection, Many Streams</title><link>https://harshith.in/blog/http2-multiplexing/</link><guid isPermaLink="true">https://harshith.in/blog/http2-multiplexing/</guid><description>HTTP/1.1 made you choose between head-of-line blocking and opening a pile of connections. HTTP/2 fixes it by carrying many independent streams over a single connection — and then TCP reintroduces the very problem it solved.</description><pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Under HTTP/1.1, a connection handles one request at a time. A slow response
blocks everything behind it — head-of-line blocking — so browsers worked around
it by opening six connections per origin and praying. HTTP/2 replaces the hack
with real multiplexing. Then it runs into a wall TCP built.&lt;/p&gt;
&lt;h2 id=&quot;http11s-dilemma&quot;&gt;HTTP/1.1’s Dilemma&lt;/h2&gt;
&lt;p&gt;One request, one response, in order, per connection:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   conn:  [── req A ──][ wait ][── req B ──][── req C ──]
                        └─ B and C stall behind a slow A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The six-connection workaround multiplies overhead — six TLS handshakes, six slow
starts, six congestion windows — and the browser limit means request seven still
waits.&lt;/p&gt;
&lt;h2 id=&quot;streams-and-frames&quot;&gt;Streams and Frames&lt;/h2&gt;
&lt;p&gt;HTTP/2 splits the single connection into many independent &lt;strong&gt;streams&lt;/strong&gt;, each a
request/response pair. Messages are chopped into &lt;strong&gt;frames&lt;/strong&gt; tagged with a stream
ID, interleaved on the wire, and reassembled on the other end:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   one TCP connection
   ┌──────────────────────────────────────────────┐
   │ [S1 hdr][S3 data][S1 data][S5 hdr][S3 data]…  │
   └──────────────────────────────────────────────┘
        frames from different streams, interleaved
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now a slow response on stream 1 doesn’t block stream 3 — their frames just
interleave. One connection, one handshake, one warm congestion window, and
effectively unlimited concurrent requests. The application-layer head-of-line
problem is gone.&lt;/p&gt;
&lt;h2 id=&quot;the-header-win&quot;&gt;The Header Win&lt;/h2&gt;
&lt;p&gt;HTTP headers are repetitive — the same &lt;code&gt;User-Agent&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;, and cookies on
every request. HTTP/2 adds &lt;strong&gt;HPACK&lt;/strong&gt;, which keeps a shared dynamic table of
previously sent headers so repeats become small references instead of full text.
On a page firing 100 requests, that’s a real saving.&lt;/p&gt;
&lt;h2 id=&quot;where-tcp-bites-back&quot;&gt;Where TCP Bites Back&lt;/h2&gt;
&lt;p&gt;Here’s the twist: HTTP/2 multiplexes streams, but they all ride one TCP
connection, and TCP guarantees in-order byte delivery. If a single packet is
lost, TCP holds back &lt;em&gt;every&lt;/em&gt; stream’s bytes until the retransmission arrives —
even streams that were complete:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   packet for S1 lost  →  TCP buffers everything after it
                          S3, S5 data sits ready but undelivered
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The head-of-line blocking moved down a layer, from HTTP to TCP. On a clean
network you never notice; on a lossy mobile link it hurts.&lt;/p&gt;
&lt;p&gt;This is exactly the problem &lt;strong&gt;HTTP/3&lt;/strong&gt; solves by moving to QUIC over UDP, where
each stream has independent delivery and one lost packet only stalls its own
stream.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;HTTP/2 carries many independent streams over one connection by interleaving
frames, killing application-layer head-of-line blocking.&lt;/li&gt;
&lt;li&gt;One connection means one handshake, one congestion window, and HPACK header
compression — a big win over HTTP/1.1’s connection-juggling.&lt;/li&gt;
&lt;li&gt;TCP’s in-order delivery reintroduces head-of-line blocking on packet loss,
which is the motivation for HTTP/3 over QUIC.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>networking</category><category>web</category></item><item><title>Why Distributed Consensus Needs a Quorum</title><link>https://harshith.in/blog/quorum-and-consensus/</link><guid isPermaLink="true">https://harshith.in/blog/quorum-and-consensus/</guid><description>Replicate data across five machines and you&apos;d think any one of them could answer. But to stay consistent through failures, a majority has to agree on every decision. Here&apos;s the arithmetic behind quorums.</description><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You replicate your data five ways for safety. A write lands on the leader — so
which replicas must confirm it before you call it durable? Answer “one” and a
crash loses data. Answer “all” and a single slow node freezes the system. The
right answer is &lt;strong&gt;a majority&lt;/strong&gt;, and the reason is a small, beautiful piece of
arithmetic.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-two-truths&quot;&gt;The Problem: Two Truths&lt;/h2&gt;
&lt;p&gt;The danger in a distributed system is &lt;strong&gt;split-brain&lt;/strong&gt; — two halves of a cluster
that lose contact and each decide they’re in charge. Both accept writes, and now
you have two divergent versions of reality with no way to merge them.&lt;/p&gt;
&lt;p&gt;To prevent this, every decision needs the agreement of a &lt;strong&gt;quorum&lt;/strong&gt;: a subset of
nodes large enough that two quorums can never both form at the same time.&lt;/p&gt;
&lt;h2 id=&quot;the-majority-trick&quot;&gt;The Majority Trick&lt;/h2&gt;
&lt;p&gt;Set the quorum to a strict majority — more than half. In a 5-node cluster, that’s
3. The key property:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   any two majorities of 5 must share at least one node

   {A B C}   and   {C D E}   overlap at C
   {A B C}   and   {A B D}   overlap at A, B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because any two majorities &lt;strong&gt;overlap in at least one node&lt;/strong&gt;, two conflicting
decisions can’t both gather a quorum — the shared node would have to vote for
both, and it won’t. Split-brain becomes mathematically impossible.&lt;/p&gt;
&lt;h2 id=&quot;read-and-write-quorums&quot;&gt;Read and Write Quorums&lt;/h2&gt;
&lt;p&gt;The same overlap logic lets you tune reads vs writes. With &lt;code&gt;N&lt;/code&gt; replicas, a write
quorum &lt;code&gt;W&lt;/code&gt; and read quorum &lt;code&gt;R&lt;/code&gt;, you get strong consistency when:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   W + R &gt; N      →  every read quorum overlaps every write quorum
   W &gt; N / 2      →  writes can&apos;t conflict with each other
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For &lt;code&gt;N = 5&lt;/code&gt;: &lt;code&gt;W = 3, R = 3&lt;/code&gt; is the balanced choice. Want faster writes? &lt;code&gt;W = 2&lt;/code&gt;
breaks the guarantee. Want fast reads at the cost of slow writes? &lt;code&gt;W = 5, R = 1&lt;/code&gt;
— but now any down node blocks all writes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   N=5, W=3, R=3
   write → {A B C}
   read  → {C D E}     reads see C, which has the latest write ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;why-clusters-are-odd-sized&quot;&gt;Why Clusters Are Odd-Sized&lt;/h2&gt;
&lt;p&gt;A majority of 4 is 3 — the same as a majority of 5. So a 4-node cluster tolerates
&lt;em&gt;one&lt;/em&gt; failure, exactly like 3 nodes, while costing more and being likelier to
have a failure. Odd sizes give you the best failure tolerance per node:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   nodes   majority   failures tolerated
     3        2              1
     4        3              1     ← extra node buys nothing
     5        3              2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why Raft and Paxos clusters are almost always 3 or 5.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A quorum is a subset big enough that any two quorums overlap — which is what
makes conflicting decisions impossible.&lt;/li&gt;
&lt;li&gt;A strict majority is the smallest such quorum; it prevents split-brain because
two majorities always share a node.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;W + R &gt; N&lt;/code&gt; and &lt;code&gt;W &gt; N/2&lt;/code&gt; give strong consistency; tune &lt;code&gt;W&lt;/code&gt;/&lt;code&gt;R&lt;/code&gt; to trade read
vs write speed.&lt;/li&gt;
&lt;li&gt;Use odd cluster sizes — an even size costs more without tolerating more
failures.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>distributed-systems</category><category>systems</category></item><item><title>Stack Canaries: How the Compiler Catches a Buffer Overflow</title><link>https://harshith.in/blog/stack-canaries/</link><guid isPermaLink="true">https://harshith.in/blog/stack-canaries/</guid><description>A classic stack smash overwrites the return address and hijacks execution. Stack canaries are the cheap, clever defense your compiler inserts automatically — and understanding them explains a whole class of exploits.</description><pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The oldest trick in memory-corruption exploitation is overflowing a local buffer
to overwrite the saved return address, redirecting the CPU into attacker code.
The stack canary is the defense that’s been quietly compiled into your binaries
for two decades. It doesn’t prevent the overflow — it makes the program notice
before it’s too late.&lt;/p&gt;
&lt;h2 id=&quot;the-stack-layout-being-attacked&quot;&gt;The Stack Layout Being Attacked&lt;/h2&gt;
&lt;p&gt;On a function call, the stack holds the local buffer, saved registers, and the
return address the CPU will jump to when the function returns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   higher addresses
   ┌────────────────────┐
   │  return address    │  ← overwrite this to hijack control
   │  saved frame ptr   │
   │  ...               │
   │  char buf[64]      │  ← overflow starts here, grows upward
   └────────────────────┘
   lower addresses
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gets(buf)&lt;/code&gt; or an unchecked &lt;code&gt;strcpy&lt;/code&gt; writes past &lt;code&gt;buf&lt;/code&gt;, marches up through saved
registers, and clobbers the return address. On &lt;code&gt;ret&lt;/code&gt;, the CPU jumps wherever the
attacker pointed.&lt;/p&gt;
&lt;h2 id=&quot;the-canary&quot;&gt;The Canary&lt;/h2&gt;
&lt;p&gt;The compiler inserts a secret random value — the &lt;strong&gt;canary&lt;/strong&gt; — between the local
buffers and the return address. It’s written on entry and checked right before
return:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   ┌────────────────────┐
   │  return address    │
   │  canary  (random)  │  ← any overflow reaching the return addr
   │  char buf[64]      │     must first overwrite this
   └────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A contiguous overflow that wants to reach the return address &lt;em&gt;must&lt;/em&gt; trample the
canary on the way. Before returning, the function compares the stored canary to
the known-good value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;   __stack_chk_fail() if  stack_canary != fs:0x28
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mismatch → the program aborts immediately instead of returning into corrupted
control flow. You’ve seen the result: &lt;code&gt;*** stack smashing detected ***&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;why-its-cheap-and-where-it-fails&quot;&gt;Why It’s Cheap and Where It Fails&lt;/h2&gt;
&lt;p&gt;The canary value lives in a per-thread location (the TLS, &lt;code&gt;fs:0x28&lt;/code&gt; on x86-64)
and is randomized per process, so an attacker can’t just hardcode it. The runtime
cost is a couple of instructions per protected function — the compiler only adds
it to functions with stack buffers (&lt;code&gt;-fstack-protector-strong&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;It is not a cure. Attackers bypass canaries by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Leaking the canary&lt;/strong&gt; via a separate info-leak bug, then writing it back
intact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Overwriting non-contiguously&lt;/strong&gt; — e.g. a bad array index that skips the canary
and hits the return address directly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Targeting other pointers&lt;/strong&gt; (function pointers, GOT entries) that don’t sit
behind the canary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s why canaries ship alongside ASLR, NX/DEP, and CFI — defense in depth, not
a single wall.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A stack canary is a random value placed between local buffers and the return
address, checked before every protected return.&lt;/li&gt;
&lt;li&gt;A linear overflow can’t reach the return address without destroying the canary
first, so the program aborts instead of being hijacked.&lt;/li&gt;
&lt;li&gt;It’s defeated by info leaks and non-linear writes — which is why it’s one layer
among ASLR, NX, and CFI, not the whole defense.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>security</category><category>systems</category></item><item><title>Raft: Consensus You Can Actually Trace</title><link>https://harshith.in/blog/raft-in-practice/</link><guid isPermaLink="true">https://harshith.in/blog/raft-in-practice/</guid><description>Raft won because you can hold the whole protocol in your head: terms, a leader, and one log-matching rule. The mechanics of election and replication — and the operational corners (fsync, membership changes, pre-vote) where deployments actually bleed.</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’ve written before about why quorums exist; this is the protocol layer
above them. Raft (Ongaro &amp;#x26; Ousterhout, 2014) was explicitly designed
around &lt;em&gt;understandability&lt;/em&gt; — the paper’s actual thesis — and it worked:
etcd, Consul, CockroachDB, TiKV, and most Kafka deployments (KRaft) now
bet their correctness on it. The payoff of learning it properly is that
distributed-systems logs stop being mystical: &lt;code&gt;term 847&lt;/code&gt;,
&lt;code&gt;leader election lost&lt;/code&gt;, &lt;code&gt;proposal dropped&lt;/code&gt; are all lines you can trace
back to specific rules.&lt;/p&gt;
&lt;h2 id=&quot;the-frame-terms-and-one-leader&quot;&gt;The Frame: Terms and One Leader&lt;/h2&gt;
&lt;p&gt;Time divides into numbered &lt;strong&gt;terms&lt;/strong&gt;; each term has at most one leader,
elected by majority vote. All writes flow through the leader — Raft
buys simplicity by refusing multi-master entirely. Every message
carries the sender’s term; seeing a higher term instantly demotes you
to follower. That one rule quietly handles most of the “stale leader”
zoo: a partitioned ex-leader rejoins, hears term 848 &gt; its 847, and
steps down before doing damage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Election&lt;/strong&gt;: followers expect heartbeats; silence past a randomized
timeout (150–300 ms class) triggers candidacy — increment term, vote
for self, request votes. Majority → leader. The randomization is the
whole liveness trick (simultaneous candidacies split votes; random
timeouts make repeats improbable), and the &lt;em&gt;vote-granting rule&lt;/em&gt; is the
first safety pillar: a node refuses its vote to any candidate whose
log is less up-to-date than its own. Elected leaders therefore already
hold every committed entry — recovery requires no log transfer to the
new leader, ever.&lt;/p&gt;
&lt;h2 id=&quot;replication-the-log-matching-property&quot;&gt;Replication: The Log Matching Property&lt;/h2&gt;
&lt;p&gt;The leader appends client commands to its log and ships them via
AppendEntries, each carrying the &lt;em&gt;previous&lt;/em&gt; entry’s (index, term) as a
consistency probe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  leader:   ... [5:t2] [6:t3] [7:t3]        AppendEntries(prev=6:t3, [7:t3])
  follower: ... [5:t2] [6:t3]           ✓ matches → append 7
  follower: ... [5:t2] [6:t2]           ✗ reject → leader backs up, overwrites
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Induction does the rest: if a follower matches at (index, term), its
entire prefix matches — divergent suffixes (from crashed leaders’
unreplicated tail entries) get found and overwritten mechanically. An
entry is &lt;strong&gt;committed&lt;/strong&gt; once replicated on a majority &lt;em&gt;and&lt;/em&gt; — the subtle
clause that fixed a real bug class (the paper’s Figure 8) — the leader
only counts replication of entries &lt;em&gt;from its own term&lt;/em&gt;; older-term
entries commit implicitly behind them. Committed entries feed the state
machine, and the guarantee stack — elected leaders have all committed
entries + log matching + commit rule — yields the property everything
else stands on: &lt;strong&gt;committed means durable across any minority of
failures, in order, exactly once in the log&lt;/strong&gt; (delivery to &lt;em&gt;your&lt;/em&gt;
application is another story — see below).&lt;/p&gt;
&lt;h2 id=&quot;where-deployments-actually-bleed&quot;&gt;Where Deployments Actually Bleed&lt;/h2&gt;
&lt;p&gt;The protocol is the easy half; a decade of etcd/Consul incident
reports names the hard one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;fsync is part of the protocol.&lt;/strong&gt; Votes and log entries must hit
stable storage before being answered — a node that votes, crashes,
and forgets its vote can elect two leaders in one term. Slow disks
therefore &lt;em&gt;are&lt;/em&gt; slow consensus: etcd’s
&lt;code&gt;wal_fsync_duration_seconds&lt;/code&gt; alerting exists because commit latency
is disk latency; running consensus on shared/burstable storage is a
well-documented way to spend a weekend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elections during GC/CPU stalls.&lt;/strong&gt; A leader that pauses past the
timeout gets deposed; the cluster churns through terms
(“leadership transferred” storms). Tuning timeouts against your
worst-case pause — and using &lt;strong&gt;pre-vote&lt;/strong&gt; (a candidate first checks
it &lt;em&gt;could&lt;/em&gt; win, without incrementing terms) plus leader
stickiness — is what keeps a flaky node from term-bombing a healthy
cluster. If your logs show terms climbing by hundreds, it’s this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Membership changes are a protocol, not a config edit.&lt;/strong&gt; Jumping
straight from {A,B,C} to {A,B,C,D,E} lets two disjoint majorities
exist mid-change. Raft’s answers (joint consensus, or
one-node-at-a-time — what etcd enforces, now with learner/non-voting
states to catch new nodes up safely) are why “just add two replicas
at once” is refused by good implementations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads need care too.&lt;/strong&gt; Serving reads from any leader replica risks
stale data from a deposed leader; linearizable reads use ReadIndex
(confirm leadership with a quorum round) or leases — the reason
etcd distinguishes &lt;code&gt;--consistency=l&lt;/code&gt; from serializable reads, and a
knob every Raft-backed store exposes somewhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Scope honesty completes the picture: Raft totally orders a &lt;em&gt;log&lt;/em&gt; —
it says nothing about exactly-once effects in your application
(dedup/idempotency still yours), it costs a quorum round-trip per
write (don’t put it on hot paths that don’t need it), and a majority
loss halts writes by design — availability was traded for the
guarantee, which is the correct trade for the configuration/metadata/
coordination tier it dominates.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Terms + majority elections + higher-term-wins handle stale leaders;
randomized timeouts handle liveness.&lt;/li&gt;
&lt;li&gt;Log matching lets one (index, term) probe verify entire prefixes;
commit = majority replication of a current-term entry.&lt;/li&gt;
&lt;li&gt;Consensus latency = fsync latency; monitor WAL sync times before
blaming the network.&lt;/li&gt;
&lt;li&gt;Pre-vote and single-node membership changes are what production
Raft looks like; their absence is what incidents look like.&lt;/li&gt;
&lt;li&gt;Raft orders the log — idempotency, read consistency modes, and the
cost of quorum rounds remain your problems.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>distributed-systems</category><category>systems</category></item></channel></rss>