<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jerome</title>
    <description>The latest articles on DEV Community by Jerome (@jeromefromhk).</description>
    <link>https://dev.to/jeromefromhk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024992%2Feb12d1dc-70ad-4c48-b8cc-6c2d8cbdc11d.jpg</url>
      <title>DEV Community: Jerome</title>
      <link>https://dev.to/jeromefromhk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeromefromhk"/>
    <language>en</language>
    <item>
      <title>Claude Code Recommended: Give Up</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:11:04 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/claude-code-recommended-give-up-460d</link>
      <guid>https://dev.to/jeromefromhk/claude-code-recommended-give-up-460d</guid>
      <description>&lt;p&gt;Nine hours into a live networking bug on my k3s cluster, Claude Code asked me a question with three options. The first was labeled &lt;code&gt;(Recommended)&lt;/code&gt;. It was to give up.&lt;/p&gt;

&lt;p&gt;The task behind it was ordinary by this point: a fourteen-step plan to put PR preview environments behind an Istio Ambient mesh, run task by task through &lt;code&gt;superpowers&lt;/code&gt;' subagent-driven-development — one implementer subagent per task, one reviewer subagent to check its work, me approving the boundary. Thirteen tasks had gone this way already, each with its own small fight (an ArgoCD self-heal loop fighting istiod's webhook config, a sync retry limit exhausted, a waypoint proxy quietly asking for ten times the CPU its namespace quota allowed) and each one resolved cleanly enough that I stopped expecting the fourteenth to be different. Task 14 was verification: open a real pull request, send it a request carrying &lt;code&gt;x-pr-lane: 1&lt;/code&gt;, confirm it landed on the preview pod instead of the baseline one.&lt;/p&gt;

&lt;p&gt;It never did. Every request, header or no header, went straight to baseline. No error, on either side of that route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling out the obvious
&lt;/h2&gt;

&lt;p&gt;What followed was the kind of debugging that doesn't look like debugging from outside — it looks like a long list of things that turned out not to be the problem. istio-cni's iptables &lt;code&gt;REDIRECT&lt;/code&gt; rule inside the pod's network namespace: confirmed catching outbound packets, &lt;code&gt;nsenter&lt;/code&gt; and all. ztunnel's xDS config: correct, the waypoint binding was right there in the config dump at the Service level. The waypoint itself: reachable, routes programmed, ready — and in three hours of its own logs, holding zero connections. A namespace-level versus Service-level &lt;code&gt;use-waypoint&lt;/code&gt; label swap: no change. A GitHub issue on Istio's tracker with the same symptom: different root cause, cross-namespace enrollment, didn't apply here. A host-side &lt;code&gt;tcpdump&lt;/code&gt; that caught traffic arriving on ztunnel's own tunnel port looked, for a while, like proof the fault lived inside ztunnel's routing logic rather than upstream of it — a read that turned out to be wrong, but not obviously wrong, not at 2 a.m.&lt;/p&gt;

&lt;p&gt;Somewhere in the middle of that list, at 03:08, Claude Code tried a fix, watched it fail, and said so plainly: the new packet capture didn't just fail to confirm the diagnosis, it contradicted it. The situation was worse than it had looked an hour earlier. It asked whether to keep going. I didn't answer for forty-four minutes — I was asleep — and when I did, I just wrote: continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest report
&lt;/h2&gt;

&lt;p&gt;Fifty minutes after that, at 04:01, it stopped asking and started reporting. The message was direct about what had happened: it had gone through, in its words, nearly every known and documented possible cause, systematically, and ruled each one out. Time to say so honestly. Then the question, three options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Wrap up here, mark waypoint L7 routing as a known limitation (Recommended)
2. File an issue with Istio upstream — unlikely to get an answer quickly
3. Keep digging — success rate is already falling, may just be unsolvable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked what, specifically, was blocking it. It answered plainly: the feature's actual selling point — routing a live PR preview by request header through the mesh — wasn't something it could deliver. Did I want it to close this out as a known limitation, or look at the other options first?&lt;/p&gt;

&lt;p&gt;Nothing about that exchange was performative. Every hypothesis it had checked really had checked out negative or inconclusive; the elimination had been careful, not lazy. What made the "Recommended" default worth noticing wasn't that it was reached in bad faith — it's that a careful, honest elimination of every path you can currently see still isn't the same thing as having eliminated every path there is. Nine hours of good work had converged on the wrong conclusion anyway, for the ordinary reason that whatever had anchored the investigation at hour one was still anchoring it at hour nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  A subagent that owed the last one nothing
&lt;/h2&gt;

&lt;p&gt;I didn't argue the evidence, and I didn't tell it to keep grinding on the same thread either. I told it to open an Opus subagent and have that subagent investigate independently.&lt;/p&gt;

&lt;p&gt;The instruction I gave it mattered as much as the decision to give it: don't inherit my read of this, and don't inherit yours either — if you think a verification step upstream was done wrong, don't take it on faith, redo it. The dispatch handed the new subagent the entire trail — every hypothesis raised, tested, and marked ruled-out or inconclusive over the previous nine hours — with explicit permission to distrust every one of them, plus careful, reversible access to the live cluster to go check for itself.&lt;/p&gt;

&lt;p&gt;Twenty-eight minutes later it came back with an answer, and the answer was uncomfortable in a specific way: one of the hypotheses from hour one had been right all along. It had just never actually been tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  The datapath nobody restarted
&lt;/h2&gt;

&lt;p&gt;The candidate was Cilium's own socket-level load balancing. With kube-proxy replacement on, Cilium's eBPF datapath resolves a Service's ClusterIP to a specific pod IP before istio-cni's netns-scoped &lt;code&gt;REDIRECT&lt;/code&gt; rule gets a chance to preserve the original ClusterIP as the connection's &lt;code&gt;SO_ORIGINAL_DST&lt;/code&gt;. By the time ztunnel inspects the redirected connection, it's already looking at a pod IP, not the Service VIP — and a waypoint binding is keyed to the Service, so it can never apply. Cilium's own chart documents the fix for exactly this case: &lt;code&gt;socketLB.hostNamespaceOnly: true&lt;/code&gt;, restricting that early resolution to the host network namespace and leaving pod-namespace traffic alone.&lt;/p&gt;

&lt;p&gt;That value had been set, hours earlier, at hypothesis two of nine. &lt;code&gt;helm upgrade&lt;/code&gt; had gone out, the &lt;code&gt;cilium-config&lt;/code&gt; ConfigMap had updated, and the ConfigMap was what every verification step after that checked — because a ConfigMap is where Kubernetes config normally lives, and reading it back is normally proof enough. It wasn't, here. &lt;code&gt;cilium-agent&lt;/code&gt; reads that config exactly once, at its own startup, and compiles it directly into the cgroup BPF program it runs — not something a controller loop re-reads on a timer. And the Helm chart puts no config-hash annotation on the DaemonSet's pod template, so a values-only change leaves that template byte-for-byte identical and triggers no rollout at all. The ConfigMap said &lt;code&gt;true&lt;/code&gt;. The compiled datapath, still running the same process it booted with, was still &lt;code&gt;full&lt;/code&gt; — silently declining every waypoint binding it was handed, with nothing anywhere logging so much as a warning.&lt;/p&gt;

&lt;p&gt;The subagent's proof wasn't a guess dressed up as confidence — it was three independent lines converging. &lt;code&gt;cilium-dbg status --verbose&lt;/code&gt; read &lt;code&gt;Socket LB Coverage: Full&lt;/code&gt;, not the &lt;code&gt;Hostns-only&lt;/code&gt; the ConfigMap promised. The DaemonSet's own history read the wrong way round: the Helm revision that introduced the setting landed nineteen hours &lt;em&gt;after&lt;/em&gt; the running &lt;code&gt;cilium-agent&lt;/code&gt; pod's own start time, with a restart count still at zero and a controller-revision-hash still pointing at a template from a week earlier. And a live trace, &lt;code&gt;cilium-dbg monitor -t trace-sock&lt;/code&gt;, caught the DNAT happening — Service VIP rewritten to pod IP, inside the calling pod's own cgroup, before ztunnel had any chance to see the original destination.&lt;/p&gt;

&lt;p&gt;The fix, once that was visible, was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system rollout restart daemonset/cilium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Socket LB Coverage&lt;/code&gt; read &lt;code&gt;Hostns-only&lt;/code&gt; afterward. The header test that had failed all night routed correctly on the first try. I wrote back one word — do it — and a few minutes later: it finally worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not stuck, anchored
&lt;/h2&gt;

&lt;p&gt;What the first nine hours got wrong wasn't the hypothesis. Hypothesis two was correct from the start. What they got wrong was treating a ConfigMap read as proof that a change had taken effect, and then never going back to re-examine that specific piece of evidence once it had been filed away as "applied." Every hour after that built on the same foundation, and no amount of additional hours was going to notice a crack in a foundation nobody was looking at anymore. That's a different failure than "not smart enough" or "gave up too easily" — the elimination really was careful, right up until the point where it wasn't checking anything new.&lt;/p&gt;

&lt;p&gt;An independent subagent fixed it not by being a better engineer, but by not knowing yet which piece of evidence was supposed to be settled. Told explicitly not to trust the read, it went and checked the one thing nobody had checked twice: whether the config change had reached the process actually running the datapath, or only the object Kubernetes stores it in. The gap between those two things is exactly where this kind of bug lives, and it's invisible from inside an investigation that's already decided where the line between "verified" and "still open" sits.&lt;/p&gt;

&lt;p&gt;The smaller fix landed the same night, in the values file, as a comment for whoever changes this setting next: after touching anything under &lt;code&gt;socketLB&lt;/code&gt;, restart the DaemonSet and check the live datapath, not the ConfigMap. A second one landed in the repo's own &lt;code&gt;CLAUDE.md&lt;/code&gt; a couple of hours later, general enough to outlast this specific bug — a rule against testing changes directly against a live GitOps-managed resource before they're committed, because ArgoCD's self-heal will revert an uncommitted edit with nothing pointing at the cause. Both are the same lesson in different clothes: know exactly what you're actually checking, and don't let an early confirmation retire the question that led you to check it in the first place.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>kubernetes</category>
      <category>agentic</category>
    </item>
    <item>
      <title>Synced, Healthy, Running, Wrong</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Tue, 18 Aug 2026 08:07:12 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/synced-healthy-running-wrong-2ak2</link>
      <guid>https://dev.to/jeromefromhk/synced-healthy-running-wrong-2ak2</guid>
      <description>&lt;p&gt;Two days after &lt;a href="https://dev.to/jeromefromhk/the-twenty-hour-fuse-4lcl"&gt;The Twenty-Hour Fuse&lt;/a&gt;, I had seven new alert rules on a four-core ARM box — PSI, swap consumption, D-state counts, a forecast on the memory trend. They were insurance against a repeat of an incident I'd already fixed. On August 17th they fired for something else entirely: the same rule, twice in one day, for two root causes with nothing in common.&lt;/p&gt;

&lt;p&gt;What connects those two incidents isn't a mechanism. It's that neither of them made anything report an error. Kubernetes said &lt;code&gt;Running&lt;/code&gt;. ArgoCD said &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt;. Helm exited zero. An admission policy checked for vulnerabilities and passed. Every layer in the stack answered the only question it knew how to ask, answered it correctly, and the composite answer was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reader with no files open
&lt;/h2&gt;

&lt;p&gt;The first alert: IO PSI &lt;code&gt;full&lt;/code&gt; above 15 percent for two minutes. &lt;code&gt;/proc/pressure/io&lt;/code&gt; confirmed it — &lt;code&gt;full avg10&lt;/code&gt; at 24.97, and &lt;code&gt;avg300&lt;/code&gt; at 21.99, which matters more, because it means this wasn't a spike I'd arrived after. It was a plateau I was standing on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vmstat&lt;/code&gt; gave the direction in one line: &lt;code&gt;bi&lt;/code&gt; around 90,000 blocks a second, &lt;code&gt;bo&lt;/code&gt; essentially zero, &lt;code&gt;wa&lt;/code&gt; over 20 percent of CPU. Reads. Disk at 38 percent, so nothing was full. A scan of &lt;code&gt;/proc/*/io&lt;/code&gt; for two-second deltas returned only my own shell — the real consumer belonged to another user — and the same scan under &lt;code&gt;sudo&lt;/code&gt; returned a single process well ahead of everything else: &lt;code&gt;all-in-one-linux&lt;/code&gt;, PID 713618, reading about 93 MB/s.&lt;/p&gt;

&lt;p&gt;Then the hunt turned over. I went looking for what it had open, and &lt;code&gt;/proc/713618/fd&lt;/code&gt; held nothing but sockets. &lt;code&gt;/proc/713618/maps&lt;/code&gt; showed one file mapping, its own executable. A process reading 93 MB/s off the block device had not one file open.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rchar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="m"&gt;18033040&lt;/span&gt;      &lt;span class="c1"&gt;# ~18 MB requested through read()&lt;/span&gt;
&lt;span class="na"&gt;read_bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;63512895488&lt;/span&gt;   &lt;span class="c1"&gt;# ~63.5 GB actually fetched from the block device&lt;/span&gt;
&lt;span class="na"&gt;write_bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two counters measure different things. &lt;code&gt;rchar&lt;/code&gt; is what the process asked for; &lt;code&gt;read_bytes&lt;/code&gt; is what the block layer actually fetched on its behalf. For a process reading files they track each other within an order of magnitude. A gap of three thousand times means the reads were never its requests: they were its own pages being faulted back in after the kernel evicted them. The IO is billed to the process. The process did not initiate it.&lt;/p&gt;

&lt;p&gt;That reframes the whole question, and it's the step where stopping early would have sent me somewhere useless. The top of the IO list wasn't a cause to investigate; it was a symptom to trace upstream. &lt;code&gt;/proc/vmstat&lt;/code&gt; confirmed the scope immediately — &lt;code&gt;workingset_refault_file&lt;/code&gt; at 31.4 million, &lt;code&gt;pgscan_file&lt;/code&gt; at 118 million, &lt;code&gt;pgsteal_file&lt;/code&gt; at 91.6 million. Not one process misbehaving. The whole host thrashing its page cache.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dmesg&lt;/code&gt; supplied the reason: three OOM kills in sixteen hours. A JVM, a trivy scan job, and at 05:39 a process named &lt;code&gt;all-in-one-linu&lt;/code&gt; — and my suspect's &lt;code&gt;etime&lt;/code&gt; was &lt;code&gt;07:25:53&lt;/code&gt;, which put its birth exactly at that kill. The heaviest reader on the box was the restarted corpse of the third victim, still thrashing in the same conditions that had killed it.&lt;/p&gt;

&lt;p&gt;It was Jaeger, in a demo namespace, and its container limit was &lt;code&gt;128Mi&lt;/code&gt;. Its &lt;code&gt;anon-rss&lt;/code&gt; at kill time was 126,032 kB. It had been living on the ceiling. The node explained why nothing had pushed back: memory requests at 41 percent of capacity, memory limits at 110 percent. The scheduler reads requests, so as far as it was concerned this node had room to spare, and it would happily keep placing pods until real usage caught up with the overcommit — which is precisely what had happened.&lt;/p&gt;

&lt;p&gt;Through all three kills the pod's phase was &lt;code&gt;Running&lt;/code&gt;. The kubelet restarts the container in place; the pod object never leaves the happy path. &lt;code&gt;kubectl get pods&lt;/code&gt; showed a restart count of 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifteen checks, and the shape of the hole
&lt;/h2&gt;

&lt;p&gt;That box runs an inspector — fifteen shell checks, twice a day at 09:00 and 21:00, each one either fixing something safe automatically or raising an alert for a human. Afterwards I went through the list to find which one should have caught this, and the answer was none of them, for reasons that were individually reasonable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;k3s-evicted-pods.sh&lt;/code&gt; queries with &lt;code&gt;--field-selector=status.phase=Failed&lt;/code&gt;. An OOM-killed container doesn't move its pod to &lt;code&gt;Failed&lt;/code&gt;, so this incident was never inside the query. &lt;code&gt;docker-restart-storms.sh&lt;/code&gt; only looks at compose containers on the host, never k3s pods, and its threshold for a storm is ten restarts; Jaeger had one. The Grafana rules could see the box stalling on IO — they're what woke me up — but aggregate pressure can't tell you which container the kernel shot.&lt;/p&gt;

&lt;p&gt;The sixteenth check reads &lt;code&gt;containerStatuses[].lastState.terminated.reason == "OOMKilled"&lt;/code&gt; with a 24-hour lookback, wide enough that a missed run still catches everything. It only alerts; whether a limit should be raised is a judgment call and not a cron job's business.&lt;/p&gt;

&lt;p&gt;The detail I keep coming back to is that the gap was exactly the shape of the failure. &lt;code&gt;lastState&lt;/code&gt; is where Kubernetes files this, faithfully, for anyone who asks. Nobody was asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set in git, never set in the cluster
&lt;/h2&gt;

&lt;p&gt;An hour after that fix went in, the same rule fired again.&lt;/p&gt;

&lt;p&gt;By the time I looked, &lt;code&gt;avg10&lt;/code&gt; was back to zero and &lt;code&gt;avg300&lt;/code&gt; was down to 6.15 — the event was already over. Prometheus had the history, since node-exporter had been scraping all along: &lt;code&gt;rate(node_pressure_io_stalled_seconds_total[2m])*100&lt;/code&gt; over three hours showed five separate waves, the largest peaking at 77.9 percent. The opposite shape from the morning. Not a plateau, a pattern. Something recurring on a schedule I hadn't found yet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;journalctl&lt;/code&gt; lined every wave up with trivy scan pods starting. (A self-hosted service on the same box, one that writes to SQLite, had started logging &lt;code&gt;database is locked&lt;/code&gt; around then too. It looked like a second lead for about a minute; it was downstream of the same IO.)&lt;/p&gt;

&lt;p&gt;The live ConfigMap said &lt;code&gt;OPERATOR_CONCURRENT_SCAN_JOBS_LIMIT: 10&lt;/code&gt;. My &lt;code&gt;values.yaml&lt;/code&gt; in git said &lt;code&gt;scanJobsConcurrentLimit: 3&lt;/code&gt;. Checking the chart's own schema explained the difference: that key belongs nested under &lt;code&gt;operator:&lt;/code&gt;, and mine sat at the top level, where Helm silently discarded it and used the chart default of 10.&lt;/p&gt;

&lt;p&gt;It had never once been in effect, and nothing anywhere could have told me. The git diff read correctly. ArgoCD reported &lt;code&gt;Synced&lt;/code&gt;, accurately — the rendered manifests did match git. The operator started clean. An unrecognized key in a Helm values file is not a warning or a non-zero exit; it's nothing at all.&lt;/p&gt;

&lt;p&gt;Ten concurrent scan jobs share one filesystem cache directory guarded by a lock file that doesn't support concurrent access. Six hours of operator logs held 36 lock timeouts, several jobs exhausting their retries into &lt;code&gt;BackoffLimitExceeded&lt;/code&gt; — each failure arriving &lt;em&gt;after&lt;/em&gt; the job had pulled the image layers in full. Pure wasted IO, on repeat.&lt;/p&gt;

&lt;p&gt;And that explains the schedule. The initial cluster-wide scan on the 16th ran from 14:01 to 18:10, four hours, stretched by exactly this contention. Report TTL is 24 hours. A four-hour smear of creation times becomes a four-hour smear of expiry times, so the rescan storm inherits its own shape from the bug that caused it, every day, at the same time. The TTL mechanism is sound; it exists to catch CVEs disclosed against images that haven't changed. The bug had turned a daily routine into a four-hour siege.&lt;/p&gt;

&lt;p&gt;Then I made the same class of mistake a second time. &lt;code&gt;skipResourceByLabels&lt;/code&gt; belongs under &lt;code&gt;trivyOperator:&lt;/code&gt; — the chart splits operator-controller settings from scan-job behaviour into two top-level blocks — and I put it under &lt;code&gt;operator:&lt;/code&gt;, having just learned that &lt;code&gt;operator:&lt;/code&gt; was where the &lt;em&gt;previous&lt;/em&gt; key belonged. Same silence, same result.&lt;/p&gt;

&lt;p&gt;So the habit that came out of this is mechanical: before committing any values change, run &lt;code&gt;helm template &amp;lt;chart&amp;gt; --repo &amp;lt;url&amp;gt; --version &amp;lt;ver&amp;gt; -f values.yaml | grep &amp;lt;key&amp;gt;&lt;/code&gt; and confirm the rendered output is non-empty and correct. Reading the YAML doesn't work. Reviewing the diff doesn't work. The failure mode has no symptoms at the layer where you're looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set in the cluster, and still inert
&lt;/h2&gt;

&lt;p&gt;One scan job kept failing regardless: cilium's, every 45 to 80 seconds.&lt;/p&gt;

&lt;p&gt;Cilium's DaemonSet has six initContainers plus a main container, all on the same image digest. trivy-operator packs all seven into a single scan job pod as ordinary &lt;code&gt;containers:&lt;/code&gt; — not Kubernetes &lt;code&gt;initContainers:&lt;/code&gt;, which would run in sequence — so Kubernetes starts all seven at once and they race each other for the same cache lock. &lt;code&gt;scanJobsConcurrentLimit&lt;/code&gt; bounds concurrency &lt;em&gt;between&lt;/em&gt; jobs. It has nothing to say about seven processes inside one.&lt;/p&gt;

&lt;p&gt;The chart has a setting for this, &lt;code&gt;skipInitContainers: true&lt;/code&gt;. I set it, confirmed the value in the live ConfigMap, watched for about ten minutes, saw no new multi-container jobs, and called it fixed.&lt;/p&gt;

&lt;p&gt;That was wrong, and the way it was wrong is the part worth keeping. The retry interval was 45 to 80 seconds and my observation window was ten minutes — long enough to look calm during a lull, short enough to miss that the cycle had never stopped. Twenty-five minutes later it was still going, unchanged.&lt;/p&gt;

&lt;p&gt;Reading the operator's source for that version explains it. &lt;code&gt;ScanJobBuilder.Get()&lt;/code&gt; does call &lt;code&gt;GetContainerImagesFromPodSpec(spec, s.skipInitContainers)&lt;/code&gt;, and the filter is applied correctly — but that filtered list is only used to build a JSON annotation on the Job object. The call that actually produces the pod spec, &lt;code&gt;s.plugin.GetScanJobSpec(...)&lt;/code&gt;, is never passed the flag, and independently recomputes the container list from the original resource. The setting is read correctly and applied to the wrong output. It changes what the operator writes down, and nothing about what it runs.&lt;/p&gt;

&lt;p&gt;This is a third kind of silence, and the most uncomfortable one. The value was correct in git, correct in the rendered chart, and correct in the running ConfigMap — every verification I'd built after the last mistake would pass. Config verification ends at the ConfigMap. Whether the config does anything is a separate question, and only behaviour answers it.&lt;/p&gt;

&lt;p&gt;The fix was to stop scanning that DaemonSet at all, which took two tries: the chart's &lt;code&gt;podLabels&lt;/code&gt; renders to &lt;code&gt;spec.template.metadata.labels&lt;/code&gt;, but the operator's skip test reads the workload object's own labels, and a DaemonSet has no ReplicaSet sitting in between — the resource &lt;em&gt;is&lt;/em&gt; the DaemonSet. So the label went on by hand, metadata only, no rollout. The price is that cilium's main container isn't scanned either. That one's a knowing trade, not a gap.&lt;/p&gt;

&lt;p&gt;Underneath all of it was a fourth failure that had been running since the day trivy-operator went in. Trivy's CLI assumes &lt;code&gt;linux/amd64&lt;/code&gt; unless told otherwise and refuses anything else, and five of the images on this cluster are built single-arch arm64 by this repo's own CI. Every scan of them had failed, every day, from the beginning. Nothing ever surfaced it, because a failed scan produces no report — and no report looks exactly like no news.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gate that admits what it can't see
&lt;/h2&gt;

&lt;p&gt;Which matters, because there's a Kyverno policy on this cluster that denies pods whose Trivy report shows CRITICAL vulnerabilities with a fix available. Its condition counts matching vulnerabilities and denies when the count is greater than zero.&lt;/p&gt;

&lt;p&gt;Run that against a workload with no report at all. The lookup returns an empty list, the count is zero, &lt;code&gt;&amp;gt; 0&lt;/code&gt; is false, and the pod is admitted. No error, no warning, and — worse — no deny event, so there's nothing in the audit trail to notice later. The policy is strongest against workloads that scan successfully and completely absent for workloads that don't, which is the inverse of what you'd want.&lt;/p&gt;

&lt;p&gt;At that point more than ten workloads had no report, for three unrelated reasons: the arm64 five, a handful of locally-built images that 401 against a registry they were never pushed to, and a few more I still can't account for.&lt;/p&gt;

&lt;p&gt;So the sequence of the fix mattered more than any individual piece of it. First make the arm64 images actually scan — a trivy config file setting &lt;code&gt;image.platform: linux/arm64&lt;/code&gt;, rendered into the scan container and passed via &lt;code&gt;--config&lt;/code&gt;, which I verified end to end in a throwaway pod against one of the failing images before it went anywhere near the real config. Then narrow the gate to the two self-built apps whose reports I'd confirmed existed. Only then flip it from &lt;code&gt;Audit&lt;/code&gt; to &lt;code&gt;Enforce&lt;/code&gt;. An enforcing gate on top of a blind scanner is worse than no gate, because it reports a control you don't actually have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the detectors came from
&lt;/h2&gt;

&lt;p&gt;The changes that came out of two days: two memory limits raised, two Helm keys moved to the blocks they belonged in, one label applied by hand, one trivy platform setting, one DaemonSet excluded, one alert's hold widened from three minutes to ten to stop it firing on ordinary noise. None of that is interesting.&lt;/p&gt;

&lt;p&gt;What's interesting is where the things that did the finding came from. The PSI rules that caught both incidents were written two days earlier, in the postmortem of a different incident with a different cause — they were aimed at VS Code sessions and caught a memory overcommit and a scanner instead. The Prometheus history that made the second incident legible existed because node-exporter had been quietly scraping the whole time. The sixteenth check exists because of the first of these two. The &lt;code&gt;helm template&lt;/code&gt; habit exists because of the second. The list of workloads with no vulnerability report exists because I went looking for absences, which is not something any dashboard will ever show you — dashboards show what exists.&lt;/p&gt;

&lt;p&gt;None of it was designed. Every piece is sediment from the incident before it.&lt;/p&gt;

&lt;p&gt;And the reason it has to accumulate that way is that nothing here malfunctioned. Kubernetes was right that the pod was Running. ArgoCD was right that the cluster matched git. Helm was right that it had applied every key it recognized. The admission policy was right that it found no fixable CRITICAL CVEs. Four correct answers to four questions I hadn't asked. Detection is the separate thing that asks the question none of them are scoped to answer, and there's no version of a well-built system that hands it to you for free.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>sre</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>The Twenty-Hour Fuse</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:15:54 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/the-twenty-hour-fuse-4lcl</link>
      <guid>https://dev.to/jeromefromhk/the-twenty-hour-fuse-4lcl</guid>
      <description>&lt;p&gt;Two nights of debugging, one incident left to write about. The night of August 14th, a Claude Code session hung for an hour inside the VS Code extension, and I traced it to a per-token streaming stall — the previous two pieces, &lt;a href="https://dev.to/jeromefromhk/two-clocks-neither-lying-1i8n"&gt;Two Clocks, Neither Lying&lt;/a&gt; and &lt;a href="https://dev.to/jeromefromhk/coalescing-the-stream-j09"&gt;Coalescing the Stream&lt;/a&gt;, cover that hunt and the middleware that fixed it. The morning after, the same four-core box nearly went down under a load average of 38.7, and it turned out to be the same incident, twenty hours downstream. Nothing exploded that morning. A fuse had been burning since the previous afternoon, and 11:09 was simply when it reached the powder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the machine before touching it
&lt;/h2&gt;

&lt;p&gt;The load numbers spoke first: 11.08, 38.69, 23.59 — one-minute, five-minute, fifteen-minute. A crisis still climbing shows the opposite order, disaster in the one-minute figure first; mine were falling, so the peak had already passed while I was still reading. Then &lt;code&gt;/proc/pressure&lt;/code&gt; — PSI, the kernel's stall accounting for CPU, memory, and IO, and the closest thing a Linux box has to a memory of the last five minutes: memory &lt;code&gt;full&lt;/code&gt; at 10 percent (processes genuinely stopped, waiting on page reclaim) and IO &lt;code&gt;some&lt;/code&gt; at 45 percent. Memory pressure had turned into an IO storm.&lt;/p&gt;

&lt;p&gt;The connection to the headline number is D state. Processes parked in uninterruptible IO waits — runc and a node healthcheck were caught in the act — count into load average exactly as if they were running. Four cores at 38.7 wasn't thirty-eight cores of computation; it was modest real work plus a crowd of processes waiting in the kernel's lobby.&lt;/p&gt;

&lt;p&gt;Elimination was fast because the box was eloquent. No OOM kills in the kernel log — the kernel hadn't started shooting anyone. &lt;code&gt;docker stats&lt;/code&gt;: every container under 500 MB, single-digit CPU — not the compose stacks. Disk at 65 percent — not full. A process listing sorted by memory, then grouped: llama.cpp at 3.9 GB, seven Java services at 2.3 GB, k3s at 1.1 GB — steady state, all normal, none of it news. And fifty-nine &lt;code&gt;vscode-server&lt;/code&gt; processes holding 7.3 GB, which is not steady-state anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  One server, two windows, and the rule that fell out of the map
&lt;/h2&gt;

&lt;p&gt;The decisive hour went into mapping the process tree, PPID by PPID, because "59 processes" is a number, not a structure. The structure: Remote-SSH runs one server per client version on the box, parented to init — detached from any SSH session, and therefore under no session's death sentence — and that one server hosts every window the client opens, one extension host per window.&lt;/p&gt;

&lt;p&gt;A server started on August 9th was hosting two. One was the window I was typing in. The other was an old window from August 14th whose extension host had grown seventeen processes and 3.2 GB — containing the very Claude session that had hung the night before, plus four more claude processes born of retries between 2:04 and 2:11 a.m., every retry a new process while the old ones never exited. A second, separate server tree — thirteen processes, another device's window from 8:26 that morning — completed the census.&lt;/p&gt;

&lt;p&gt;The operational rule fell straight out of the map: I could not kill a server, only windows. Kill the shared server and I'd be sawing the branch I sat on. The kill list was the old window's extension-host tree, the other device's whole server tree, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty hours, then eleven minutes
&lt;/h2&gt;

&lt;p&gt;As a timeline, the incident runs at two speeds. Accumulation: from 15:17 on the 14th, when the old window connected and the stalled session began its hour-long hang, to 8:26 the next morning. Retries stacked unexiting processes; detached servers sat immune; and six versions of the server binary piled up in &lt;code&gt;~/.vscode-server&lt;/code&gt; — each client upgrade downloads a new one — 3.8 GB of them. Memory crept past 85 percent slowly enough that nothing intervened: no swap to absorb the creep, and memory alerts set at 80 and 90 percent are spike detectors — for growth this gradual they arrive with hours of momentum already behind them.&lt;/p&gt;

&lt;p&gt;Ignition: 10:59 to 11:09. A new connection, a reconnect, two fresh extension hosts spinning up their language servers — Pylance and friends, CPU- and IO-heavy — on a box whose page cache was already being squeezed to stay alive. Eleven minutes of simultaneous startup on a machine with no headroom: the kernel began reclaiming the pages everything was reading, IO seized, processes dropped into D state, and the load average registered the detonation. The five-minute peak of 38.7 covers exactly those eleven minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four layers, four failures
&lt;/h2&gt;

&lt;p&gt;The useful postmortem question isn't what broke; it's what should have caught this and didn't. Four layers, each with a job, each failing for a specific reason.&lt;/p&gt;

&lt;p&gt;The trigger layer is the stall itself, and it's the one layer that held — the middleware went in the night before. This incident arrived with its ignition source already removed; the fuse just had to finish burning.&lt;/p&gt;

&lt;p&gt;The accumulation layer failed architecturally. Servers that outlive their sessions because nothing ever kills them; an old client version that doesn't start its servers with the auto-shutdown flag the new ones carry; and — the part that's mine — a replication engine made of my own reasonable responses. Every "reopen the window and retry" was the correct move as far as I knew, and every one cloned the problem: a fresh process next to the ones that never left.&lt;/p&gt;

&lt;p&gt;The buffer layer failed by absence. Without swap, the kernel's relief valve under memory pressure is reclaiming the page cache — the exact thing every running process is trying to read — which is how a memory shortage converts into an IO storm. Swap is the difference between slow and seized.&lt;/p&gt;

&lt;p&gt;The capacity layer failed by design. Steady state on this box is about 15 GB of its 23 — k3s, llama.cpp, ten compose stacks, seven JVMs — leaving thin elastic headroom for the one workload that actually bursts: interactive sessions. The incident priced that layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surgery, and two humbling footnotes
&lt;/h2&gt;

&lt;p&gt;The cleanup went carefully, in this order: a keep-list first — the entire process chain of the window I was typing in, the shared pty host, the agent host. Then a recursive walk collecting every descendant of each root marked for death, with each root's identity re-verified against its command line, because a PID is a label that gets reused, not a name. TERM first; KILL for the survivors four seconds later. And one rule I never skip: the kill list gets checked for intersection with my own process chain, because the checker is also a process on this box, and the one tree it must never reap is its own. Fifty-nine VS Code processes became nineteen; fourteen once the dust settled.&lt;/p&gt;

&lt;p&gt;Then 4 GB of swap — fallocate, mkswap, swapon, fstab — after which available memory jumped from 2.8 GB to 8.2 GB, mostly because the kernel stopped strangling the page cache. Then the version pile: five of the six server directories deleted, 3.2 GB back.&lt;/p&gt;

&lt;p&gt;Footnote one: before deleting those directories I verified nothing referenced them, by grepping &lt;code&gt;ps&lt;/code&gt; output for the directory names. Every directory came back "in use" — because &lt;code&gt;ps&lt;/code&gt; prints my own checking command's command line, names included. The check had matched its own author. The corrected version then carried its own bug, an invalid &lt;code&gt;ps&lt;/code&gt; invocation that silently verified nothing, and the deletion went through unguarded; a full manual census afterward confirmed nothing innocent had died. The lesson generalizes: any check that greps a process list and then acts on the result must exclude the checker — the observer is always inside the observation.&lt;/p&gt;

&lt;p&gt;Footnote two: at 11:28 the killed device reconnected by itself — the desktop client, still open, reaching out and re-downloading 600 MB of server binary because I'd just deleted it — and the old window re-spawned an extension host onto the shared server. Killed again; four quiet minutes after that. You cannot win this fight on the server alone. Server-side cleanup plus client windows actually closed, or the tree regrows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;11:09&lt;/th&gt;
&lt;th&gt;11:33&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Load average (1 min)&lt;/td&gt;
&lt;td&gt;11.1 (5-min peak 38.7)&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory used / available&lt;/td&gt;
&lt;td&gt;20 Gi / 2.8 G&lt;/td&gt;
&lt;td&gt;15 Gi / 8.2 G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;4 GB, in fstab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IO PSI (10 s / 5 min)&lt;/td&gt;
&lt;td&gt;21% / 45%&lt;/td&gt;
&lt;td&gt;0.01% / 2.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code processes&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server versions on disk&lt;/td&gt;
&lt;td&gt;6, ~3.8 GB&lt;/td&gt;
&lt;td&gt;1, 570 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Checked again as I write this: swap present and quiet, one server directory, load under one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually fixed
&lt;/h2&gt;

&lt;p&gt;The honest accounting is that the morning's cleanup treated the wreckage, not the disease. The actual fix had happened the night before, in a middleware file: with the per-token stream gone from the pipeline, sessions end when they end, processes exit, and the accumulation layer lost its fuel. What this incident added was the rest of the defense — swap as the shock absorber, and seven new alert rules, across four signals: PSI (io and memory pressure), the count of processes stuck in D state, swap presence and consumption, and a &lt;code&gt;predict_linear&lt;/code&gt; forecast on the memory trend. All four read metrics &lt;code&gt;node_exporter&lt;/code&gt; was already emitting, so none of it needed new infrastructure. The instinct I reached for first — alert on the &lt;code&gt;vscode-server&lt;/code&gt; process count directly — would have been the more legible signal, but shipping it meant a host cron job, a textfile collector, and a node-exporter mount change for one app-specific number; it's shelved for now in favor of generic pressure signals that were already flowing. And a habit: close the window, don't reopen it.&lt;/p&gt;

&lt;p&gt;Every alarm I had was built for the eleven minutes. The trend forecast is the one now aimed at the twenty hours before it.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>sre</category>
      <category>vscode</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Coalescing the Stream</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:15:28 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/coalescing-the-stream-j09</link>
      <guid>https://dev.to/jeromefromhk/coalescing-the-stream-j09</guid>
      <description>&lt;p&gt;Claude Code, on my box, doesn't talk to a model provider directly. It goes through claude-code-router (ccr) — a self-hosted proxy that lets the same CLI point at the official Anthropic API or at third-party providers — and I watch the whole thing through the VS Code extension over Remote-SSH. The previous piece, &lt;em&gt;Two Clocks, Neither Lying&lt;/em&gt;, worked out why that path once let an approval prompt sit for an hour: one streaming event per token, an extension that couldn't burn them down fast enough, and the CLI's own control messages queued in behind six thousand deltas. This piece is about the fix, which turned out to be two problems in sequence — writing a middleware that merges those events back into chunks, and then getting the middleware to actually run. The first took an evening. The second took five attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one place I control
&lt;/h2&gt;

&lt;p&gt;The fault had three addresses. The provider's granularity — theirs, not mine to change. The extension — closed source, not mine to patch. And between them, the router: my container, my compose file, my rules. The official Anthropic API already streams in merged chunks, and the extension handles that fine — so the goal was never to make the extension faster. It was to make my stream look like the one the extension already digests.&lt;/p&gt;

&lt;p&gt;Which sounds like a small thing: a transformer that re-chunks deltas on the way out. But the constraint that governs every other decision is nastier than "merge events." The middleware must merge without changing what the stream means. A merger that corrupts semantics is worse than the stall — the stall costs an hour, a lying stream costs the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merge, without changing what the stream says
&lt;/h2&gt;

&lt;p&gt;The rule, in one sentence: merge consecutive &lt;code&gt;content_block_delta&lt;/code&gt; events only when they carry string payloads for the same block index and the same delta type — text into text, thinking into thinking, partial JSON into partial JSON — and flush the moment any other event appears.&lt;/p&gt;

&lt;p&gt;Every clause exists because its absence corrupts something. &lt;em&gt;Same index&lt;/em&gt;: one assistant message interleaves blocks — text, then a tool call, then more text — and block boundaries are how the client tells them apart; merge across an index change and you fuse two blocks into one that never existed. &lt;em&gt;Any other event flushes&lt;/em&gt;: &lt;code&gt;content_block_start&lt;/code&gt;, &lt;code&gt;content_block_stop&lt;/code&gt;, &lt;code&gt;message_delta&lt;/code&gt;, &lt;code&gt;message_stop&lt;/code&gt; all carry protocol meaning, so the window empties and they pass through untouched, in order. The middleware deliberately understands nothing about the conversation — it only combines things that are provably interchangeable and defers to everything else. That ignorance is the safety property.&lt;/p&gt;

&lt;p&gt;The window is the one knob: longer windows merge more and delay more. &lt;code&gt;CCR_SSE_COALESCE_MS&lt;/code&gt; sets it globally, and zero is the kill switch that disables the middleware outright. It later grew per-type overrides — &lt;code&gt;CCR_SSE_COALESCE_THINKING_MS&lt;/code&gt;, &lt;code&gt;_TEXT_MS&lt;/code&gt;, &lt;code&gt;_INPUT_JSON_MS&lt;/code&gt; — because one number can't serve two populations: thinking deltas can afford to wait, and text is the thing a human watches appear. A per-type value of zero or less doesn't mean "off for this type"; it means fall back to the global — a zero-width window has no timer to flush it and would strand data until the stream ended. Keep-alive pings are dropped by default (&lt;code&gt;CCR_SSE_DROP_PINGS=0&lt;/code&gt; restores them) — not for the bytes, but because a ping landing mid-thinking-run would flush the window exactly where the stream is densest; merging that continues across keep-alives is worth more than the pings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two transport truths that must not go stale
&lt;/h2&gt;

&lt;p&gt;Merging bodies invalidates two things the transport layer believes. Content-length: the merged body is no longer the length anyone declared, and a client honoring the stale header will truncate or hang — SSE travels chunked, so the header is simply removed. Compression: the middleware asks for &lt;code&gt;accept-encoding: identity&lt;/code&gt; on the way in, and if a response comes back compressed anyway, it bypasses itself — passes the stream through untouched rather than decompress, merge, and re-encode a body it can't vouch for. The rule underneath both: merge what you fully understand, decline what you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't trade a stall for a balloon
&lt;/h2&gt;

&lt;p&gt;The original bug was a slow reader backing up a pipe. A naive merger just relocates that disease one floor up: buffer eagerly, and the middleware becomes a balloon that grows until something else stalls. So the pressure passes through — when the downstream consumer signals stop, the queue pauses; when it signals go, delivery resumes. The middleware can smooth the stream's shape. It can't repeal its economics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loaded is not running
&lt;/h2&gt;

&lt;p&gt;Design done, the remaining problem looked small: get the file into the router's process. Four configurations failed before one worked, and each failure was its own lesson.&lt;/p&gt;

&lt;p&gt;Attempt one patched &lt;code&gt;globalThis.fetch&lt;/code&gt; — the famous door. Nothing. The router's gateway doesn't call fetch; it &lt;code&gt;require&lt;/code&gt;s undici and dispatches through &lt;code&gt;getGlobalDispatcher()&lt;/code&gt;. Patch the API actually in use, not the one with the brand recognition.&lt;/p&gt;

&lt;p&gt;Attempt two moved to the dispatcher layer — intercept undici's headers/data/completion handlers — and it worked, in exactly one process. The container runs more than one node process: a core server, and the gateway it spawns. Some provider traffic (DeepSeek's, it turned out) is fetched by the core server, which never loaded the patch — only the gateway had. Right layer, wrong process: a patch deployed into one process does nothing for its siblings, and when the target is "the container," the unit of deployment is the process tree.&lt;/p&gt;

&lt;p&gt;Attempt three made loading universal: &lt;code&gt;NODE_OPTIONS&lt;/code&gt; with &lt;code&gt;--require&lt;/code&gt;, so every node process in the container loads the module at birth. The module loaded. Nothing happened. &lt;code&gt;--require&lt;/code&gt; loads a file; it doesn't call anything in it — my module exported an &lt;code&gt;install()&lt;/code&gt; and waited politely for a caller who never came. Loading is not running. The fix was to make the module install itself at load time, idempotently, so that being loaded &lt;em&gt;is&lt;/em&gt; being deployed.&lt;/p&gt;

&lt;p&gt;Attempt four targeted a file that already existed: the gateway's preload. Also a trap — the core server re-creates that file from an embedded copy via &lt;code&gt;writeFileSync&lt;/code&gt; on every startup, so an edit to it has a lifespan of one restart. Never patch the generated artifact. Bring your own file, and get it invited.&lt;/p&gt;

&lt;p&gt;The fifth configuration is the one running now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;NODE_OPTIONS: &lt;span class="s2"&gt;"--require /data/.claude-code-router/sse-coalesce.cjs"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My file, kept in the ops repo and bind-mounted read-only into the container at a path the core doesn't know about — which is precisely why nothing ever overwrites it — loaded into every node process, installing itself the moment it lands. The repo is the single authority; the container is just where it runs.&lt;/p&gt;

&lt;p&gt;That placement carries one more trap, discovered when the middleware itself first needed changing: a bind mount's contents aren't part of what compose hashes, so editing the file leaves the running container untouched — and &lt;code&gt;--require&lt;/code&gt; loads only at process birth anyway. Changed is not reloaded. Every edit to the middleware means &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;, or it isn't an edit at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting what came out
&lt;/h2&gt;

&lt;p&gt;Before touching real traffic, the middleware got nine unit tests — the merge itself, content-length removal, backpressure passthrough, compression bypass, the refusal to merge across block indexes, ping dropping among them. Live verification ran through DeepSeek, because Zhipu's five-hour quota ceiling (&lt;code&gt;[1308]&lt;/code&gt;) interrupted the plan mid-check — an accident that doubled as proof the middleware doesn't care which provider it fronts. A 200-token response arrived as 85 events and left as 11, counted by the middleware's own stats log — which logs every merge and truncates itself at 256 KB, so the observability doesn't become its own incident.&lt;/p&gt;

&lt;p&gt;One hygiene change rode along: the router's request log had been capturing full bodies — 156 MB of them — and capture went from &lt;code&gt;all&lt;/code&gt; to &lt;code&gt;errors&lt;/code&gt; that night, with a vacuum bringing the file down to 12 MB. Which is why the granularity numbers in the previous piece are a record of one night, not a rerun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three minutes, still
&lt;/h2&gt;

&lt;p&gt;Deployed, the middleware killed the hour-level hang — and left a residue. The next afternoon my phone and the extension disagreed again, only smaller: the notification arrived on time, and the extension took another three minutes to show the question Claude was asking. The transcript settled which one was honest, same as before — the question landed in it the same second the notification went out. The three minutes were all display queue: the turn's last two responses had been through the merger and still arrived as 534 events, at an estimated 250 to 400 milliseconds of rendering each. That last number deserves a pause. It's the extension's per-event cost, obtained by division, and it's the ceiling everything else in this story pushes against.&lt;/p&gt;

&lt;p&gt;The stats log also explained why the merger hadn't merged more. Across the day, the forty-millisecond window was buying only three-to-five-fold reduction: the provider emits a delta every 25 to 50 milliseconds, and a window that narrow catches one or two before it expires. The knob had been set before anyone knew the stream's tempo.&lt;/p&gt;

&lt;p&gt;So the knob learned the tempo, per type: two hundred milliseconds globally, five hundred for thinking — ninety-nine percent of the traffic, where nobody is watching smoothness — and a hundred and twenty for text, where latency is the thing a human feels. On live traffic: 719 events in, 31 out. Then 337 in, 14 out. Twenty-three-fold and better, against a floor I'd set at fifteen. The ceiling itself is upstream and untouched — a renderer that costs a quarter-second per event on a long session — so the whole game on my side of the pipe is how many events there are to render. Fewer is the strategy. On long sessions, so is &lt;code&gt;/compact&lt;/code&gt;. That renderer is filed upstream now, at &lt;a href="https://github.com/anthropics/claude-code/issues/86854" rel="noopener noreferrer"&gt;anthropics/claude-code#86854&lt;/a&gt;, where the per-event cost numbers above are recorded for whoever fixes it.&lt;/p&gt;

&lt;p&gt;The middleware is sixteen kilobytes of code. Writing it was the short half of the job. The long half was learning where code has to sit before it counts as deployed — in the API actually in use, in every process that matters, invoked rather than merely loaded, in a file nothing regenerates, recreated by force whenever it changes. A fix doesn't exist when it's written. It exists when it's running.&lt;/p&gt;

&lt;p&gt;The story wasn't over, though. The next morning, the same box nearly went down under a load average of 38.7 — a separate-looking incident that turned out to be this one's own aftermath, twenty hours downstream. That's &lt;a href="https://dev.to/jeromefromhk/the-twenty-hour-fuse-4lcl"&gt;The Twenty-Hour Fuse&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>node</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Two Clocks, Neither Lying</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:14:21 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/two-clocks-neither-lying-1i8n</link>
      <guid>https://dev.to/jeromefromhk/two-clocks-neither-lying-1i8n</guid>
      <description>&lt;p&gt;The night of August 14th, Claude Code was planning me a trip. An eight-day itinerary — not code, just travel planning — running in the VS Code extension against the remote box where my sessions live. Just past midnight my phone buzzed: the turn had finished, and Claude was waiting for me to approve its plan. That buzz was &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;claude-code-notify&lt;/a&gt;, a hook I wrote myself so that a long turn, or one waiting on my input, wouldn't pass unnoticed — before it existed, nothing notified me at all. I saw the message and switched to the window. The session tab said the turn was still executing.&lt;/p&gt;

&lt;p&gt;Both indicators were right, and the hour between them was the bug. It just took a while to see how a session could be finished and not finished at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Late, not lost
&lt;/h2&gt;

&lt;p&gt;The extension wasn't frozen, and it wasn't empty. Every so often it nudged a little more text into view — and the text was old, stale by the time it reached the screen. It wasn't showing me nothing. It was showing me the past, slowly.&lt;/p&gt;

&lt;p&gt;That distinction did most of the diagnostic work before I touched anything. A lost handshake drops things: events go missing, the session looks truncated, the missing pieces never show up. A queue does the opposite: everything arrives, in order, late. What I was watching wasn't a failure to deliver — it was a delivery schedule. The events weren't lost; they were stacked up somewhere between the CLI and the screen, and something at the front of the stack moved slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the stalled session still knew
&lt;/h2&gt;

&lt;p&gt;The stalled session's process was still alive — asleep, not spinning. No CPU worth mentioning, zero network connections, zero child processes. A turn waiting on a model has a socket open; a turn running a command has children. This one had neither. It wasn't waiting on the internet and it wasn't waiting on a shell. It was waiting on something local, the way a program waits on a pipe.&lt;/p&gt;

&lt;p&gt;The session's own transcript said the same thing, more precisely. Claude Code journals every session as a file of JSON lines, and the last line of that night's was a complete assistant message — usage accounted for, stop reason recorded — ending in a tool call: the request for me to approve the plan it had drafted. After that line, nothing. No result, no close-out. The CLI had finished producing its question and never heard the turn end; it still believed the session was mid-flight. Even the tool-call id carried a small confession — its format was the third-party provider's, not Anthropic's, proof of which road the request had taken.&lt;/p&gt;

&lt;h2&gt;
  
  
  An alibi, timestamped
&lt;/h2&gt;

&lt;p&gt;I run Claude Code through claude-code-router on that box — ccr, a self-hosted router that lets me point the same workflow at the official Anthropic subscription or at third-party providers. That night it pointed at a Zhipu GLM coding plan. The router logs every request it forwards, so "did the model side actually finish?" had a direct answer: the turn's request completed in 1.7 seconds — HTTP 200, no retries, no credential throttling — and in the surrounding hour, 186 requests, all 200. Everything upstream of the CLI had finished long before I started asking questions.&lt;/p&gt;

&lt;p&gt;That pinned the stall to one segment: after the CLI, before the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  One variable left
&lt;/h2&gt;

&lt;p&gt;Which left a short list of suspects, and a matrix that cleared it in one pass. Terminal plus ccr: fine. Extension plus the official API: fine. Extension plus ccr: stuck. The two working combinations have nothing in common except that they both avoid the failing one — so the variable isn't the router and isn't the extension. It's what the stream looks like when the extension consumes what the router forwards.&lt;/p&gt;

&lt;p&gt;The router's log had captured response bodies that night, and each body told the story in two numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Logged request&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;th&gt;SSE delta events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;749&lt;/td&gt;
&lt;td&gt;6,400&lt;/td&gt;
&lt;td&gt;6,314&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;745&lt;/td&gt;
&lt;td&gt;3,822&lt;/td&gt;
&lt;td&gt;3,803&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;746&lt;/td&gt;
&lt;td&gt;2,877&lt;/td&gt;
&lt;td&gt;2,720&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nearly one streaming event per token, roughly 135 bytes each — 855 KB of server-sent events for a single response — and with thinking mode on, about 99% of them were thinking deltas. The official Anthropic API doesn't stream like that: it merges many tokens into each delta event, which puts its event rate one to two orders of magnitude lower. Same conversation, same renderer — wildly different event counts. (Those bodies are gone now; I've since turned capture down to failures only, so this table is a record of one night, not a measurement you could rerun on my box today.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue with a small mouth
&lt;/h2&gt;

&lt;p&gt;Now the mechanism. For every streaming event, the extension does three kinds of work: parse the event's JSON, post a message across the Remote-SSH tunnel into the webview, and re-render the conversation — the whole conversation, which on a long session is not cheap. None of it is slow per event. It's slow per six thousand events, and the provider side was producing them faster than the extension could burn them down.&lt;/p&gt;

&lt;p&gt;Between the CLI and the extension sits a plain stdout pipe, 64 KB by default. When the reader falls behind, the pipe fills, and then the writer blocks: the CLI's next write stalls until the extension drains some of the backlog. Everything the CLI tries to say next — including small control messages, like &lt;em&gt;I need you to approve this plan&lt;/em&gt; — queues behind tens of thousands of tiny deltas waiting their turn at the renderer. An approval prompt that was ready at 12:28 a.m. reaches the screen an hour later, not because anything crashed, but because it joined the back of a very long, very slow line.&lt;/p&gt;

&lt;p&gt;And that is why my phone was right. claude-code-notify hangs off the CLI's own hooks — the session lifecycle — not off the extension's rendering channel. The CLI finished its message and raised its approval request on schedule; the hook fired on time, straight to Telegram, without ever standing in the render queue. The two clocks weren't disagreeing about one quantity. One was reading the session. The other was reading a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither clock lied
&lt;/h2&gt;

&lt;p&gt;The notification reported the session: message finished, approval waiting — correct, at 12:28. The extension reported its render queue: thousands of events deep and moving slowly — also correct, about a different thing. What looked like a contradiction was two clocks measuring two different pipes, and the hour between them was just the drain time.&lt;/p&gt;

&lt;p&gt;There's a real design question upstream of my setup, though. A renderer this slow shouldn't be able to stall the protocol channel: stream events can be batched for display without making control messages wait behind them. Per-token granularity exposed it, but the coupling is the extension's. I've filed it upstream — &lt;a href="https://github.com/anthropics/claude-code/issues/86854" rel="noopener noreferrer"&gt;anthropics/claude-code#86854&lt;/a&gt; — with the event-granularity data.&lt;/p&gt;

&lt;p&gt;The fix, when it came, wasn't in the extension at all — it was a small middleware that merges those deltas back into chunks before the stream ever reaches the pipe. Getting it actually loaded took five attempts, four of them failures. That's the next piece.&lt;/p&gt;

&lt;p&gt;That fix is the subject of the next piece — &lt;a href="https://dev.to/jeromefromhk/coalescing-the-stream-j09"&gt;Coalescing the Stream&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>vscode</category>
      <category>debugging</category>
    </item>
    <item>
      <title>One Fact, Nowhere on the Machine</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:05:26 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/one-fact-nowhere-on-the-machine-5eoc</link>
      <guid>https://dev.to/jeromefromhk/one-fact-nowhere-on-the-machine-5eoc</guid>
      <description>&lt;p&gt;This started with a k3s install and not enough memory to do it in. I shut down another project's stack by hand, which freed up enough to get k3s running. k3s loads &lt;code&gt;br_netfilter&lt;/code&gt; when it starts, and once the module was in place a kernel switch that applies to the whole host changed state. Then I brought the stopped project back, and its containers had lost the ability to reach each other. That's its own story. This one is about what I tried next — an attempt that did nothing for that problem and quietly broke a different one.&lt;/p&gt;

&lt;p&gt;What I tried was a docker daemon restart: take the engine down, bring everything up clean, see whether the failure came back with it. It came back. The restart's real effect landed somewhere else entirely — every container on a different network, one I hadn't touched all day, came up holding an address it hadn't had before. Two of them matter here: the reverse proxy standing in front of every service on the box, and a proxy service standing in front of the reverse proxy. Neither had a static address pinned at the time, so each got whatever docker dealt it on the way up. That's where this story actually starts.&lt;/p&gt;

&lt;p&gt;That second proxy is worth explaining before anything else, because the whole investigation turns on it. Every service on the box sits behind the reverse proxy's access rule, which admits exactly two sources and denies everything else. The one exception is the proxy service itself — call it the gateway from here — which exists specifically as a security control: connect to it first, and it's the only thing standing between the public internet and everything else running on the box. That's not how the wiring happened to end up. It's what the wiring was built to do — turn a set of publicly reachable services into something that behaves like a private intranet, gated behind one deliberately unrestricted entry point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The allow-list, read right
&lt;/h2&gt;

&lt;p&gt;After the restart I reported the symptom as precisely as I could see it: almost everything behind the reverse proxy had gone unreachable, while the gateway's own entry point still answered fine. Was it the access rule, the server's address, or a container's address that had moved?&lt;/p&gt;

&lt;p&gt;The agent went straight to the reverse proxy's own configuration — the per-hostname files it writes one of for each gated service. Every one of them carried the same rule: allow an address on the shared docker network, allow the VPS's own public address, deny everything else. Read against the symptom, that rule explains all of it in one pass. Those two addresses are the only two ways in; the gateway's own front door carries no such rule at all. If either of the two allowed addresses had moved, everything gated goes dark while the entrance keeps answering exactly as it did.&lt;/p&gt;

&lt;p&gt;That's the right read, and it came fast — one config, one pattern repeated on every gated host, matched cleanly against a symptom that had more than one plausible cause before this. What it didn't yet answer was the identity of the two addresses inside the rule. That's not a gap in the diagnosis. It's the next question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The address that matched
&lt;/h2&gt;

&lt;p&gt;One of the two addresses was easy: the VPS's own public IP, &lt;code&gt;203.0.113.10&lt;/code&gt; — the address anything outside the docker network shows up as. The other was a docker-network address, &lt;code&gt;172.19.0.2&lt;/code&gt;, inside the same subnet the reverse proxy and the gateway both sit on. The natural next question was whose address that was, and there was a fast way to answer it: check what's actually running there right now.&lt;/p&gt;

&lt;p&gt;It checked. The reverse proxy's own container, at that moment, was sitting at exactly &lt;code&gt;172.19.0.2&lt;/code&gt;. Address in the config, address on the wire, same value. That's not a weak signal — a live process answering at the address in question is about as direct a confirmation as a check like that produces.&lt;/p&gt;

&lt;p&gt;Except the container's address wasn't fixed to anything. Nothing had pinned it there; it was wherever docker's own address allocation had put it after the restart that shuffled this whole network in the first place. The match wasn't the allow-list describing the reverse proxy. It was two independent things landing on the same value at the same moment — a rule that had been sitting there, and a container that had landed on that exact address by whatever the restart's allocation did with it. Nothing forced them to agree. They just did, right then, and there was no reason yet to ask why.&lt;/p&gt;

&lt;p&gt;There was no step left undone here in any obvious sense. Check the config, check what's live, they match — that's exactly the kind of confirmation a live system is supposed to give you. The process had a shape that quietly assumed the docker address named something, and it never had to ask what the address meant, only what currently answered there. Something always answers there. That question doesn't have a way to come back negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else got checked, and cleared
&lt;/h2&gt;

&lt;p&gt;The agent made two more calls during the same pass, in the other direction — flagged, then walked back, both times before either one turned into an action.&lt;/p&gt;

&lt;p&gt;The first was the VPS's own public egress address, spotted somewhere it hadn't been expected and read, on sight, as somebody's home or office connection — a plausible guess, since a bare public IP with no label attached could belong to anyone. Instead of accepting that, the agent checked: it asked a container on the host what its own outbound address looks like, then asked the host the same question, and got the identical value both times. Separately, the reverse proxy's own access log already had that exact address on file, showing up once a minute, every minute, as the source of a self-monitoring probe that had been running and been let through the whole time. An address that shows up once a minute forever isn't someone's laptop. It's the box talking to itself.&lt;/p&gt;

&lt;p&gt;The second was a running process carrying the name of a well-known security tool, with nothing in any of the host's package managers recording it as installed and no binary sitting at the path a real install of that tool would use. On those two facts alone, flagging it as a possible intrusion was the right call to make out loud — an unexplained process wearing that name is exactly what a compromise can look like. It didn't survive the next check. Resolving the process's &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/exe&lt;/code&gt; link led to an ordinary, already-installed script interpreter, and its parent process was the gateway application's own binary: the tool ships bundled inside the gateway software itself, which is also exactly why no package manager had ever heard of it.&lt;/p&gt;

&lt;p&gt;Two calls, two reversals, each one on a specific checkable reason rather than a change of heart. The habit of checking was doing exactly what it does throughout this investigation — catching a coincidence in one place, an unlabeled address in another, a suspicious name in a third, and running each one down before it became a decision. None of that checking was pointed at the one address that mattered. It didn't fail there. It was never aimed there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Written down looks decided
&lt;/h2&gt;

&lt;p&gt;The address match from two sections back didn't stay a working theory. Treating the docker-network address in the allow-list as the reverse proxy's own, the agent pinned the reverse proxy's compose file to that exact address — turning a coincidence into a static declaration — and reported the fix as applied.&lt;/p&gt;

&lt;p&gt;That's the expensive step, and it's expensive for a specific reason. Everything before it was reasoning, held loosely, revisable the moment new evidence showed up. A pin in a compose file isn't reasoning anymore. It's a written commitment, sitting in the same file that decides how the container actually starts, and it reads to anyone who looks at it afterward — including the agent itself, on the next question — as a decided fact rather than a step that happened to follow from an assumption nobody had checked. The report went out the same way: fixed, not probably fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  An answer with a number in it
&lt;/h2&gt;

&lt;p&gt;I read the fix and didn't accept it. Traffic through the gated services comes from the gateway — that's the reason the gateway exists in the shape it does — so the address pinned in the allow-list should be the gateway's, not the reverse proxy's. I wasn't guessing on the spot. I was remembering how I'd built the system in the first place and laying that against what the report actually said, and the mismatch was immediate: pinning the reverse proxy's own compose file to the reverse proxy's own address didn't match anything about how traffic through the gateway was supposed to arrive.&lt;/p&gt;

&lt;p&gt;The agent didn't concede the point. It tested it.&lt;/p&gt;

&lt;p&gt;A container already sitting on the shared docker network requested the public hostname directly, the same way any client on the internet would, and the reverse proxy's access log was read for what showed up as the source. The answer: the VPS's own public address, not the container's own docker-network address. Whatever a container on that network sends toward the public domain, the reverse proxy sees it arrive from outside — which meant pinning the gateway's specific address, on top of what was already pinned, shouldn't change anything either way.&lt;/p&gt;

&lt;p&gt;That's a real result. The container really did hit the real hostname, the log really did show the public address, and nothing about the number was wrong. It answered a question — does traffic to the public domain from an arbitrary container on this network show up carrying that container's own address? — cleanly, and the answer was no. What it didn't answer, because nothing about the test asked it to, was whether the gateway's own traffic took that same path at all. A generic container resolving a public hostname the ordinary way, and the gateway forwarding through whatever its own proxy engine does with that hostname, are two different roads that happen to look identical from a compose file. The test measured the first one and reported on the second.&lt;/p&gt;

&lt;p&gt;It read as the stronger answer of the two, not the weaker one. My objection was a sentence about how the system was designed to work. The agent's reply was a live measurement, run and read back on the spot. Between an argument from memory and a number from a test just run, the number wins the room every time — right up until someone checks what the number was actually a number of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fact that wasn't on the machine
&lt;/h2&gt;

&lt;p&gt;I supplied the piece that neither the config nor the test could produce: the allow-list hadn't always carried two addresses. When I first wrote it, it carried one — the docker-network address — and that was enough. It worked.&lt;/p&gt;

&lt;p&gt;Lay that fact next to the agent's test and the test breaks. If a request from anywhere on the docker network to the public hostname always arrives at the reverse proxy carrying the VPS's own public address, no exceptions — the test had just shown exactly that — then an allow-list with only the docker-network address on it, and nothing granting the public one, could never have let a single request through. Not once. But it had. For however long the allow-list ran with just that one line, something was reaching the reverse proxy from inside the docker network, under its own docker-network address, without ever picking up the public one on the way. The test said that path didn't exist. The system's own history said it always had.&lt;/p&gt;

&lt;p&gt;There's no config file this comes from, no log line, nothing on the box today that records what the allow-list looked like before the second address went in. It isn't derivable from the server's current state — the state has already moved past it, twice over, once when the address was added and again when the restart reshuffled everything on top of that. It exists in exactly one place: my memory of building the thing. And it's what turns the investigation around — not a new check run against the server, but an old fact about the server that the server itself was never going to hand back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DNS override, and the address it always pointed at
&lt;/h2&gt;

&lt;p&gt;The agent read the proxy engine's own configuration directly — not inferring from what a generic client did, but reading what the gateway's own software was actually told to do — and that settled it. The proxy engine carries a DNS override for the entire domain: every hostname under it resolves, inside the gateway, not to whatever the internet's DNS would return, but straight to a fixed address on the shared docker network.&lt;/p&gt;

&lt;p&gt;That's the road the generic test container never took. An ordinary client resolves the public hostname through public DNS and lands on the VPS's public address, same as the reverse proxy expects from anyone outside. The gateway, for that one domain, never asks public DNS at all — the override answers first, and the request goes straight to a docker-network address without leaving the host. A connection that never leaves the host is never rewritten to look like it came from the host's own public interface. It arrives at the reverse proxy carrying the gateway's real docker-network address, not the VPS's public one — which is exactly the address the allow-list's other line was written for, and exactly what a request from an arbitrary container, going the long way through public DNS, was never going to produce.&lt;/p&gt;

&lt;p&gt;The other half of the fault was on record from the start of this: neither the reverse proxy's container nor the gateway's had a static address pinned anywhere. The docker daemon restart that kicked this whole thing off reassigned both, along with everything else on that network, to whatever addresses happened to be free at the time — and a third container, unrelated to either of them, ended up sitting on the one address the proxy engine's override had always pointed to. That's what actually broke: not the allow-list, not the reverse proxy, not the override itself, but which container each address named on any given restart.&lt;/p&gt;

&lt;p&gt;The fix pinned both: the reverse proxy to &lt;code&gt;172.19.0.3&lt;/code&gt;, the gateway to &lt;code&gt;172.19.0.2&lt;/code&gt;, each written into its own compose file. The third container moved off the address the override needed and onto a free one. Three containers restarted to pick up the change. Not the daemon — restarting that again is how this started.&lt;/p&gt;

&lt;p&gt;Verifying it didn't call for trusting the compose files. It called for reproducing the actual path, so the agent did: from inside the gateway container, it issued a request following the same forwarding the proxy engine's override would take — straight to the reverse proxy's pinned docker-network address, bypassing public DNS the way the gateway itself does. The request succeeded, and the reverse proxy's own access log recorded the source as &lt;code&gt;172.19.0.2&lt;/code&gt; — the gateway's own address, logged by the one component in the chain that was never asked to guess what it meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nowhere in writing
&lt;/h2&gt;

&lt;p&gt;None of this — the gateway as the deliberate front door, the docker-network address in the allow-list as always meant for it, the allow-list's own history before the public address got added — existed in any file on the box. Not in a compose file, not in a comment, not in the proxy engine's own configuration, which describes what it does but not why. The agent had the server's current state and nothing else to check it against, and current state doesn't distinguish a coincidence from a decision. Two things landing on the same address at the same moment reads identically to one thing being pinned there on purpose, right up until someone who was there says otherwise.&lt;/p&gt;

&lt;p&gt;That's what made the wrong pin possible, and it's also what nearly made the wrong pin permanent. The agent's own test wasn't sloppy — it measured something real and reported it accurately. What turned the investigation was me declining an answer I had reason to doubt, on grounds the agent's evidence couldn't reach, and then handing over the one fact that reached back further than any log on the machine: what the allow-list looked like before there was a reason to add a second line to it.&lt;/p&gt;

&lt;p&gt;The compose files carry the pin now. The design intent behind it — the reason &lt;code&gt;172.19.0.2&lt;/code&gt; belongs to the gateway and not to whatever last happened to sit there — exists in exactly one place outside my own memory: a page of reference notes about this host. It wasn't there when any of this started. The record came after the repair, because the repair is what produced it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>debugging</category>
      <category>docker</category>
    </item>
    <item>
      <title>Nothing Changed, Except One Kernel Module</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:05:17 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/nothing-changed-except-one-kernel-module-4n6b</link>
      <guid>https://dev.to/jeromefromhk/nothing-changed-except-one-kernel-module-4n6b</guid>
      <description>&lt;p&gt;Memory was tight on the box, so I stopped another project by hand to make room for a k3s install. The install pulled in &lt;code&gt;br_netfilter&lt;/code&gt;, which flipped a kernel switch host-wide. The project I'd stopped came back up — and its containers couldn't reach each other anymore.&lt;/p&gt;

&lt;p&gt;My first move was restarting the docker daemon, on the theory that bringing everything back up fresh would shake the problem loose. It didn't touch this one. It did reassign every container IP on a second, unrelated network, which left me with an allow-list somewhere no longer admitting the source it had been written to admit. That's its own story. This one is about the first failure, and it turned out to have nothing to do with the daemon restart at all.&lt;/p&gt;

&lt;p&gt;The symptom held up under testing. No container on that bridge could reach any other — I checked several pairs, and they all failed the same way. Every attempt just ran until it timed out; nothing came back refused. A refused connection means a socket answered and said no. A timeout means the packet went somewhere and nothing came back, which puts the fault in the network layer, not in whatever was supposed to be listening on the other end. A different stack on the same host, on a different bridge, kept working the whole time. Whatever this was, it wasn't host-wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only change
&lt;/h2&gt;

&lt;p&gt;Only one thing had actually changed on the host that day: the k3s install, and specifically what it did to the kernel underneath it. k3s's own systemd unit runs &lt;code&gt;ExecStartPre=-/sbin/modprobe br_netfilter&lt;/code&gt; — from &lt;code&gt;/etc/systemd/system/k3s.service&lt;/code&gt; — before the service itself starts. That's k3s loading the module as shipped, not a step I added.&lt;/p&gt;

&lt;p&gt;Loading the module isn't what matters on its own. What matters is what comes with it: &lt;code&gt;br_netfilter&lt;/code&gt; brings &lt;code&gt;net.bridge.bridge-nf-call-iptables&lt;/code&gt; into play, and somewhere in that install, the switch flipped — from &lt;code&gt;0&lt;/code&gt; to &lt;code&gt;1&lt;/code&gt;, host-wide. That sysctl decides whether traffic that never leaves a bridge gets shown to iptables at all. Two containers on the same bridge talking to each other is bridge-internal traffic in the strict sense — packets forwarded between ports of the same bridge, no routing involved. At &lt;code&gt;0&lt;/code&gt;, the kernel never submits that traffic to iptables; nothing evaluates it. At &lt;code&gt;1&lt;/code&gt;, it does — the same chains that judge traffic arriving on a real interface now get asked to judge this too.&lt;/p&gt;

&lt;p&gt;Nothing about the containers changed, and nothing about the project I'd stopped and brought back up changed either. What changed is which packets iptables now gets a say over. k3s did that on purpose: loading the module and turning on that sysctl is baked into how it starts, on any host it's installed to. It isn't a step aimed at this box specifically, and it isn't a step k3s skipped or fumbled. The install did exactly what it was built to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flipping the switch, and what it doesn't prove
&lt;/h2&gt;

&lt;p&gt;I tested the sysctl directly that day. &lt;code&gt;net.bridge.bridge-nf-call-iptables&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt;, and the containers could reach each other again — immediately, no restart, no wait. Back to &lt;code&gt;1&lt;/code&gt;, and they stopped again, just as fast. I flipped it a second time to be sure. Same result both directions.&lt;/p&gt;

&lt;p&gt;That's confirmation, not a diagnosis. All it shows is that whatever's dropping this traffic sits downstream of that switch — not what that thing is, and not why it's dropping these particular packets between these particular containers instead of every packet crossing the bridge. &lt;strong&gt;This identifies the mechanism, not the rule.&lt;/strong&gt; And leaving the sysctl at &lt;code&gt;0&lt;/code&gt; was never a real fix — the switch is host-wide, and k3s's unit sets it unconditionally every time it starts, so leaving it at &lt;code&gt;0&lt;/code&gt; only holds until the next restart flips it back. It isn't a fix scoped to whatever's actually dropping these packets. From here the question splits in two: something inside iptables is dropping this traffic, and nothing I'd tested so far said which table, which chain, or which rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling out everything else
&lt;/h2&gt;

&lt;p&gt;Bridge port STP state first — a port stuck outside &lt;code&gt;forwarding&lt;/code&gt; would produce exactly this symptom on its own, no iptables involved. &lt;code&gt;bridge link show&lt;/code&gt; on both ports: &lt;code&gt;forwarding&lt;/code&gt;, &lt;code&gt;forwarding&lt;/code&gt;. Not that.&lt;/p&gt;

&lt;p&gt;tc had no filters attached to either interface, so there was nothing to even check a counter on; XDP had no program loaded on either side either. nftables' bridge family — the modern interface to the same hooks ebtables covers — had no tables defined at all, and ebtables itself came back with an empty ruleset. Last, the ARP cache, because a stale entry would produce this exact kind of silent black hole without touching anything above it: the destination MAC each sending container had cached matched the target container's actual, current MAC.&lt;/p&gt;

&lt;p&gt;Five places checked, five negatives. Whatever was eating this traffic wasn't sitting in any of the spots that usually catch a problem at the bridge layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What conntrack didn't have
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;conntrack&lt;/code&gt; wasn't on this box to begin with — I installed it mid-investigation, specifically to watch this flow live.&lt;/p&gt;

&lt;p&gt;It had nothing to show me. Not a dropped entry, not a half-open one — no entry, for a flow I could reproduce on demand just by opening a connection between the two containers. A conntrack entry gets created the moment a new flow is first evaluated, win or lose. If there's no entry at all, connection tracking never got a look at this traffic.&lt;/p&gt;

&lt;p&gt;That points at exactly one place. In the IPv4 &lt;code&gt;PREROUTING&lt;/code&gt; hook, netfilter runs &lt;code&gt;raw&lt;/code&gt; at priority &lt;code&gt;-300&lt;/code&gt; and connection tracking at &lt;code&gt;-200&lt;/code&gt;, and &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks" rel="noopener noreferrer"&gt;hooks fire in increasing priority order&lt;/a&gt; — &lt;code&gt;raw&lt;/code&gt; always goes first. A packet dropped in &lt;code&gt;raw&lt;/code&gt; never reaches conntrack at all, which is exactly the shape of what I wasn't seeing. One table left to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw table
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;iptables -t raw -L PREROUTING -n -v -x --line-numbers&lt;/code&gt; turned up two matched sets of rules, seven lines each, keyed to the same seven destination addresses — &lt;code&gt;172.18.0.2&lt;/code&gt; through &lt;code&gt;172.18.0.8&lt;/code&gt;. Each set named a different bridge interface. One bridge was live; the other was stale, already deleted.&lt;/p&gt;

&lt;p&gt;The live set's shape, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-d&lt;/span&gt; 172.18.0.3/32 &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; br-b951f3fb0958 &lt;span class="nt"&gt;-j&lt;/span&gt; DROP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DROP&lt;/code&gt;, gated on a &lt;code&gt;/32&lt;/code&gt; destination match and a &lt;em&gt;negated&lt;/em&gt; input-interface match. dockerd puts one of these on every container IP itself, and what it's enforcing is anti-spoofing: drop traffic for this address unless it arrived on the bridge that's supposed to own it. &lt;code&gt;! -i br-b951f3fb0958&lt;/code&gt; reads as "drop unless it came in on &lt;code&gt;br-b951f3fb0958&lt;/code&gt;" — a real exception, as long as &lt;code&gt;br-b951f3fb0958&lt;/code&gt; exists. The stale set carried the identical logic, just naming &lt;code&gt;br-66885a1f7aad&lt;/code&gt; in place of &lt;code&gt;br-b951f3fb0958&lt;/code&gt; — and &lt;code&gt;br-66885a1f7aad&lt;/code&gt; no longer existed. So there was no interface left for a packet to arrive on that could ever satisfy that stale set's exception. The condition could never be true. A rule that reads as conditional is, in practice, unconditional — every packet for those seven addresses, arriving from anywhere, got dropped by the stale set regardless of where it actually came from.&lt;/p&gt;

&lt;p&gt;The counters said it before I'd finished reasoning through it. The rules pinned to the deleted bridge were already past 4,967 hits that day, and still climbing. The rules pinned to the live bridge — the correct rules, the ones actually describing the network as it existed — sat at zero. That's not reassuring. iptables walks a chain top to bottom and stops at the first match, and the stale rules came first in the chain. Zero hits on the correct rules didn't mean they were fine. It meant they'd never been reached at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whose bug this is
&lt;/h2&gt;

&lt;p&gt;k3s loaded &lt;code&gt;br_netfilter&lt;/code&gt; and flipped the sysctl because that's what it does on every host it installs to — already established, not up for reargument. The seven rules keyed to a bridge that no longer existed are a separate question, and the answer isn't k3s.&lt;/p&gt;

&lt;p&gt;dockerd wrote those rules. This host runs Docker Engine 29.6.0, and since Docker Engine 28.0 (2025) — &lt;a href="https://www.docker.com/blog/docker-engine-28-hardening-container-networking-by-default/" rel="noopener noreferrer"&gt;a deliberate hardening change&lt;/a&gt;, not a bug — dockerd drops unsolicited inbound traffic to a container's internal IP by default unless the port is explicitly published. The raw-table rules from the previous section are that change's implementation. The blog post doesn't get me that far on its own — it frames the change around unpublished ports and never names a table. What names it is &lt;a href="https://github.com/moby/moby/pull/49621" rel="noopener noreferrer"&gt;moby#49621&lt;/a&gt;, which added &lt;code&gt;DOCKER_INSECURE_NO_IPTABLES_RAW=1&lt;/code&gt; as an opt-out for kernels built without &lt;code&gt;CONFIG_IP_NF_RAW&lt;/code&gt;: an escape hatch that only makes sense if the rules were going into &lt;code&gt;raw&lt;/code&gt; in the first place.&lt;/p&gt;

&lt;p&gt;At some point before this incident, the network these seven rules belonged to went down and came back up, and dockerd built a new bridge to replace the old one — the live &lt;code&gt;br-b951f3fb0958&lt;/code&gt; standing in for the dead &lt;code&gt;br-66885a1f7aad&lt;/code&gt;. The new bridge got its own seven rules. The old bridge's seven didn't go anywhere. On this host, nothing removed those rules when the interface they named stopped existing.&lt;/p&gt;

&lt;p&gt;I looked for whether that specific gap is a known, filed defect, and I didn't find one. The closest matches are &lt;a href="https://github.com/moby/libnetwork/issues/570" rel="noopener noreferrer"&gt;moby/libnetwork#570&lt;/a&gt; — stale &lt;code&gt;FORWARD&lt;/code&gt;-chain rules left behind after a bridge is removed — and a &lt;a href="https://github.com/docker/cli/issues/5646" rel="noopener noreferrer"&gt;docker/cli report&lt;/a&gt; about &lt;code&gt;network rm&lt;/code&gt; corrupting a different network's rules. Different chain in one case, different trigger in the other; neither is this. What I can say is what happened on this host on 2026-08-06. I can't call it a tracked bug, and I'm not hanging a version range on one.&lt;/p&gt;

&lt;p&gt;And it sat there doing nothing for the same reason established at the start: at &lt;code&gt;bridge-nf-call-iptables=0&lt;/code&gt;, bridge-internal traffic never reaches iptables at all, live rule or stale one. The seven orphaned rules weren't evaluated, weren't skipped, weren't anything — they just weren't asked. k3s didn't write this bug. It was the first thing on this box to turn the light on in the room where it had been sitting the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven deletions, and the check that would have caught it earlier
&lt;/h2&gt;

&lt;p&gt;The fix is narrower than the last six sections might suggest: delete the seven rules pinned to &lt;code&gt;br-66885a1f7aad&lt;/code&gt;, and leave everything else in the raw table exactly as it is — including &lt;code&gt;bridge-nf-call-iptables&lt;/code&gt;, which stays at &lt;code&gt;1&lt;/code&gt;. k3s and its CNI need that sysctl on. Turning it back off doesn't fix this; it just re-hides it, and takes bridge-internal traffic on every other network on the box off iptables' radar along with it. That's not a fix, that's the same outage wearing a different cause.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iptables -t raw -D PREROUTING &amp;lt;line&amp;gt;&lt;/code&gt;, seven times, one call per stale rule, in descending order of line number. Delete low-to-high and every deletion renumbers the lines below it — the seventh line you meant to hit has already moved by the time you get to it. High-to-low, each deletion only touches numbers you've already dealt with, so the number you read off &lt;code&gt;--line-numbers&lt;/code&gt; is still the number that's true when you use it.&lt;/p&gt;

&lt;p&gt;Checked again on 2026-08-06, the fix held: &lt;code&gt;iptables -t raw -S PREROUTING&lt;/code&gt; on this host shows exactly seven &lt;code&gt;DROP&lt;/code&gt; rules for &lt;code&gt;172.18.0.2&lt;/code&gt; through &lt;code&gt;172.18.0.8&lt;/code&gt; — all seven addresses, none missing — every one of them naming the live &lt;code&gt;br-b951f3fb0958&lt;/code&gt;. Nothing in the table names &lt;code&gt;br-66885a1f7aad&lt;/code&gt; anymore.&lt;/p&gt;

&lt;p&gt;That closes the one incident. It doesn't close the bug, because the bug isn't scoped to that bridge or that day — the same orphaned rule can turn up on any other network on this host, for as long as nothing on this host cleans up a stale rule when a bridge gets recreated. The check that catches it is the fix run in reverse: take every interface name the raw table's &lt;code&gt;PREROUTING&lt;/code&gt; chain references, and confirm each one still exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; raw &lt;span class="nt"&gt;-S&lt;/span&gt; PREROUTING | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'(?&amp;lt;=-i )\S+'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;bridge | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'^\d+: \K[^:@]+'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that prints is a rule pointed at an interface that isn't there — the same bug, on some other bridge, waiting for whatever eventually submits its traffic to iptables. Run against this host on 2026-08-06, it prints nothing: four bridge names in the raw table, all four listed in &lt;code&gt;ip link show type bridge&lt;/code&gt;, none orphaned.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>kubernetes</category>
      <category>iptables</category>
      <category>networking</category>
    </item>
    <item>
      <title>Nineteen Subdomains, One Allowed IP</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Mon, 03 Aug 2026 23:09:11 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/nineteen-subdomains-one-allowed-ip-3fk5</link>
      <guid>https://dev.to/jeromefromhk/nineteen-subdomains-one-allowed-ip-3fk5</guid>
      <description>&lt;p&gt;One DNS record covers the whole box: &lt;code&gt;*.example.com&lt;/code&gt; → &lt;code&gt;203.0.113.10&lt;/code&gt;. I set it once, and publishing a service has not involved DNS since. I pick a name, and the name already resolves.&lt;/p&gt;

&lt;p&gt;What's left is a form in Nginx Proxy Manager (NPM) with four fields that matter: the domain, the container name, the container port, and a checkbox that requests a certificate. That is the whole act of publishing. Fill it in, save, and the hostname is serving HTTPS from a container that was never exposed to anything.&lt;/p&gt;

&lt;p&gt;Once publishing is a form, it is mechanisable. I stopped filling it in by hand a while ago — I describe the service, Claude Code takes it end to end (compose file, network, proxy host, certificate), and hands me back the URL.&lt;/p&gt;

&lt;p&gt;That is how I ended up with nineteen proxy hosts. Nineteen public hostnames, all of them off-the-shelf self-hosted software, each with its own idea of what authentication means: some have real user accounts, some a single shared password, some a setup wizard that never asked, some nothing at all.&lt;/p&gt;

&lt;p&gt;The ease is the problem. Nothing in that form makes you decide who is allowed to reach the thing you just published.&lt;/p&gt;

&lt;p&gt;What I actually wanted wasn't to keep any of it off the internet — that stopped being an option the moment the wildcard resolved. I wanted all nineteen of them reachable from exactly one place: a private network, built entirely out of public infrastructure, where being on the internet and being reachable are two different facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the wildcard buys, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The wildcard is a DNS record, not a certificate. Those two get conflated constantly, and the difference decides what the firewall looks like permanently.&lt;/p&gt;

&lt;p&gt;The record is real work removed. &lt;code&gt;*.example.com&lt;/code&gt; points at the VPS, so every hostname I will ever invent already resolves; there is no propagation wait between deciding on a name and using it.&lt;/p&gt;

&lt;p&gt;The certificates are not wildcard. NPM issues them per hostname over HTTP-01, so for each of those nineteen names Let's Encrypt makes an inbound request to &lt;code&gt;http://name.example.com/.well-known/acme-challenge/...&lt;/code&gt; and expects nginx to answer on port 80. Nineteen certificates, nineteen challenges — and since they last about ninety days, the renewal timer re-runs the same challenge against every one of them, forever.&lt;/p&gt;

&lt;p&gt;So port 80 can never close. Not "should probably stay open" — cannot close, on either of the two firewall layers this host has. And the failure mode is silent: closing 80 breaks nothing you can observe. Sites keep serving on 443 with the certificates they already have, renewals fail into a log nobody reads, and the outage lands ninety days later as every hostname going untrusted at once.&lt;/p&gt;

&lt;p&gt;DNS-01 would buy a genuine wildcard certificate and let 80 close, at the price of giving the proxy write credentials to my DNS zone. I didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  One network, and why the proxy finds services by name
&lt;/h2&gt;

&lt;p&gt;The network gets created once, by hand — &lt;code&gt;docker network create proxy&lt;/code&gt; — and every compose file after that declares it as something it did not make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;trilium&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# this is what attaches the container&lt;/span&gt;
&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;         &lt;span class="c1"&gt;# this only says where the network comes from&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two blocks look like one setting written twice. They aren't. Leave out the one under the service and the file is still valid, the network still resolves, and nothing is on it.&lt;/p&gt;

&lt;p&gt;With both in place, the proxy host form takes a container name: forward hostname &lt;code&gt;trilium&lt;/code&gt;, forward port &lt;code&gt;8080&lt;/code&gt;. Docker's embedded DNS resolves the name across the shared network, and &lt;code&gt;8080&lt;/code&gt; is reachable inside that network and nowhere else. None of the containers nginx proxies to publishes a port to the host at all.&lt;/p&gt;

&lt;p&gt;That is the part that is easy to half-do. Joining the shared network does not reclaim a published host port. They are two separate acts, and doing only the first adds a road without removing one.&lt;/p&gt;

&lt;p&gt;nginx serves its own admin panel the same way, by proxying &lt;code&gt;127.0.0.1:81&lt;/code&gt;, so 81 is never published either — the entrance shouldn't have a second door. You will be told this can't work — that inside the container &lt;code&gt;127.0.0.1&lt;/code&gt; is the container, so it loops and 502s. The premise is right; the conclusion isn't. One nginx process serves every virtual host, and handing a request from the 443 listener to the 81 listener is a hop, not recursion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;npm curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; http://127.0.0.1:81/
&lt;span class="c"&gt;# 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real loops come from forwarding a host to its own hostname, or 443 to 443.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gateway, and the coincidence it runs on
&lt;/h2&gt;

&lt;p&gt;One more container sits on that network: a self-hosted proxy gateway, on the same VPS as everything else.&lt;/p&gt;

&lt;p&gt;Only one thing about it matters here, and it's where its traffic comes out. Point a device at the gateway and that device's requests leave the VPS through the VPS's public interface and come back to &lt;code&gt;:443&lt;/code&gt; on the same box. To nginx there is nothing exotic about them — ordinary inbound HTTPS off the public internet — and the client address it writes to the log is &lt;code&gt;203.0.113.10&lt;/code&gt;. The VPS's own.&lt;/p&gt;

&lt;p&gt;I'd seen that address in that column before, and I'd been wrong about it. I was chasing intermittent connection drops at the time, and the failing requests logged a client IP identical to the server's public address. It looked like a routing fault: something on the box talking to itself, traffic eating its own tail. I filed it as a loop to untangle later.&lt;/p&gt;

&lt;p&gt;It wasn't a fault. It was the gateway, doing what a gateway does. The log had been telling me the truth the whole time, in a shape I didn't recognise.&lt;/p&gt;

&lt;p&gt;The loop I tried to route around is what the whole design stands on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rule actually says
&lt;/h2&gt;

&lt;p&gt;Every hostname that gets gated gets the same block, written by NPM into that proxy host's config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;auth_basic&lt;/span&gt;            &lt;span class="s"&gt;"Authorization&lt;/span&gt; &lt;span class="s"&gt;required"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;auth_basic_user_file&lt;/span&gt;  &lt;span class="n"&gt;/data/access/2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;172.19&lt;/span&gt;&lt;span class="s"&gt;.0.2/32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# the gateway container, on the shared docker network&lt;/span&gt;
  &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;203.0&lt;/span&gt;&lt;span class="s"&gt;.113.10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;# the VPS's own public address&lt;/span&gt;
  &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;satisfy&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;allow&lt;/code&gt; lines are an OR. A request passes the address check by matching either one; everything else falls to &lt;code&gt;deny all&lt;/code&gt; and gets a 403 before nginx opens a connection to the upstream at all.&lt;/p&gt;

&lt;p&gt;They both have to be there because the same request can arrive from either side of the box. A container on the shared network connects to nginx across that network, and the client address nginx records is that container's Docker address. Anything not on that network comes in through the host's public interface, and nginx records the VPS's public address — including the gateway's, which left the box and came back. Two paths in, two addresses in the log, one rule that has to cover both. Neither line is a spare.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;satisfy all&lt;/code&gt; is not part of that. It's a different axis: it makes the address check &lt;em&gt;and&lt;/em&gt; basic auth both mandatory. (&lt;code&gt;satisfy any&lt;/code&gt; — the one people pick by accident — would make either alone sufficient.) So an allowed address buys you a password prompt, not the service.&lt;/p&gt;

&lt;p&gt;That's the design, stated in one line: nineteen public hostnames, all of them resolving, all of them presenting a valid certificate to anyone who connects — and none of that decides who gets in. The two &lt;code&gt;allow&lt;/code&gt; lines and the password after them do. A public DNS record and a public certificate get a stranger exactly as far as a login prompt they can't clear. That's what the wildcard actually bought me: not privacy for the DNS, not secrecy for the addresses, but a private network assembled entirely out of public infrastructure — an intranet where every door is a public IP with one line of nginx behind it.&lt;/p&gt;

&lt;p&gt;One path escapes all of it. The rule is scoped to &lt;code&gt;location /&lt;/code&gt;, and NPM includes a separate location that opts out on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;^~&lt;/span&gt; &lt;span class="n"&gt;/.well-known/acme-challenge/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;auth_basic&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;auth_request&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's Encrypt publishes no IP ranges for its validation infrastructure, so there is nothing to allow-list — the challenge path stays open to the entire internet or the renewals stop. Same fact as port 80, wearing different clothes: a deliberate, documented hole that is the correct call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two hosts that can't be gated
&lt;/h2&gt;

&lt;p&gt;Seventeen of the nineteen proxy hosts carry that block. The two that don't are the gateway's own admin panel and the endpoint that hands out client configs.&lt;/p&gt;

&lt;p&gt;They can't carry it. The rule assumes the gateway is already carrying you — that's what puts an allowed address in the log. Reaching the panel means getting in before that's true, and setting up a new device means fetching its config from a device that isn't configured yet. You can't put the key inside the lock.&lt;/p&gt;

&lt;p&gt;So those two sit on their own authentication and nothing else: a login form on the public internet, with whatever password I gave it and whatever rate-limiting the software ships with. Every other hostname on the box is behind two independent checks. These two are behind one, and I'd rather name that than pretend the wildcard covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proxy broke: services that guess their own address
&lt;/h2&gt;

&lt;p&gt;Putting everything behind one nginx broke a service that had run untouched for a year. Not the gateway, and not either of the ungated two — this one carries the full block now; it didn't then, and this is why. It issues each user a connection profile with a server address baked into it.&lt;/p&gt;

&lt;p&gt;It failed in stages: one person, then two, then everyone who asked.&lt;/p&gt;

&lt;p&gt;Everything the box could tell me was green: container up, credentials matching, nothing banned, a clean log. Curling the service's hostname from the server returned 200.&lt;/p&gt;

&lt;p&gt;So I formed a theory, and it was a good one: a firewall rule had been deleted. I'd spent that afternoon in the cloud console editing security-list rules. Healthy inside, dead outside is the shape of a packet filter dropping traffic before it lands.&lt;/p&gt;

&lt;p&gt;Instead of reasoning further, I asked someone who was failing to send me what their client actually said.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connect: connection refused&lt;/code&gt;, against &lt;code&gt;198.51.100.20&lt;/code&gt;. Not my server. That killed the theory twice over — a filter that drops packets gives you a timeout, not a refusal, and the client wasn't trying to reach my server at all. A reverse lookup landed on my own ISP's egress: my home connection, that morning.&lt;/p&gt;

&lt;p&gt;nginx was doing its job: it sets &lt;code&gt;X-Forwarded-For&lt;/code&gt; to the real client address. The service downstream read that header as &lt;em&gt;its own&lt;/em&gt; public address — the one it writes into every config. So the address it advertised was whoever had asked last. My laptop had pulled a config from home that morning; after that, the service told everyone to connect to my house. The field that pins the advertised address was right there in the settings, empty.&lt;/p&gt;

&lt;p&gt;The neighbouring mistake is easy: the port it listens on inside its container and the address it advertises outside sit next to each other in one form, and the first instinct is to change the wrong one. That code path had been unreachable since install — clients connected straight to the box, no header to misread.&lt;/p&gt;

&lt;p&gt;And it hid for days because of where I stood to test. My own client goes out through the gateway, so every config I fetched for myself came back carrying &lt;code&gt;203.0.113.10&lt;/code&gt; — exactly what it was supposed to carry. It read correct every time. The address that makes the access rule work is the address that hid this bug: one fact doing both jobs, read from the wrong side twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the encryption stops
&lt;/h2&gt;

&lt;p&gt;The certificate lives on nginx and nowhere else. TLS terminates there; from nginx to the container the request travels as plaintext HTTP across the Docker network. That is why the Scheme field on every proxy host reads &lt;code&gt;http&lt;/code&gt;, and why setting it to &lt;code&gt;https&lt;/code&gt; returns a 502 immediately instead of hanging — nothing on the backend port is listening with TLS.&lt;/p&gt;

&lt;p&gt;Fine in itself, since that network is published to nothing. But it decides where the access check has to live. Past nginx the client is gone: the container sees a connection from a Docker address on its own network, and everything else it knows about the caller arrives as a header. nginx is the last point that still has the client's address from the socket rather than from a string. The rule goes there, or it goes nowhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;Two allowed addresses is not zero-trust. Anything that can source traffic from either address is inside. Everyone else who uses the gateway arrives as &lt;code&gt;203.0.113.10&lt;/code&gt;, same as me; so does every process on the box. The rule doesn't identify a caller — it says the request came in by an expected road.&lt;/p&gt;

&lt;p&gt;Behind it is basic auth, which is a password. One shared string, no second factor, nothing rate-limiting guesses. Two checks beat one because they fail independently, not because either is strong.&lt;/p&gt;

&lt;p&gt;The services still trust nginx's headers without question — the same unconditional trust that spent days advertising my house. The rule doesn't fix that. It shrinks the audience for it.&lt;/p&gt;

&lt;p&gt;Port 80 stays open permanently, and the challenge path under it stays open to everyone.&lt;/p&gt;

&lt;p&gt;If the gateway dies, so does my access to seventeen hostnames. SSH is the way back — which is why the rule is nginx answering 403 rather than a firewall dropping packets. A 403 lives in a config file on the host; the door I need during an outage can't be the one I just shut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The address in both columns
&lt;/h2&gt;

&lt;p&gt;Nineteen hostnames gated by one line, and what holds it up is an accident of topology: traffic that leaves this box and comes back is indistinguishable from traffic that was never here. I get to allow-list a single address because the machine vouches for me to itself.&lt;/p&gt;

&lt;p&gt;The cost is that I debug from the position the rule rewards. Whatever I check, I check while already holding the credential, so it checks out. It took someone else's error message to show me a view I couldn't produce.&lt;/p&gt;

&lt;p&gt;The cheapest access control on this box and the bug that hid longest on it are the same string in the same column of the same log. I'd take the trade again. I'd just stop testing from inside the allow-list.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>docker</category>
      <category>nginx</category>
      <category>security</category>
    </item>
    <item>
      <title>236 Tests Passed. The Release Was Still Broken.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:31:18 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/236-tests-passed-the-release-was-still-broken-5j8</link>
      <guid>https://dev.to/jeromefromhk/236-tests-passed-the-release-was-still-broken-5j8</guid>
      <description>&lt;p&gt;I fixed a small timezone bug, ran the suite, and watched all 236 tests go green. Out of habit more than doubt, I pushed to CI before tagging the release. CI went red — and then it went redder. That one small fix had two more bugs stacked underneath it, and every one of them was invisible on the machine where I'd just watched the tests pass.&lt;/p&gt;

&lt;p&gt;The setting is a tool I maintain called claude-code-notify: it pings me when Claude Code hits a usage limit, and again when the limit resets. You don't need to know the tool. The bug is an ordinary timezone bug, and what it cost me is a lesson about tests — about how a completely green local suite can be lying to your face.&lt;/p&gt;

&lt;p&gt;The original bug lived in the part that computes &lt;em&gt;when&lt;/em&gt; the limit resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right only by coincidence
&lt;/h2&gt;

&lt;p&gt;When Claude Code tells you a limit will reset, it writes the time into the message as plain text, with the zone in parentheses: &lt;code&gt;resets 5:20am (Asia/Hong_Kong)&lt;/code&gt;. The function that read that time, &lt;code&gt;parse_reset()&lt;/code&gt;, pulled out the hour and minute and did all the arithmetic in the host machine's local time. It never looked at the zone in the parentheses.&lt;/p&gt;

&lt;p&gt;The host machine, in my case, is a remote server pinned to Hong Kong time. So the zone Claude Code embedded and the zone my code silently assumed were the same zone — Asia/Hong_Kong on both sides — and the answer came out right every single time. Not because the code was correct. Because two timezones I'd never thought to compare happened to be identical. Run the same tool on a laptop in London, or catch a reset that Claude Code reports in another zone, and the "your limit resets at…" ping lands hours off, with no exception and no log line to explain it — just a confident, wrong time.&lt;/p&gt;

&lt;p&gt;The fix was small: capture the zone name out of the text and resolve it through Python's stdlib &lt;code&gt;zoneinfo&lt;/code&gt;, falling back to host-local time only when the zone is missing or unresolvable — which is exactly what the code used to do always, now demoted to a deliberate last resort. The tests passed. This is the point where I should have been suspicious and wasn't, because locally there was nothing to be suspicious of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that couldn't fail
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ubuntu-latest&lt;/code&gt; runs in UTC. The first CI run came back &lt;code&gt;4 failed, 232 passed&lt;/code&gt;, and two of the failures were in tests I hadn't touched at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;tests/test_usagelimit.py::test_parse_reset_returns_next_local_occurrence FAILED
tests/test_usagelimit.py::test_parse_reset_rolls_to_tomorrow_when_past FAILED
E   assert (13, 0) == (21, 0)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both checked &lt;code&gt;parse_reset()&lt;/code&gt; against &lt;code&gt;resets 9pm (Asia/Hong_Kong)&lt;/code&gt;. Both built the "current time" they fed in from a plain &lt;code&gt;datetime&lt;/code&gt; — read as host-local — and then verified the parser's answer by converting it back, also as host-local. The same ambient timezone sat on both sides of the assertion.&lt;/p&gt;

&lt;p&gt;Under the old, buggy &lt;code&gt;parse_reset()&lt;/code&gt;, which also worked in host-local time, that symmetry was fatal in a quiet way: feed in host-local, compute in host-local, read back in host-local, and the timezone cancels straight out of the equation. The two sides matched on every machine on earth. The test read like it was verifying timezone handling. It was verifying that a number equals itself. It could not fail — not on my box, not on anyone's.&lt;/p&gt;

&lt;p&gt;Bug 1's fix broke the symmetry, which is the only reason any of this surfaced. &lt;code&gt;parse_reset()&lt;/code&gt; now resolves 9pm Hong Kong to a real instant, the same instant no matter what the host clock says. On my Hong Kong machine, reading that instant back as host-local still shows 21:00 — still green. On the UTC runner, the identical instant reads back as 13:00, because 9pm in Hong Kong &lt;em&gt;is&lt;/em&gt; 1pm UTC. &lt;code&gt;(13, 0) == (21, 0)&lt;/code&gt;: an eight-hour gap that appears the moment the code runs somewhere my clock doesn't.&lt;/p&gt;

&lt;p&gt;This is the one to keep. A test that derives its expected value from the same environment the code reads is self-consistent, and on a green dashboard self-consistent is indistinguishable from correct. It passes, so you trust it, and it is checking nothing. The repair is to anchor the expectation to something external and explicit — both the input time and the assertion pinned to &lt;code&gt;zoneinfo.ZoneInfo("Asia/Hong_Kong")&lt;/code&gt; instead of "whatever this machine calls local." I re-ran it under &lt;code&gt;TZ=UTC&lt;/code&gt;, &lt;code&gt;TZ=America/Los_Angeles&lt;/code&gt;, and &lt;code&gt;TZ=Pacific/Kiritimati&lt;/code&gt; before pushing again, so the test now asserts a fact about the world instead of a fact about my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests didn't fail the way the code would
&lt;/h2&gt;

&lt;p&gt;Second push. Red again, and differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;tests/test_usagelimit.py::test_parse_reset_uses_reported_timezone_not_host FAILED
E   ModuleNotFoundError: No module named 'zoneinfo'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zoneinfo&lt;/code&gt; only entered the standard library in Python 3.9. The tool supports 3.8 — &lt;code&gt;pyproject.toml&lt;/code&gt; declares &lt;code&gt;requires-python = "&amp;gt;=3.8"&lt;/code&gt; — and the CI matrix runs 3.8 on purpose. The production code already accounted for that: the import of &lt;code&gt;ZoneInfo&lt;/code&gt; sits behind a &lt;code&gt;try/except ImportError&lt;/code&gt;, and when it fails the code falls back to host-local time, the same fallback Bug 1's fix leans on. It bends.&lt;/p&gt;

&lt;p&gt;The tests didn't bend. The four that exercise timezone resolution — the two I'd just anchored, plus two I'd added alongside the fix — each did a bare &lt;code&gt;from zoneinfo import ZoneInfo&lt;/code&gt;. On 3.8 that isn't a graceful fallback; it's a hard &lt;code&gt;ModuleNotFoundError&lt;/code&gt; before the test body even runs. The code under test degraded, and the tests meant to cover it snapped instead.&lt;/p&gt;

&lt;p&gt;The fix is one line apiece — &lt;code&gt;pytest.importorskip("zoneinfo")&lt;/code&gt; — which skips the test cleanly when the module isn't there. The principle behind it is duller than either earlier bug and matters more than both: a test has to degrade the same way the code it covers degrades. If production tolerates a missing optional dependency, a test that hard-requires that dependency isn't testing production. It's testing a stricter promise the code never made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red that meant something, and red that didn't
&lt;/h2&gt;

&lt;p&gt;One more job in that run was red, and it was bait. &lt;code&gt;macos-latest&lt;/code&gt; on Python 3.8 showed as failed, sitting right next to the genuine 3.8 failure on Ubuntu — close enough to look like the same bug. It wasn't. Its log said &lt;code&gt;The operation was canceled&lt;/code&gt;, at the &lt;code&gt;setup-python&lt;/code&gt; step, before a single test ran. When one leg of a matrix fails, GitHub Actions cancels the rest by default, and my workflow never turns that off — so the real Ubuntu failure dragged the entire macOS column down with it, 3.8, 3.11 and 3.12 all cancelled, not one of them an actual bug. Chase that red as a fourth problem and you lose an afternoon debugging a cancellation. Telling a real failure apart from fail-fast noise is its own step; the color alone won't do it for you.&lt;/p&gt;

&lt;p&gt;Line the three real bugs up and they rhyme. Bug 1 needs a host whose timezone differs from the reported one. So does Bug 2. Bug 3 needs a Python without &lt;code&gt;zoneinfo&lt;/code&gt;. My dev machine is in Hong Kong, on Python 3.12 with &lt;code&gt;zoneinfo&lt;/code&gt; built in — the one configuration on which not a single one of the three can happen. I didn't get unlucky and overlook them. The machine I built on was, structurally, the exact machine where all three were invisible. Every green run I'd trusted was true and worthless in the same breath: the code agreed with the computer that ran it. That is the whole of what a green suite proves. Whether the code is &lt;em&gt;correct&lt;/em&gt; is a separate claim, and the space between those two claims is precisely where a machine's timezone, its Python version, and its installed modules go to hide.&lt;/p&gt;

&lt;p&gt;The fix that actually mattered wasn't any of the three patches. It was running the matrix before tagging, instead of reading "236 passed" on one box as if it meant "shippable." Any of the three patches was ten minutes' work; the step I'd been skipping was the cheap, redundant-feeling one — letting a machine that isn't yours try the code before you call it done.&lt;/p&gt;

&lt;p&gt;The tool is claude-code-notify — MIT-licensed, at &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;github.com/Jeromefromcn/claude-code-notify&lt;/a&gt;, correct reset timezones now included. If Claude Code's usage limits have ever cost you an afternoon, it might be worth a look. And if you'd rather read the bugs than the writeup, they're three commits in the history — one per bug.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ci</category>
      <category>python</category>
      <category>timezones</category>
    </item>
    <item>
      <title>Claude Code Hit Its Limit — and the Notification Never Came.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:30:57 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/claude-code-hit-its-limit-and-the-notification-never-came-4dd4</link>
      <guid>https://dev.to/jeromefromhk/claude-code-hit-its-limit-and-the-notification-never-came-4dd4</guid>
      <description>&lt;p&gt;A while back I wrote about &lt;code&gt;claude-code-notify&lt;/code&gt;, a small tool I built because I kept losing time to work I wasn't watching. It pings me on Telegram when a Claude Code turn finishes, when it needs my input, or when it dies with an error — so I can kick off a long task, switch windows, and stop thinking about it. The push reaches me whichever session has focus; being reachable when I'm &lt;em&gt;not&lt;/em&gt; looking at the terminal is the whole point.&lt;/p&gt;

&lt;p&gt;One case slipped through: the usage limit.&lt;/p&gt;

&lt;p&gt;Hit your account's usage limit and every running session stops at once. The tool did fire — my error hook caught the dead turn and sent its generic "stopped with error" ping. But that ping tells you &lt;em&gt;something&lt;/em&gt; broke, not that it was a limit, and not when it'll lift. Then a few hours later the limit quietly resets and nothing fires at all. So the shape was always the same: hit the limit, get a vague error, put the phone down, forget. By the time I wandered back, the limit had been open for who-knows-how-long — the exact dead time the tool exists to kill.&lt;/p&gt;

&lt;p&gt;So I set out to add the one notification that would close the gap: a limit-specific message — something like &lt;em&gt;usage limit reached, resets 5:20am&lt;/em&gt; — and a second ping when it actually reset.&lt;/p&gt;

&lt;p&gt;It should have been an afternoon. Instead, just to add this one notification, I fell into a string of silent failures: the tool breaking in the one way a notification tool can't afford to, and never telling me it had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version that never fired
&lt;/h2&gt;

&lt;p&gt;I wired it up the way you'd expect. On the error hook, read the session transcript, find the last assistant message, check whether it's a rate-limit envelope — Claude Code tags those with &lt;code&gt;isApiErrorMessage: true&lt;/code&gt; and &lt;code&gt;error: "rate_limit"&lt;/code&gt; — and if so, pull the reset time out of the text and send the limit-specific ping. I tested it against a saved transcript, it worked, I shipped it.&lt;/p&gt;

&lt;p&gt;Then I hit two real usage limits over the next week. Both times: the generic "stopped with error" ping, and nothing else. No limit-specific message, no reset ping. The new detection had run and decided, both times, that this was &lt;em&gt;not&lt;/em&gt; a usage limit — on a turn that had died of nothing else.&lt;/p&gt;

&lt;p&gt;The generic ping was the tell. It's the consolation prize of a detection that has silently failed: the turn errored, so the old error hook fired regardless, which meant every real miss still looked like &lt;em&gt;a&lt;/em&gt; notification arrived. I only noticed anything was wrong because I knew a limit-specific message was supposed to be there and wasn't.&lt;/p&gt;

&lt;p&gt;So I added debug logging around the transcript read and waited to hit the limit again. When I did, the log showed the read returning nothing — no rate-limit envelope in the transcript at the moment my hook looked. Then I compared the read timestamp in my log against the file's own modification time. In that one incident, my code had read the transcript at &lt;code&gt;00:13:14.735881&lt;/code&gt;; the file's mtime was &lt;code&gt;00:13:14.755575&lt;/code&gt;. I had read it 19.7 milliseconds &lt;em&gt;before&lt;/em&gt; Claude Code finished writing it. One measurement, one incident — I don't know the distribution — but the direction was unambiguous: the read beat the write.&lt;/p&gt;

&lt;p&gt;Here's the mechanism, and it's the whole article. &lt;code&gt;transcript_path&lt;/code&gt; in the hook payload guarantees the file &lt;em&gt;exists&lt;/em&gt;. It does not guarantee the current turn is &lt;em&gt;in&lt;/em&gt; it. Claude Code writes the transcript asynchronously, and the &lt;code&gt;StopFailure&lt;/code&gt; hook can fire before the rate-limit envelope has been flushed to disk — the same local disk, same machine, no network anywhere in it. My detection was reading a file Claude Code hadn't finished writing, seeing no rate-limit envelope, classifying that as "not a limit," and moving on. Correct, given what was on disk. Useless, given what was true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch, and the fix I should have started with
&lt;/h2&gt;

&lt;p&gt;The first fix was the obvious one: if the first read comes up empty, wait and read again. One retry at 200 milliseconds — &lt;code&gt;_STOP_FAILURE_RETRY_DELAYS = (0.2,)&lt;/code&gt;, roughly ten times the gap I'd measured — gave the write enough slack to land by the second look. It worked; the next few limits sent proper reset pings.&lt;/p&gt;

&lt;p&gt;But a 200ms sleep to paper over a race I didn't fully understand is a patch, not an answer. Before building anything else on top of it, I went and read Claude Code's official hooks documentation — which I should have done before writing a line of transcript-parsing code, because the answer was sitting in it twice over.&lt;/p&gt;

&lt;p&gt;The race is documented behavior, not something I'd stumbled into. The reference says the transcript file "is written asynchronously and may lag the in-memory conversation, so it may not yet include the current turn's most recent messages when a hook fires," and it says what to do instead: use &lt;code&gt;last_assistant_message&lt;/code&gt; "instead of reading the transcript." There's even a public issue for the exact symptom — &lt;a href="https://github.com/anthropics/claude-code/issues/15813" rel="noopener noreferrer"&gt;anthropics/claude-code#15813&lt;/a&gt; — though a stale bot closed it for inactivity rather than any fix, so this is just how it works: documented, and unowned.&lt;/p&gt;

&lt;p&gt;The bigger miss was the part I'd assumed away. &lt;code&gt;StopFailure&lt;/code&gt; hands you the error &lt;em&gt;in the payload&lt;/em&gt;. It arrives in the hook's stdin JSON, right next to &lt;code&gt;transcript_path&lt;/code&gt;: a structured &lt;code&gt;error&lt;/code&gt; field drawn from a fixed enum — &lt;code&gt;rate_limit&lt;/code&gt; is one of the values — plus &lt;code&gt;last_assistant_message&lt;/code&gt;, which on an error turn holds the API error string itself. No file, no flush, no race. I'd built the entire transcript-reading path on the belief that the hook gave me nothing structured and I'd have to reconstruct the error myself. It had been handing it to me the whole time.&lt;/p&gt;

&lt;p&gt;So I flipped the priority. The payload is the primary source now: if &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt; and &lt;code&gt;last_assistant_message&lt;/code&gt; is there, classify and send, with no disk read at all. The transcript read, retry and all, stays only as the fallback for the edge the payload doesn't cover — the racy path runs when it has to and never when it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it fired when it shouldn't have
&lt;/h2&gt;

&lt;p&gt;The classifier had a second bug waiting, and this one broke in the opposite direction: one day it sent me a usage-limit notification that was a lie.&lt;/p&gt;

&lt;p&gt;The turn hadn't hit my account's usage limit at all. It had tried to use Fable 5 — a model that bills against its own usage-credits balance instead of the subscription allowance — without credits. Claude Code's message said so plainly: &lt;em&gt;Fable 5 requires usage credits. Run /usage-credits to continue or switch models with /model.&lt;/em&gt; My tool pinged "usage limit reached" anyway.&lt;/p&gt;

&lt;p&gt;It fooled the classifier because Claude Code tags this error with the &lt;em&gt;same&lt;/em&gt; fields a real limit uses. Same &lt;code&gt;isApiErrorMessage: true&lt;/code&gt;, same &lt;code&gt;error: "rate_limit"&lt;/code&gt;, same &lt;code&gt;429&lt;/code&gt; status. If your rule is "it's a limit when &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt;" — which was exactly my rule, on both the payload and the transcript path — a per-model credits gate is indistinguishable from an account limit. That one field is doing double duty for two conditions that mean completely different things to me.&lt;/p&gt;

&lt;p&gt;What set them apart sat a level deeper. On disk, the genuine limits I'd inspected carried no &lt;code&gt;errorDetails&lt;/code&gt; at all; the credits error carried one, with a structured code inside it: &lt;code&gt;error.details.error_code == "credits_required"&lt;/code&gt;. That field, and only that field, is the ground truth.&lt;/p&gt;

&lt;p&gt;So the fix is one extra clause, on both paths: it's a usage limit only when &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the error body isn't a &lt;code&gt;credits_required&lt;/code&gt; error. It keys on the structured code, never on the words "usage credits" in the message — text-matching is how you build a classifier that breaks the moment the wording changes, and not doing it is a standing rule in this project. The most specific structured field wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both failures had in common
&lt;/h2&gt;

&lt;p&gt;Put the two bugs side by side. One never fired; one fired when it shouldn't. Opposite symptoms — the same mistake. Both are what happens when you read one ambiguous, half-documented signal that another process hands you and trust your first reading of it. The miss trusted an empty file. The false alarm trusted an overloaded field. Wiring the hook was never the hard part; classifying the signal on the other end was the entire job.&lt;/p&gt;

&lt;p&gt;And here's what stings: neither bug was catchable by the test suite as it stood. My fixtures encoded the happy path — a fully-written transcript with a clean rate-limit envelope. A fixture is an already-written file, so it can't reproduce a race against a file still being written. And I had no credits-error fixture at all, because I didn't know that error existed until Claude Code sent me one. The tests were green the whole time. They were confirming that my code agreed with my assumptions, which is a different thing from confirming it was right.&lt;/p&gt;

&lt;p&gt;Both bugs surfaced the same way: a real event I couldn't have predicted, plus enough debug logging already in place to see what actually happened when it hit. The retry, the payload switch, the credits check — those were the easy part once I could see the signal. The discipline that made them possible was instrumenting the thing before I understood it, so the next strange event would leave a trace instead of a shrug. That, more than any single fix, is the skill.&lt;/p&gt;

&lt;p&gt;The worked-out version — retry, payload-first, the credits check — lives in &lt;code&gt;claude-code-notify&lt;/code&gt; on GitHub, MIT-licensed: &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;github.com/Jeromefromcn/claude-code-notify&lt;/a&gt;. What I'd still like to collect is other people's version of the same story: a notification that fired for the wrong reason, or stayed quiet for the right one. That's usually where the interesting bug is hiding — in the signal you trusted without reading twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>debugging</category>
      <category>cli</category>
      <category>automation</category>
    </item>
    <item>
      <title>Every Field Was Correct. It Still Wouldn't Connect.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:13:28 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/every-field-was-correct-it-still-wouldnt-connect-44ik</link>
      <guid>https://dev.to/jeromefromhk/every-field-was-correct-it-still-wouldnt-connect-44ik</guid>
      <description>&lt;p&gt;The VPN I share with a few other people ran out of traffic for the month. I had an idle OCI ARM instance sitting there doing nothing, so I decided to stop waiting and build my own node: one VLESS + Reality endpoint, self-hosted, good enough for personal use through Clash Verge. I drove the whole thing with Claude — chatted through the plan, had Claude Code do the Docker deploy, then went back to chat to walk the panel config step by step. The build was an afternoon. The debugging was the rest of the evening, and every minute of it was spent on failures where the configuration was, provably, correct.&lt;/p&gt;

&lt;p&gt;That's the part worth writing down. Not the happy path — the four traps, and especially the last one, where every field matched on every screen and it still refused to connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standing up the panel
&lt;/h2&gt;

&lt;p&gt;The management panel is &lt;a href="https://github.com/MHSanaei/3x-ui" rel="noopener noreferrer"&gt;3x-ui&lt;/a&gt;, run as a container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /etc/x-ui:/etc/x-ui &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 443:443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 2096:2096 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 54321:2053 &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/mhsanaei/3x-ui:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things here are not optional. Persist &lt;code&gt;/etc/x-ui&lt;/code&gt; to the host, or every container recreate wipes your config. Set a restart policy (&lt;code&gt;unless-stopped&lt;/code&gt; or &lt;code&gt;always&lt;/code&gt;) so the node comes back on its own after a reboot — a proxy you have to SSH in and restart by hand is a proxy you'll abandon. And map only the ports you actually use with &lt;code&gt;-p&lt;/code&gt;: the node (&lt;code&gt;443&lt;/code&gt;), the subscription port (&lt;code&gt;2096&lt;/code&gt; here), and the panel. After it's running, &lt;code&gt;docker ps&lt;/code&gt; and confirm every one shows up in the PORTS column.&lt;/p&gt;

&lt;p&gt;The moment it's up, before anything else: log in and change the default credentials. This panel is exposed to the public internet, and default logins are exactly what scanners look for. While you're in there, move the panel off its default &lt;code&gt;2053&lt;/code&gt; to a random port — that's the &lt;code&gt;54321:2053&lt;/code&gt; mapping above — and consider changing its base access path. None of this is real security on its own; it just takes you out of the easy-target bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two firewalls, or nothing works
&lt;/h2&gt;

&lt;p&gt;On a normal VPS this is one step. On OCI it's two, and missing either one gives you the &lt;em&gt;identical&lt;/em&gt; symptom — a plain connection timeout — which makes it genuinely hard to tell which layer is at fault.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OCI's own network layer.&lt;/strong&gt; In the console, add ingress rules to the instance's Security List (or NSG) for every port you need: TCP, source &lt;code&gt;0.0.0.0/0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The instance's host firewall.&lt;/strong&gt; Ubuntu's &lt;code&gt;ufw&lt;/code&gt; is separate and just as real:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp     &lt;span class="c"&gt;# the node&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2096/tcp    &lt;span class="c"&gt;# subscription port, etc.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open one and not the other and you'll spend twenty minutes &lt;code&gt;curl&lt;/code&gt;-ing from the wrong side of the problem. Open both first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inbound — and the button that rewrites your config
&lt;/h2&gt;

&lt;p&gt;Create a VLESS inbound on port &lt;code&gt;443&lt;/code&gt; (blending in with ordinary HTTPS). Most defaults are fine. Two tabs need care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 1 — the Generate button has a side effect.&lt;/strong&gt; The Protocol tab has Decryption/Encryption fields; for standard VLESS both should read &lt;code&gt;none&lt;/code&gt;. But clicking &lt;strong&gt;Generate&lt;/strong&gt; to create the Reality X25519 key pair &lt;em&gt;also&lt;/em&gt; silently overwrites those two fields with an experimental post-quantum encryption string — something like &lt;code&gt;mlkem768x25519plus.native.600s...&lt;/code&gt;. This is a newer Xray feature, and Mihomo (the core inside Clash Verge) can't parse it; it errors out with &lt;code&gt;invaild vless encryption value&lt;/code&gt; (yes, misspelled that way in Mihomo itself). The fix: after you generate the Reality keys, go back and manually set Decryption and Encryption to &lt;code&gt;none&lt;/code&gt;. And don't click Generate again afterward — it re-overwrites the fields &lt;em&gt;and&lt;/em&gt; regenerates your key pair, which just adds a fresh variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decoy site that's too big
&lt;/h2&gt;

&lt;p&gt;On the Security tab, set the security type to &lt;strong&gt;Reality&lt;/strong&gt; and pick a camouflage target — a real TLS 1.3 site your handshake will impersonate. Set uTLS to &lt;code&gt;chrome&lt;/code&gt;; leave short IDs and SpiderX auto-generated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 2 — some targets are literally too large.&lt;/strong&gt; With &lt;code&gt;www.microsoft.com&lt;/code&gt; as the target, every parameter matched and the keys were confirmed identical, but the server log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REALITY: processed invalid connection ... handshake did not complete successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't your config. Xray-core reads the target's TLS Certificate record during the Reality handshake and rejects anything over a hardcoded 8192 bytes. &lt;code&gt;www.microsoft.com&lt;/code&gt; returns a record around 8273 bytes — just over the line — so the handshake dies. It's a known, reproduced upstream limit (&lt;a href="https://github.com/XTLS/Xray-core/issues/6356" rel="noopener noreferrer"&gt;Xray-core #6356&lt;/a&gt;, &lt;a href="https://github.com/XTLS/Xray-core/issues/6402" rel="noopener noreferrer"&gt;#6402&lt;/a&gt;, &lt;a href="https://github.com/XTLS/Xray-core/discussions/6387" rel="noopener noreferrer"&gt;discussion #6387&lt;/a&gt;). The fix is to pick a target with a smaller certificate. &lt;code&gt;www.cloudflare.com&lt;/code&gt; works, and the error vanished the instant I switched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client and the subscription that Clash refuses
&lt;/h2&gt;

&lt;p&gt;On the Add Client page, attach the client to the port-443 inbound you just made, or it's linked to nothing. Then, on the Credentials tab, change &lt;strong&gt;Flow&lt;/strong&gt; from its default &lt;code&gt;None&lt;/code&gt; to &lt;code&gt;xtls-rprx-vision&lt;/code&gt; to match the server's Reality setup — leave it at &lt;code&gt;None&lt;/code&gt; and the connection behaves inconsistently or fails outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 3 — Clash needs YAML, not the default link.&lt;/strong&gt; 3x-ui's default subscription link (&lt;code&gt;http://ip:port/sub/&amp;lt;id&amp;gt;&lt;/code&gt;) returns the generic Base64 format that clients like V2RayNG expect. Paste that into Clash Verge and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;the remote profile data is invalid yaml: invalid type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string, expected a YAML mapping&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clash/Mihomo needs a YAML subscription, which 3x-ui serves from a &lt;em&gt;different&lt;/em&gt; path. In panel settings, confirm the Clash/Mihomo subscription is enabled, check its URI path (usually &lt;code&gt;/clash/&lt;/code&gt;), and use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://server-ip:subscription-port/clash/&amp;lt;subscription-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import that under Profiles → New → Remote. Only this path returns parseable YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that ate the evening
&lt;/h2&gt;

&lt;p&gt;Subscription imported, node listed, everything green. Every dial failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[TCP] dial ... error: server-ip:443 connect error: REALITY authentication failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We worked it in order — I say "we" because Claude was driving the diagnosis the whole way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nc -zv server-ip 443&lt;/code&gt; from my laptop — TCP connects. Network's fine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; from the server to the Reality target — clean 200. Target's reachable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec ... netstat -tlnp | grep 443&lt;/code&gt; — the port is listening inside the container.&lt;/li&gt;
&lt;li&gt;Then the careful part: read the actual running &lt;code&gt;config.json&lt;/code&gt; from &lt;em&gt;inside&lt;/em&gt; the container and compare Public Key, Short ID, SNI, UUID, and Flow against the panel UI and against the subscription Clash was consuming. Field by field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything matched. An assistant that will happily read every value in every file had just confirmed all of them correct — and the connection still failed. The thing that was wrong wasn't &lt;em&gt;in&lt;/em&gt; any file.&lt;/p&gt;

&lt;p&gt;The answer was the version of the running binary. 3x-ui v3.5.0 ships Xray-core &lt;strong&gt;v26.7.11&lt;/strong&gt;, and that release is deliberately incompatible with Mihomo — a documented breaking change (a new default &lt;code&gt;minClientVer&lt;/code&gt;), not a local misconfiguration. Multiple people reproduced the exact &lt;code&gt;REALITY authentication failed&lt;/code&gt; symptom on v3.5.0, and Mihomo has stated outright it won't support Xray v26.7.11+ (&lt;a href="https://github.com/MHSanaei/3x-ui/issues/5957" rel="noopener noreferrer"&gt;3x-ui #5957&lt;/a&gt;, &lt;a href="https://github.com/MHSanaei/3x-ui/issues/5922" rel="noopener noreferrer"&gt;#5922&lt;/a&gt;; &lt;a href="https://github.com/XTLS/Xray-core/issues/6477" rel="noopener noreferrer"&gt;Xray-core #6477&lt;/a&gt;). The fix is to downgrade the core to &lt;strong&gt;v26.6.27&lt;/strong&gt;. Recreating clients, regenerating keys, rebuilding the inbound — none of it matters, because none of it touches the version.&lt;/p&gt;

&lt;p&gt;There was one more layer to it, and it's the reason the fix &lt;em&gt;looked&lt;/em&gt; like it didn't work. After downgrading, the error was still there. What I hadn't accounted for: the panel applies changes through Xray's core API and reports &lt;code&gt;config changes applied through the core API, no restart needed&lt;/code&gt;. That hot-reload path does not swap the running Xray process. The old v26.7.11 binary was still serving traffic. A full &lt;code&gt;docker restart 3x-ui&lt;/code&gt; loaded the downgraded core, and it connected immediately.&lt;/p&gt;

&lt;p&gt;So the two facts sit together. The fix was the downgrade. It only took effect on a real process restart — because the panel's "no restart needed" is true for config edits and quietly false for the one thing that actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;When every file says the config is correct and it still fails, stop re-reading the files. The mismatch you can't see is the process, not the config: what version is actually running, and did it actually reload. The panel telling you "no restart needed" is a claim about config edits, not a guarantee about the process — so after any change that matters, restart the container and check the running core yourself.&lt;/p&gt;

&lt;p&gt;A short checklist, in the order that would have saved me the evening:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;nc -zv server-ip port&lt;/code&gt; — reachability from the client.&lt;/li&gt;
&lt;li&gt;Confirm &lt;strong&gt;both&lt;/strong&gt; the OCI security list and the host &lt;code&gt;ufw&lt;/code&gt; allow the port.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec ... netstat -tlnp&lt;/code&gt; — the port is really listening inside the container.&lt;/li&gt;
&lt;li&gt;Decryption/Encryption are &lt;code&gt;none&lt;/code&gt;, not the &lt;code&gt;mlkem768...&lt;/code&gt; post-quantum string.&lt;/li&gt;
&lt;li&gt;Camouflage target has a small certificate — avoid &lt;code&gt;www.microsoft.com&lt;/code&gt;; &lt;code&gt;www.cloudflare.com&lt;/code&gt; is safe.&lt;/li&gt;
&lt;li&gt;Client Flow is &lt;code&gt;xtls-rprx-vision&lt;/code&gt;; subscription is the &lt;code&gt;/clash/&lt;/code&gt; YAML path, not &lt;code&gt;/sub/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the &lt;strong&gt;running Xray-core version&lt;/strong&gt;. If it's v26.7.11 and your client is Mihomo/Clash Verge, downgrade to v26.6.27.&lt;/li&gt;
&lt;li&gt;After any change, &lt;code&gt;docker restart&lt;/code&gt; the container — don't trust "no restart needed" — then retest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The config was never the hard part. The hard part was trusting, for one evening too long, that a correct file meant a correct process.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>docker</category>
      <category>networking</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Bug That Kept Coming Back</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:41:27 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/the-bug-that-kept-coming-back-47lp</link>
      <guid>https://dev.to/jeromefromhk/the-bug-that-kept-coming-back-47lp</guid>
      <description>&lt;p&gt;The first sign something was wrong wasn't a crash. It was a pattern.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;blockly-platform&lt;/code&gt; was the first real thing I built with Claude Code end to end — a Blockly-based platform for university programming exercises, driven entirely through Claude Code's Telegram channel. No editor open, no repo checked out on my machine, just a chat thread. I'd describe what I wanted, Claude Code would build it on a box I never looked at directly, and I'd judge the result by clicking around the deployed app.&lt;/p&gt;

&lt;p&gt;On March 22nd, the home page came up empty. &lt;code&gt;GET /api/exercises/published&lt;/code&gt; was returning 403. I said so in the chat; a few messages later, Claude Code said it was fixed — the endpoint hadn't been added to Spring Security's &lt;code&gt;permitAll()&lt;/code&gt; list. I moved on, tried the category filter. Also empty, also 403, also missing from the same &lt;code&gt;permitAll()&lt;/code&gt; list — same file, same class of fix, different line. Then the exercise detail page. Same story, third time, same day. Three days later, the like button stopped working — root cause, again: &lt;code&gt;POST /api/exercises/*/like&lt;/code&gt; had never been whitelisted either. Four times, one file, one recurring gap.&lt;/p&gt;

&lt;p&gt;None of these were hard bugs. Each one, in isolation, is a one-line fix a competent engineer makes without thinking twice. What bothered me, once I noticed the pattern, was that I hadn't noticed it &lt;em&gt;as&lt;/em&gt; it happened. I had no diff to scroll through, no file to glance at and think "wait, didn't we just fix this exact class of thing twice already?" I had a chat log and a live app to poke at. The fourth fix looked, from where I sat, exactly like the first: a message telling me it was resolved.&lt;/p&gt;

&lt;p&gt;That was the moment I started to suspect the problem wasn't the model. It was that nobody — not the model, not me — had anything to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chat-only vibe coding breaks down
&lt;/h2&gt;

&lt;p&gt;Here's what makes that pattern more interesting than "the AI made a mistake": every one of those four fixes was correct. Claude Code read the error, found the missing &lt;code&gt;permitAll()&lt;/code&gt; entry, added it, and moved on — each time, in isolation, exactly the right diagnosis and exactly the right fix. Grade any single one of those four turns on its own and it passes.&lt;/p&gt;

&lt;p&gt;What failed wasn't a single decision. It was the absence of anything connecting the four decisions to each other. A human engineer touching &lt;code&gt;SecurityConfig.java&lt;/code&gt; for the third time in a week, over the same kind of hole, stops and thinks "let me just check the whole file" — not because they're smarter than Claude Code, but because they've &lt;em&gt;been there before&lt;/em&gt; and remember it. Chat-only, Telegram-driven vibe coding doesn't give either party that continuity. Claude Code sees the current turn's error and fixes the current turn's error. I see a message saying "fixed" and move to the next thing on my list. Neither of us is holding the shape of the file over time, because neither of us is looking at the file — I'm looking at a phone screen, and the model's context is whatever's in that turn's window, not an accumulated sense that this file has a recurring problem.&lt;/p&gt;

&lt;p&gt;That's the first layer. The second layer worried me more, because it doesn't have an obvious fix inside the chat-only model at all: some of Claude Code's decisions came back to me to approve. But I hadn't read the code structure — I'd never opened the repo, never seen &lt;code&gt;SecurityConfig.java&lt;/code&gt;, never seen how exercises, submissions, and grading fit together. So when a decision &lt;em&gt;did&lt;/em&gt; land in my lap, I was approving it with roughly the same information the model had: none, beyond the current message. "Human in the loop" only adds oversight if the human has something the model doesn't. Mine didn't.&lt;/p&gt;

&lt;p&gt;To be clear about the boundary of this claim: none of it means chat-only driving is always wrong. For something small and throwaway, it's fine — there's nothing to lose track of. The failure mode shows up specifically as complexity and stakes climb, once a project has enough moving parts that "the shape of the file over time" is actually worth tracking.&lt;/p&gt;

&lt;p&gt;The repo backs this up in ways that aren't just my memory of it. &lt;code&gt;blockly-platform&lt;/code&gt; went from an empty repository to a merged, working platform in 80 commits over 23 days — fast, and it shows. There's no design doc anywhere in it, no architecture note, nothing that records why a decision was made — only a &lt;code&gt;RUNBOOK.md&lt;/code&gt;, a &lt;code&gt;TODO.md&lt;/code&gt;, and an &lt;code&gt;ERROR_LOG.md&lt;/code&gt; that reads like a list of things discovered after the fact. A &lt;code&gt;CLAUDE.md&lt;/code&gt; — the file that would have told Claude Code the project's own rules, things like "don't forget the security config when you add an endpoint" — didn't exist until April 2nd. Every dated entry in &lt;code&gt;ERROR_LOG.md&lt;/code&gt;, all eighteen of them, including all four &lt;code&gt;permitAll()&lt;/code&gt; misses, falls between March 22nd and March 28th. The guardrails arrived after the bugs they would have prevented, not before them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;The project that replaced it, &lt;code&gt;programming-learning-platform&lt;/code&gt;, started the same day &lt;code&gt;blockly-platform&lt;/code&gt;'s pull request merged. That's not a coincidence I engineered — it's genuinely where the first project ended and the lesson from it started applying. It isn't a separate idea, either: its own &lt;code&gt;CLAUDE.md&lt;/code&gt; describes it as a platform for Blockly &lt;em&gt;and&lt;/em&gt; Python exercises, the same tool rebuilt from the ground up rather than a new one started from scratch.&lt;/p&gt;

&lt;p&gt;This time I changed two things at once. First, visibility: I moved to IntelliJ IDEA's remote development mode, with the project checked out and running on the same kind of remote box as before, but now with an actual editor attached to it — I could see the file tree, open a class, read a diff before it landed. Second, process: I turned on the &lt;code&gt;superpowers&lt;/code&gt; skill and wrote it into the project's &lt;code&gt;CLAUDE.md&lt;/code&gt; as non-negotiable — "Every task: Brainstorm → Plan → Implement (TDD). No skipping brainstorm. No code without a plan. No implementation before a failing test." — with "No skipping Superpowers" listed again under the project's explicit red lines, in case that wasn't clear enough the first time.&lt;/p&gt;

&lt;p&gt;The difference shows up in the repo's shape, not just in my memory of how it felt. &lt;code&gt;programming-learning-platform&lt;/code&gt; is 458 commits deep and still active more than three months later. It has a real &lt;code&gt;docs/&lt;/code&gt; tree: a PRD, per-role user flows, an architecture doc, feature specs split by priority — and a &lt;code&gt;docs/superpowers/plans/&lt;/code&gt; directory holding forty-four dated implementation plans, running from within its first two weeks to earlier this month, each one a record of a brainstorm-and-plan step that happened &lt;em&gt;before&lt;/em&gt; the corresponding code did. Where &lt;code&gt;blockly-platform&lt;/code&gt;'s only paper trail was a log of things that had already gone wrong, this one has a paper trail of decisions made on purpose, in order, before they were implemented.&lt;/p&gt;

&lt;p&gt;It worked, in the sense that mattered most: I stopped approving things blind, because I finally had a diff to actually read and a plan to check the diff against.&lt;/p&gt;

&lt;p&gt;It also had friction I didn't expect, and I want to be precise that this is my own experience with a specific setup at a specific time, not a verdict on the products themselves. IntelliJ IDEA's remote development client, even with the actual computation happening entirely on the server, still felt heavy on my local machine — noticeably more than I expected from something meant to be a thin window onto remote work. And the JetBrains Claude Code plugin had gaps that got in the way of the one thing visibility is supposed to buy you: clear communication. I couldn't attach an image to a message — no screenshot of a broken layout, no annotated diagram — so some things I could have shown in five seconds took several paragraphs to describe instead, with more room for the description to be wrong.&lt;/p&gt;

&lt;p&gt;Visibility and process had fixed the real problem. The tool I'd wrapped them in was still fighting me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that stopped fighting me
&lt;/h2&gt;

&lt;p&gt;A friend who'd been running a similar remote setup suggested I drop IntelliJ for VS Code partway through the project — specifically, VS Code's Remote-SSH extension to manage &lt;code&gt;programming-learning-platform&lt;/code&gt; directly on the remote box, with the Claude Code extension running against that same connection, instead of a JetBrains-specific remote protocol and its own plugin.&lt;/p&gt;

&lt;p&gt;Nothing about the visibility or the process changed, and that's the point: the file tree was still there, diffs still landed in front of me before I approved anything, and &lt;code&gt;superpowers&lt;/code&gt;' brainstorm-plan-TDD loop carried over untouched, because it lives in the project's &lt;code&gt;CLAUDE.md&lt;/code&gt;, not in whichever editor happens to be open. What changed was the tool wrapped around both of those things. The client was lighter — it stopped being noticeably heavy on my local machine the way IDEA's had been. And the Claude Code extension let me attach a screenshot directly to a message, which matters more than it sounds for a platform built around a visual block editor and a grading UI: a broken Blockly toolbox or a misaligned grading panel is a five-second screenshot and a two-word question, not three paragraphs trying to describe a layout in words.&lt;/p&gt;

&lt;p&gt;I've written before about the mechanics of &lt;em&gt;why&lt;/em&gt; this specific combination — VS Code's Remote-SSH plus the Claude Code extension — is worth trusting with long-running, walk-away-able work: &lt;a href="https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c"&gt;the process survives your laptop sleeping, and the compute never touches your local machine&lt;/a&gt;. I won't re-run that ground here. What matters for this piece is narrower: this was the first setup where visibility, process, and the tool itself all pulled in the same direction, instead of two out of three fighting the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last mile
&lt;/h2&gt;

&lt;p&gt;With visibility and process both solid, the last piece was the one that let me actually step away: knowing &lt;em&gt;when&lt;/em&gt; Claude Code needed me, and being able to answer &lt;em&gt;without&lt;/em&gt; being at my desk.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;when&lt;/em&gt; is &lt;a href="https://dev.to/jeromefromhk/the-notification-that-lied-to-me-46lm"&gt;&lt;code&gt;claude-code-notify&lt;/code&gt;&lt;/a&gt;, a tool I built after getting tired of either babysitting a terminal or discovering twenty minutes later that a long TDD-driven turn had been sitting blocked on my sign-off the whole time. It pings me on Telegram at exactly the moments that matter — a turn is genuinely finished, background work included, or it needs my input, or it errored out. The &lt;em&gt;how&lt;/em&gt; is Remote Control (&lt;code&gt;/remote-control&lt;/code&gt;), which lets me approve prompts and answer questions from the Claude app on my phone instead of reopening a laptop. Get pinged that Claude needs me, answer from my pocket — that's the workflow, and &lt;a href="https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c"&gt;I've written separately about the one way it can quietly fail&lt;/a&gt; if the underlying remote session doesn't survive long enough to still be there when you answer.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what this layer is and isn't. It isn't what fixed the &lt;code&gt;permitAll()&lt;/code&gt; problem — visibility and &lt;code&gt;superpowers&lt;/code&gt;' plan-before-code discipline did that. What notify and Remote Control add is the ability to be &lt;em&gt;away&lt;/em&gt; from the keyboard without losing either one: I still see the diff, Claude Code still follows the plan, I just don't have to be sitting there for either to keep happening. Bolt the same walk-away layer onto the Telegram-only setup from the first project, and it wouldn't have fixed anything — you'd just get notified faster that a decision you couldn't evaluate had already been made. The order matters: visibility and process first, walk-away convenience on top of that foundation, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, one more time
&lt;/h2&gt;

&lt;p&gt;I sometimes wonder what would have happened if the fourth &lt;code&gt;permitAll()&lt;/code&gt; miss — the one on the like button — had happened under the later setup instead of the first one. My honest guess: it still would have gotten written, because forgetting a security rule on a new endpoint is an easy thing for anyone, human or model, to do once. But it wouldn't have taken three prior occurrences to catch, because a plan step would have said "add the endpoint and its security rule" as one unit, and a diff would have shown both halves — or shown only one, right there, before I approved it. The bug wouldn't have needed a human to notice a &lt;em&gt;pattern&lt;/em&gt; across a week of chat messages. It would have needed a human, or a model, to notice a &lt;em&gt;gap in one diff&lt;/em&gt; — a much smaller ask, and one that visibility actually makes possible.&lt;/p&gt;

&lt;p&gt;That's the whole shift, underneath all the tool names. Pure chat-only vibe coding doesn't fail because the model is bad at its job — every individual &lt;code&gt;permitAll()&lt;/code&gt; fix in &lt;code&gt;blockly-platform&lt;/code&gt; was correct. It fails because past a certain size, nobody involved, model or human, has enough in front of them to notice the second time a mistake happens, let alone stop it before the fourth. Visibility and a process that makes you plan before you build don't make Claude Code smarter. They give whoever's watching, model or human, something to actually watch.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>agentic</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
