<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://spicyshark.co/feed.xml" rel="self" type="application/atom+xml" /><link href="https://spicyshark.co/" rel="alternate" type="text/html" /><updated>2026-10-17T06:28:57+00:00</updated><id>https://spicyshark.co/feed.xml</id><title type="html">spicysharkco</title><subtitle>Faster, cheaper, harder to break.</subtitle><entry><title type="html">Two URL lists: the adapter webhook and the runtime sidecar</title><link href="https://spicyshark.co/2026/10/17/aibrix-adapter-urls-webhook-vs-sidecar.html" rel="alternate" type="text/html" title="Two URL lists: the adapter webhook and the runtime sidecar" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/17/aibrix-adapter-urls-webhook-vs-sidecar</id><content type="html" xml:base="https://spicyshark.co/2026/10/17/aibrix-adapter-urls-webhook-vs-sidecar.html"><![CDATA[<p>I found that AIBrix could admit an adapter URL and then reject it when the runtime sidecar tried to load it. I fixed two forms: the <code class="language-plaintext highlighter-rouge">hf://</code> alias in <a href="https://github.com/vllm-project/aibrix/pull/3130">PR #3130</a>, merged 2026-10-16 about 2 hours 27 minutes after I opened it, and an absolute path starting with <code class="language-plaintext highlighter-rouge">/</code> in <a href="https://github.com/vllm-project/aibrix/pull/3131">PR #3131</a>, merged 2026-10-17 about 6 hours 5 minutes after opening. The webhook and the sidecar kept separate lists of acceptable inputs.</p>

<h2 id="when-it-happens">When it happens</h2>

<p>Both failures depended on the controller using the runtime sidecar. With that path enabled and an <code class="language-plaintext highlighter-rouge">aibrix-runtime</code> container present, the controller sent the adapter URL to the sidecar. The direct path handled the same inputs differently: it resolved the alias to a repository identifier and passed an absolute path through to the engine.</p>

<p>The alias failure was narrower than a broken downloader. The longer <code class="language-plaintext highlighter-rouge">huggingface://</code> spelling already worked. The docs and samples used that spelling, while the webhook’s supported-input error advertised <code class="language-plaintext highlighter-rouge">hf://</code> too. For absolute paths, the loading guide explicitly told readers to mount shared storage into the pod and use a value starting with <code class="language-plaintext highlighter-rouge">/</code>. I was following an accepted input to its consumer, rather than adding a new URL form.</p>

<h2 id="how-the-value-travels">How the value travels</h2>

<p>In <code class="language-plaintext highlighter-rouge">pkg/webhook/modeladapter_webhook.go</code>, the webhook parses <code class="language-plaintext highlighter-rouge">artifactURL</code> and calls <code class="language-plaintext highlighter-rouge">ValidateArtifactURL</code>. Its list lives in <code class="language-plaintext highlighter-rouge">pkg/utils/modeladapter.go</code>: <code class="language-plaintext highlighter-rouge">AllowedSchemas</code> includes both <code class="language-plaintext highlighter-rouge">hf://</code> and <code class="language-plaintext highlighter-rouge">/</code>.</p>

<p>The controller’s <code class="language-plaintext highlighter-rouge">pkg/controller/modeladapter/lora_client.go</code> then chooses the load payload. For the sidecar, <code class="language-plaintext highlighter-rouge">loadAdapterCall</code> puts the original value into <code class="language-plaintext highlighter-rouge">artifact_url</code>. It does not turn the alias into the longer spelling or change a mounted path into a remote URL.</p>

<p>Next comes <code class="language-plaintext highlighter-rouge">load_adapter_with_delegation</code> in <code class="language-plaintext highlighter-rouge">python/aibrix/aibrix/runtime/artifact_service.py</code>. Before my changes, it sent every value through <code class="language-plaintext highlighter-rouge">download_artifact</code>. That reached <code class="language-plaintext highlighter-rouge">get_downloader</code> in <code class="language-plaintext highlighter-rouge">python/aibrix/aibrix/runtime/downloaders.py</code>, which selected a downloader by the parsed scheme. There was an entry for <code class="language-plaintext highlighter-rouge">huggingface</code>, none for <code class="language-plaintext highlighter-rouge">hf</code>, and none for the empty scheme of an absolute path. The webhook’s acceptance did not carry across that boundary.</p>

<h2 id="why-the-lists-drifted">Why the lists drifted</h2>

<p><a href="https://github.com/vllm-project/aibrix/pull/556">#556</a> added absolute-path support to the controller and its validation. Later, <a href="https://github.com/vllm-project/aibrix/pull/1680">#1680</a> introduced downloading through the runtime. Its downloader map had the longer spelling but no alias, and its delegation method sent all inputs to the downloader.</p>

<h2 id="the-fixes">The fixes</h2>

<p>For the alias, I added the missing dispatch entry:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     "huggingface": HuggingFaceArtifactDownloader(),
<span class="gi">+    "hf": HuggingFaceArtifactDownloader(),
</span></code></pre></div></div>

<p>The downloader already built the repository identifier from the URL’s netloc and path. It did not need a different download implementation for the alias.</p>

<p>For a mounted path, there is nothing to download. The merged branch is:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">artifact_url</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="s">"/"</span><span class="p">):</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">_validate_lora_name</span><span class="p">(</span><span class="n">lora_name</span><span class="p">)</span>
    <span class="n">local_path</span> <span class="o">=</span> <span class="n">artifact_url</span>
<span class="k">else</span><span class="p">:</span>
    <span class="n">local_path</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">download_artifact</span><span class="p">(</span>
        <span class="n">artifact_url</span><span class="p">,</span> <span class="n">lora_name</span><span class="p">,</span> <span class="n">credentials</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>The engine receives that value as <code class="language-plaintext highlighter-rouge">lora_path</code>. It resolves the path in its own container. The runtime does not copy the mounted directory or check that it exists there.</p>

<h2 id="the-tests">The tests</h2>

<p>My PR descriptions record the local before-and-after runs. For the alias, the focused file had <code class="language-plaintext highlighter-rouge">1 failed, 2 passed</code> before the fix and <code class="language-plaintext highlighter-rouge">3 passed</code> after it. The runtime suite reported <code class="language-plaintext highlighter-rouge">178 passed</code>. That test uses a stand-in for the download call; it checks dispatch and the returned directory, without a real Hub download.</p>

<p>For the local-path change, the final focused file had <code class="language-plaintext highlighter-rouge">2 failed, 2 passed</code> before and <code class="language-plaintext highlighter-rouge">4 passed</code> after. The failures were the path-load cases. The other cases checked invalid names. The full runtime directory went from <code class="language-plaintext highlighter-rouge">2 failed, 179 passed</code> to <code class="language-plaintext highlighter-rouge">181 passed</code> in the runs recorded for that PR.</p>

<p>The path tests use a recording engine. They check the forwarded path and that the runtime writes nothing into its download directory. They do not prove a real engine loaded files from a mounted volume.</p>

<h2 id="what-happened-in-review">What happened in review</h2>

<p>On the alias PR, the review reproduced the failure and suggested sharing a downloader instance and listing the alias in docstrings. Those were optional suggestions; the merged change contains the dispatch entry and its test.</p>

<p>The local-path review found a more useful mistake in my first version. A review bot pointed out that bypassing <code class="language-plaintext highlighter-rouge">download_artifact</code> also bypassed name validation. The review confirmed it: a name containing a slash could load through the new branch, then produce a confusing cleanup error after the engine had already unloaded it.</p>

<p>I added the explicit validation call in <a href="https://github.com/vllm-project/aibrix/commit/c8ce9b77f1921b54857f963ebdf0ce78fb48f9c4">c8ce9b77</a>, plus a test requiring local and remote inputs to reject the same invalid name before reaching the engine. The follow-up review removed that call and saw the local case fail. @varungup90 merged both PRs.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>Trace each accepted spelling to its final consumer. A validator’s list is only useful if later dispatch agrees.</li>
  <li>Before bypassing a helper, list its other jobs. I needed to preserve name validation when skipping the download.</li>
  <li>Keep the evidence boundary clear. Dispatch tests and a recording engine answer useful questions without proving a mounted-volume load.</li>
</ul>

<p>If you have found an input accepted at the front door and rejected later, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I found that AIBrix could admit an adapter URL and then reject it when the runtime sidecar tried to load it. I fixed two forms: the hf:// alias in PR #3130, merged 2026-10-16 about 2 hours 27 minutes after I opened it, and an absolute path starting with / in PR #3131, merged 2026-10-17 about 6 hours 5 minutes after opening. The webhook and the sidecar kept separate lists of acceptable inputs.]]></summary></entry><entry><title type="html">The duplicate guard that never recorded a path</title><link href="https://spicyshark.co/2026/10/17/aibrix-dedup-set-never-written.html" rel="alternate" type="text/html" title="The duplicate guard that never recorded a path" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/17/aibrix-dedup-set-never-written</id><content type="html" xml:base="https://spicyshark.co/2026/10/17/aibrix-dedup-set-never-written.html"><![CDATA[<p>I found a set under <code class="language-plaintext highlighter-rouge">// avoid duplicates</code> that nothing wrote to. AIBrix’s <code class="language-plaintext highlighter-rouge">appendCustomModelRouterPaths</code> checked it before appending a path, so repeated annotation entries became repeated HTTPRoute matches. I fixed it in <a href="https://github.com/vllm-project/aibrix/pull/3133">PR #3133</a>, merged 2026-10-17 about 1 hour 57 minutes after I opened it. The impact was narrow: identical matches did not change routing, but they used space near the 64-match limit.</p>

<h2 id="when-it-happens">When it happens</h2>

<p>The input is the custom-path annotation defined by <a href="https://github.com/vllm-project/aibrix/blob/f06d171f9eb9cde301d894954599f27c4c1dcce5/pkg/constants/model.go#L119-L121"><code class="language-plaintext highlighter-rouge">ModelAnnoRouterCustomPath</code></a>. A list such as <code class="language-plaintext highlighter-rouge">/version,/score,/version</code> added the first path again at the end. The helper also removed spaces before checking for duplicates, so <code class="language-plaintext highlighter-rouge">/f oo</code> and <code class="language-plaintext highlighter-rouge">/foo</code> became the same path and still both got appended. A custom entry equal to a default path could repeat a match too.</p>

<p>I do not have an outage report. My evidence is the code, the failing table-test row, and the local count-and-schema check recorded in the PR.</p>

<h2 id="how-the-paths-reach-a-route">How the paths reach a route</h2>

<p>In <code class="language-plaintext highlighter-rouge">pkg/controller/modelrouter/modelrouter_controller.go</code>, workload annotations reach <code class="language-plaintext highlighter-rouge">createHTTPRoute</code>. <code class="language-plaintext highlighter-rouge">buildHTTPRoute</code> first makes the default matches from <code class="language-plaintext highlighter-rouge">modelPaths</code>, then calls <code class="language-plaintext highlighter-rouge">appendCustomModelRouterPaths</code> to extend the route.</p>

<p>The helper reads the comma-separated annotation, removes spaces, and skips empty entries. Each accepted custom path becomes a prefix match with the same model-header condition as the default matches. All of these matches go into the same rule. Repeating an identical path here adds no new routing choice.</p>

<p>There was already a <code class="language-plaintext highlighter-rouge">pathSet</code>, and the loop checked whether it contained each normalized path. But the set started empty and stayed empty. The <code class="language-plaintext highlighter-rouge">exists</code> check could never find an earlier entry. Seeing a set and a duplicate-check comment was not enough; I had to look for the write.</p>

<h2 id="where-the-guard-came-from">Where the guard came from</h2>

<p>I traced the helper to <a href="https://github.com/vllm-project/aibrix/pull/1841">#1841</a>, which added custom paths through an annotation. Its <a href="https://github.com/vllm-project/aibrix/commit/e927436255bf48592e282c4a8be558f32d0fee44">first commit</a> used a <code class="language-plaintext highlighter-rouge">pathMap</code> and recorded accepted paths. A <a href="https://github.com/vllm-project/aibrix/commit/de5419074d12d5b3633166c7c366b77148ae25db">follow-up in that PR</a> replaced it with <code class="language-plaintext highlighter-rouge">pathSet</code> and dropped the write. The empty-set guard was already present when #1841 merged.</p>

<p>My first fix filled that gap inside the loop. It kept the first occurrence and recorded each path before appending it. That solved repeated entries within the annotation. Review made me look at the matches already on the route as well.</p>

<h2 id="the-fix">The fix</h2>

<p>This is the relevant part of the merged change, with the existing skip check kept as context:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> pathSlice := strings.Split(paths, ",")
 // avoid duplicates
 pathSet := make(map[string]struct{})
<span class="gi">+for _, match := range httpRoute.Spec.Rules[0].Matches {
+    if match.Path != nil &amp;&amp; match.Path.Value != nil {
+        pathSet[*match.Path.Value] = struct{}{}
+    }
+}
</span> for _, path := range pathSlice {
     // remove illegal space in path
     path = strings.ReplaceAll(path, " ", "")
     if _, exists := pathSet[path]; path == "" || exists {
         continue
     }
<span class="gi">+    pathSet[path] = struct{}{}
</span></code></pre></div></div>

<p>Seeding the set means a custom path already covered by a default match is skipped. Recording each new entry means later repeats in the annotation are skipped too. The first occurrence keeps its place; the change does not reorder the remaining paths.</p>

<h2 id="how-small-is-the-impact">How small is the impact?</h2>

<p>At this change, <code class="language-plaintext highlighter-rouge">modelPaths</code> had 22 entries. The HTTPRoute schema used by the pinned Envoy Gateway dependency allowed 64 matches per rule. That left room for 42 distinct custom paths outside the default set.</p>

<p>The PR records a local check against that schema: 42 such paths plus a repeat made 65 matches before the fix, so the route exceeded the limit. After deduplication, it had 64. Repeating a default path had the same effect. A list of 43 distinct non-default paths still made 65 matches after my change; removing duplicates cannot make that list fit.</p>

<p>On initial creation, a rejected route makes <code class="language-plaintext highlighter-rouge">createHTTPRoute</code> log <code class="language-plaintext highlighter-rouge">Failed to create httproute</code> and return. That is a concrete consequence, but it requires a long list near the cap. I would not describe this as a general routing fix. Most lists only lose useless repeated matches.</p>

<h2 id="the-test">The test</h2>

<p>I added a row to <code class="language-plaintext highlighter-rouge">TestAppendCustomModelRouterPaths</code> in <code class="language-plaintext highlighter-rouge">pkg/controller/modelrouter/modelrouter_controller_test.go</code>. It starts with an <code class="language-plaintext highlighter-rouge">/origin</code> match and supplies <code class="language-plaintext highlighter-rouge">/foo,/bar,/foo, /f oo ,/bar,/origin</code>. The expected paths are <code class="language-plaintext highlighter-rouge">/origin</code>, <code class="language-plaintext highlighter-rouge">/foo</code>, <code class="language-plaintext highlighter-rouge">/bar</code>. The existing table checks also verify the model header on added matches.</p>

<p>The final PR body records the failure before the fix as <code class="language-plaintext highlighter-rouge">expected 3 matches, got 7</code>. Across the package, the verbose run showed 22 top-level tests passing and 1 failing. With the fix, all 23 passed. Those are the recorded local results, rather than a new run for this post.</p>

<h2 id="what-happened-in-review">What happened in review</h2>

<p>A review bot caught the limit of my first version: adding to an initially empty set only deduplicated the annotation itself. A custom entry could still duplicate a default match.</p>

<p>I added the initial scan in <a href="https://github.com/vllm-project/aibrix/commit/f06d171f9eb9cde301d894954599f27c4c1dcce5">f06d171f</a> and extended the repeated-path test with <code class="language-plaintext highlighter-rouge">/origin</code>. That existing match is why the row now checks both kinds of repetition. @varungup90 merged the PR.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>When a guard reads a set, find every write. An empty set can make correct-looking control flow inert.</li>
  <li>Define the initial state before testing a loop. The route already had matches before custom paths arrived.</li>
  <li>Put a number on low impact. Here the cap and default count explain exactly when redundant entries matter.</li>
</ul>

<p>If you have found a guard whose state never changed, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I found a set under // avoid duplicates that nothing wrote to. AIBrix’s appendCustomModelRouterPaths checked it before appending a path, so repeated annotation entries became repeated HTTPRoute matches. I fixed it in PR #3133, merged 2026-10-17 about 1 hour 57 minutes after I opened it. The impact was narrow: identical matches did not change routing, but they used space near the 64-match limit.]]></summary></entry><entry><title type="html">Two route lists, one missing path: image generations in AIBrix</title><link href="https://spicyshark.co/2026/10/17/aibrix-image-generations-route.html" rel="alternate" type="text/html" title="Two route lists, one missing path: image generations in AIBrix" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/17/aibrix-image-generations-route</id><content type="html" xml:base="https://spicyshark.co/2026/10/17/aibrix-image-generations-route.html"><![CDATA[<p>AIBrix’s gateway accepted <code class="language-plaintext highlighter-rouge">POST /v1/images/generations</code>, but with no routing strategy set, the route it handed the request to had no match for that path, for every engine except <code class="language-plaintext highlighter-rouge">xdit</code>. I fixed it by adding one line to a list (<a href="https://github.com/vllm-project/aibrix/pull/3132">PR #3132</a>, merged 2026-10-17, about 55 minutes after I opened it). There was no existing issue for it.</p>

<h2 id="when-it-happens">When it happens</h2>

<p>The image guide in the repo (<code class="language-plaintext highlighter-rouge">docs/source/features/gateway-routing/omni-image.rst</code>) shows a <code class="language-plaintext highlighter-rouge">curl</code> call with a <code class="language-plaintext highlighter-rouge">model</code> field and no <code class="language-plaintext highlighter-rouge">routing-strategy</code> header. On a setup where nothing else sets a strategy, that call reached the gateway, and then the model’s own route had nothing that matched the path. <a href="https://github.com/vllm-project/aibrix/pull/3017">PR #3017</a> says that entries added to <code class="language-plaintext highlighter-rouge">modelPaths</code> “404 for models deployed before an upgrade”. That has a different cause, a stale route, but the same shape: the model’s route lacks the path. My evidence is reading the code and the unit test below, not a request through a running gateway, so I give no status code of my own.</p>

<h2 id="how-a-request-travels">How a request travels</h2>

<p>Without a routing strategy, a request passes through two routes: a shared one, then one per model. The shared route, <code class="language-plaintext highlighter-rouge">aibrix-reserved-router</code>, lists the API paths the gateway serves and sends them to the gateway plugin. That list lives in two files, <code class="language-plaintext highlighter-rouge">config/gateway/gateway-plugin/gateway-plugin.yaml</code> and the Helm chart’s <code class="language-plaintext highlighter-rouge">httproute.yaml</code>. Both already had <code class="language-plaintext highlighter-rouge">/v1/images/generations</code>.</p>

<p>The plugin reads <code class="language-plaintext highlighter-rouge">model</code> from the body and looks for a routing strategy, in this order: one locked in the model’s config, the <code class="language-plaintext highlighter-rouge">routing-strategy</code> request header, the config profile’s strategy, then the <code class="language-plaintext highlighter-rouge">ROUTING_ALGORITHM</code> environment variable. If none is set, the strategy is <code class="language-plaintext highlighter-rouge">RouterNotSet</code>. The plugin then picks no pod. It sets a <code class="language-plaintext highlighter-rouge">model</code> header, and a code comment says <code class="language-plaintext highlighter-rouge">RouterNotSet</code> “delegates routing to the HTTPRoute/k8s Service”.</p>

<p>That second route exists once per model. <code class="language-plaintext highlighter-rouge">buildHTTPRoute</code> in <code class="language-plaintext highlighter-rouge">pkg/controller/modelrouter/modelrouter_controller.go</code> builds it with one match per entry of <code class="language-plaintext highlighter-rouge">modelPaths</code>, and each match also requires the <code class="language-plaintext highlighter-rouge">model</code> header to equal the model’s name. A path that is not in <code class="language-plaintext highlighter-rouge">modelPaths</code> has no match.</p>

<p>One engine was unaffected. For pods whose engine is <code class="language-plaintext highlighter-rouge">xdit</code>, <code class="language-plaintext highlighter-rouge">getEngineBasedPathRewrite</code> turns <code class="language-plaintext highlighter-rouge">/v1/images/generations</code> into <code class="language-plaintext highlighter-rouge">/generate</code>, and <code class="language-plaintext highlighter-rouge">/generate</code> was already in <code class="language-plaintext highlighter-rouge">modelPaths</code>. An engine that keeps the OpenAI path, such as vLLM-Omni, got no match.</p>

<h2 id="why-one-list-lacked-the-path">Why one list lacked the path</h2>

<ul>
  <li><a href="https://github.com/vllm-project/aibrix/pull/1867">#1867</a> (merged 2026-01-05) corrected the shared route from <code class="language-plaintext highlighter-rouge">/v1/image/generations</code> to the OpenAI spelling <code class="language-plaintext highlighter-rouge">/v1/images/generations</code>. Before it, the plugin rewrote the old path to <code class="language-plaintext highlighter-rouge">/generate</code> for every engine, and <code class="language-plaintext highlighter-rouge">/generate</code> was in <code class="language-plaintext highlighter-rouge">modelPaths</code>; it limited that rewrite to <code class="language-plaintext highlighter-rouge">xdit</code> pods. It changed nothing under <code class="language-plaintext highlighter-rouge">pkg/controller/modelrouter</code>. At that commit <code class="language-plaintext highlighter-rouge">modelPaths</code> had eight entries and none was an OpenAI-style image path.</li>
  <li><a href="https://github.com/vllm-project/aibrix/pull/3086">#3086</a> (merged 2026-10-14) added <code class="language-plaintext highlighter-rouge">/v1/images/edits</code> to both shared-route files and to <code class="language-plaintext highlighter-rouge">modelPaths</code>, “so it is served both with and without a routing strategy”. It opened a <code class="language-plaintext highlighter-rouge">// Image</code> group in <code class="language-plaintext highlighter-rouge">modelPaths</code> holding only that path.</li>
</ul>

<p>So before this PR, <code class="language-plaintext highlighter-rouge">/v1/images/edits</code> was in the shared route (both files) and in <code class="language-plaintext highlighter-rouge">modelPaths</code>, and <code class="language-plaintext highlighter-rouge">/v1/images/generations</code> was only in the shared route.</p>

<h2 id="the-fix">The fix</h2>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> 	// Image
<span class="gi">+	"/v1/images/generations",
</span> 	"/v1/images/edits",
</code></pre></div></div>

<p>A comment above <code class="language-plaintext highlighter-rouge">modelPaths</code> says it also sets the order of each route’s matches and that new paths go into their group, so the line sits next to the edits path. Routes of models that already exist are brought up to date by the resync from #3017, so on a build that has both changes, existing models should pick up the new path after an upgrade.</p>

<h2 id="the-test">The test</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestBuildHTTPRouteMatchesImageGenerations</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">route</span> <span class="o">:=</span> <span class="n">buildHTTPRoute</span><span class="p">(</span><span class="s">"default"</span><span class="p">,</span> <span class="s">"demo"</span><span class="p">,</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span><span class="p">)</span>
	<span class="n">paths</span> <span class="o">:=</span> <span class="n">routeMatchPaths</span><span class="p">(</span><span class="n">route</span><span class="p">)</span>
	<span class="k">if</span> <span class="o">!</span><span class="n">slices</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">paths</span><span class="p">,</span> <span class="s">"/v1/images/generations"</span><span class="p">)</span> <span class="p">{</span>
		<span class="n">t</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"per-model HTTPRoute does not match /v1/images/generations, got %v"</span><span class="p">,</span> <span class="n">paths</span><span class="p">)</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The package already had tests that build a route and compare its match paths to <code class="language-plaintext highlighter-rouge">modelPaths</code>, among them <code class="language-plaintext highlighter-rouge">TestInformerEventsCreateHTTPRoute</code> and <code class="language-plaintext highlighter-rouge">TestEnsureHTTPRoutesUpdatesRouteMissingNewPaths</code>. The route and the expected value come from the same list, so a path missing from the list is missing from both sides and the comparison passes. The new test writes the path as a literal.</p>

<p>I checked that locally on the merge commit. With my line removed, 22 of the package’s 23 tests passed, the existing comparison tests among them, and the new one failed with <code class="language-plaintext highlighter-rouge">per-model HTTPRoute does not match /v1/images/generations, got [...]</code>. With the line back, all 23 passed. I counted top-level tests in <code class="language-plaintext highlighter-rouge">go test -v</code> output, since plain <code class="language-plaintext highlighter-rouge">go test</code> prints no counts.</p>

<h2 id="what-the-maintainer-did">What the maintainer did</h2>

<p>A review bot posted a summary 30 seconds after I opened the PR, with nothing to change. @varungup90, who had also merged #3086 and #3017, approved with no review text and merged 13 seconds later. The PR has no other comments and stayed one commit.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>When an entry has to appear in two lists, adding it to one is half a change. Search for a neighbouring entry and compare where each one shows up: here the edits path was in both lists and the generations path in one.</li>
  <li>A test that takes its expected value from the code it checks cannot see an omission. Write the literal.</li>
  <li>Remove the fix and run the suite. If only your new test fails, the old ones were never checking that.</li>
</ul>

<p>If a list in your own code has two homes and one of them drifted, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[AIBrix’s gateway accepted POST /v1/images/generations, but with no routing strategy set, the route it handed the request to had no match for that path, for every engine except xdit. I fixed it by adding one line to a list (PR #3132, merged 2026-10-17, about 55 minutes after I opened it). There was no existing issue for it.]]></summary></entry><entry><title type="html">An environment override kept the source it was meant to replace in OME</title><link href="https://spicyshark.co/2026/10/17/ome-env-override-stale-source.html" rel="alternate" type="text/html" title="An environment override kept the source it was meant to replace in OME" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/17/ome-env-override-stale-source</id><content type="html" xml:base="https://spicyshark.co/2026/10/17/ome-env-override-stale-source.html"><![CDATA[<p>OME could replace an environment variable’s text and leave its old source attached. A ConfigMap-backed entry then carried both a literal value and <code class="language-plaintext highlighter-rouge">valueFrom</code>, making the rendered workload invalid. I fixed the matching-entry assignment in <a href="https://github.com/ome-projects/ome/pull/1231">PR #1231</a>, merged 2026-10-17, about 59 minutes after I opened it.</p>

<h2 id="when-it-happens">When it happens</h2>

<p>An environment entry can provide a literal through <code class="language-plaintext highlighter-rouge">value</code>, or obtain its value through <code class="language-plaintext highlighter-rouge">valueFrom</code>, such as a ConfigMap or Secret reference. I used a ConfigMap reference in the regression test. The troublesome transition was from a sourced entry to a computed literal with the same name.</p>

<p>For the source trace below, I use <code class="language-plaintext highlighter-rouge">spec.deploymentMode: RawDeployment</code>, a runner with a recognized accelerator resource count of one, and no leader or worker blocks. The input is <code class="language-plaintext highlighter-rouge">PARALLELISM_SIZE</code> using <code class="language-plaintext highlighter-rouge">valueFrom.configMapKeyRef</code> in <code class="language-plaintext highlighter-rouge">spec.engine.runner.env</code>. With no <code class="language-plaintext highlighter-rouge">tensorParallelismOverride</code> in the runtime’s matched accelerator configuration, OME computes the literal <code class="language-plaintext highlighter-rouge">"1"</code>.</p>

<p>That overwrite was intentional. <a href="https://github.com/ome-projects/ome/pull/1168">PR #1168</a> documented that the computation replaces any existing <code class="language-plaintext highlighter-rouge">PARALLELISM_SIZE</code> in the runner environment. I was fixing how that promise was implemented, rather than adding a new precedence rule.</p>

<h2 id="how-the-value-travels">How the value travels</h2>

<p>The service schema in <code class="language-plaintext highlighter-rouge">config/crd/full/ome.io_inferenceservices.yaml</code> accepts the runner’s ConfigMap reference. In <code class="language-plaintext highlighter-rouge">pkg/controller/v1beta1/inferenceservice/utils/merging.go</code>, runtime engine defaults are merged with the service’s engine overrides. The service’s values take precedence.</p>

<p>Rendering then reaches <code class="language-plaintext highlighter-rouge">EnginePodSpec</code> in <code class="language-plaintext highlighter-rouge">pkg/render/engine.go</code>. With no leader block, it selects the top-level engine runner. It applies the environment updates, merges resources, and applies accelerator argument overrides. When no accelerator parallelism override owns the setting, it calls <code class="language-plaintext highlighter-rouge">SetParallelismEnvVar</code>.</p>

<p>That function lives in <code class="language-plaintext highlighter-rouge">pkg/render/accelerator.go</code>. It computes the parallelism size from the recognized resource count and the primary and worker counts. It builds a literal <code class="language-plaintext highlighter-rouge">EnvVar</code> named <code class="language-plaintext highlighter-rouge">PARALLELISM_SIZE</code> and passes it to <code class="language-plaintext highlighter-rouge">UpdateEnvVars</code> in <code class="language-plaintext highlighter-rouge">pkg/controller/v1beta1/inferenceservice/utils/container.go</code>.</p>

<p>The old helper searched the container’s environment by name. On a match, it assigned only <code class="language-plaintext highlighter-rouge">container.Env[i].Value</code>. The ConfigMap reference lived in a different field, <code class="language-plaintext highlighter-rouge">ValueFrom</code>, so it survived. The entry now said to use <code class="language-plaintext highlighter-rouge">"1"</code> and to read a source. A Secret reference in the same field would survive that assignment too; the added regression test specifically covers the ConfigMap case.</p>

<h2 id="where-it-fails">Where it fails</h2>

<p>In <code class="language-plaintext highlighter-rouge">pkg/controller/v1beta1/inferenceservice/reconcilers/deployment/deployment_reconciler.go</code>, the rendered pod spec becomes the Deployment’s pod template. For a new Deployment, the reconciler submits a create request. For an existing one, it first attempts a dry-run update, where validation can reject the template before the normal update.</p>

<p>The validation failure documented in my PR is “may not be specified when <code class="language-plaintext highlighter-rouge">value</code> is not empty”. It rejects <code class="language-plaintext highlighter-rouge">valueFrom</code> beside a nonempty literal at the pod template. In this RawDeployment path, the Deployment itself fails admission; I would look there before looking for a container startup failure.</p>

<p>The reconciler returns the error. In <code class="language-plaintext highlighter-rouge">pkg/controller/v1beta1/inferenceservice/controller.go</code>, a non-conflict component reconciliation error becomes a <code class="language-plaintext highlighter-rouge">ComponentReconcileError</code> warning event on the InferenceService. That connects the small helper assignment to the service-level symptom. My evidence here is the source trace and the PR’s tests; I did not validate a live deployment.</p>

<h2 id="why-the-gap-was-easy-to-miss">Why the gap was easy to miss</h2>

<p>The existing update test started with a literal and replaced it with another literal. There was no old source to clear. The helper’s append branch already copied the whole incoming entry, so adding a name and replacing a name did not use the same rule.</p>

<h2 id="the-fix">The fix</h2>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">-// If it exists, update its value.
-container.Env[i].Value = envVar.Value
</span><span class="gi">+// If it exists, replace it.
+container.Env[i] = *envVar
</span></code></pre></div></div>

<p>The replacement copies <code class="language-plaintext highlighter-rouge">Name</code>, <code class="language-plaintext highlighter-rouge">Value</code>, and <code class="language-plaintext highlighter-rouge">ValueFrom</code> together. A literal override has no source pointer, so replacing the entry clears the old one. The search still stops at the first matching name, and the append branch is unchanged.</p>

<h2 id="the-test">The test</h2>

<p>I added a row to <code class="language-plaintext highlighter-rouge">TestUpdateEnvVars</code> in <code class="language-plaintext highlighter-rouge">pkg/controller/v1beta1/inferenceservice/utils/utils_test.go</code>. It starts with <code class="language-plaintext highlighter-rouge">TEST_VAR</code> reading key <code class="language-plaintext highlighter-rouge">value</code> from ConfigMap <code class="language-plaintext highlighter-rouge">settings</code>, then overrides it with <code class="language-plaintext highlighter-rouge">new-value</code>. The expected entry contains the literal and no source.</p>

<p>The results recorded in my PR isolate the change: with only the helper reverted, 1 row failed and 4 passed. With the fix, all 5 passed. The scoped suites passed with 240 leaf cases in <code class="language-plaintext highlighter-rouge">utils</code> and 71 in <code class="language-plaintext highlighter-rouge">render</code>, excluding parent test groups. Those are the PR’s local results, not fresh runs for this post.</p>

<p>I also recorded a validation limit. The pinned full linter could not finish type checking because of incompatible dependency export data. Its fast mode passed. I did not claim a full lint pass or a full-suite run.</p>

<h2 id="what-happened-in-review">What happened in review</h2>

<p>A review bot reported no actionable comments, but separately warned about docstring coverage and flagged redundant comments. GitHub records no inline review comments or approval reviews. @pallasathena92 merged the one-commit PR.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>When fields describe alternative ways to supply a value, replace the choice together. Updating the text alone can preserve an obsolete source.</li>
  <li>Test a change of representation: source to literal, not only literal to literal.</li>
  <li>Follow the rendered value to the first workload API that validates it. The helper’s return is not the end of the failure path.</li>
</ul>

<p>If an override in your own code left an old source attached, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[OME could replace an environment variable’s text and leave its old source attached. A ConfigMap-backed entry then carried both a literal value and valueFrom, making the rendered workload invalid. I fixed the matching-entry assignment in PR #1231, merged 2026-10-17, about 59 minutes after I opened it.]]></summary></entry><entry><title type="html">I moved sql_exporter’s job filters into each request</title><link href="https://spicyshark.co/2026/10/17/sql-exporter-job-filters-per-request.html" rel="alternate" type="text/html" title="I moved sql_exporter’s job filters into each request" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/17/sql-exporter-job-filters-per-request</id><content type="html" xml:base="https://spicyshark.co/2026/10/17/sql-exporter-job-filters-per-request.html"><![CDATA[<p>Two scrapes of sql_exporter could ask for different jobs and receive each other’s metrics. I fixed the handler’s shared filter state in <a href="https://github.com/burningalchemist/sql_exporter/pull/1116">PR #1116</a>, merged 2026-10-17, about 4 hours and 7 minutes after I opened it. The filter was request input, but the handler stored it on the exporter that all requests shared.</p>

<h2 id="when-it-happens">When it happens</h2>

<p>A job groups database targets and the collectors to run against them. The <code class="language-plaintext highlighter-rouge">jobs[]</code> query parameter selects which configured jobs to scrape. It can appear more than once. With jobs named <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">/metrics?jobs[]=a</code> asks for <code class="language-plaintext highlighter-rouge">a</code>; omitting the filter selects all targets.</p>

<p>The failure needs overlapping requests. One scrape asks for <code class="language-plaintext highlighter-rouge">a</code> while another asks for <code class="language-plaintext highlighter-rouge">b</code> on the same exporter. This can happen when separate Prometheus scrape jobs select different jobs from that exporter. A response for <code class="language-plaintext highlighter-rouge">b</code> could include <code class="language-plaintext highlighter-rouge">scrape_errors_total</code> series labelled <code class="language-plaintext highlighter-rouge">job="a"</code>. Less often, the wrong job’s <code class="language-plaintext highlighter-rouge">up</code> and <code class="language-plaintext highlighter-rouge">scrape_duration_seconds</code> series appeared too. My previous sql_exporter post covered the warning fixed in <a href="https://github.com/burningalchemist/sql_exporter/pull/1115">#1115</a>. Here, the problem reached the response’s metrics.</p>

<h2 id="how-a-request-travels">How a request travels</h2>

<p>In <code class="language-plaintext highlighter-rouge">cmd/sql_exporter/main.go</code>, the exporter is constructed and passed into <code class="language-plaintext highlighter-rouge">ExporterHandlerFor</code>. The handler lives in <code class="language-plaintext highlighter-rouge">cmd/sql_exporter/promhttp.go</code>. Before my change, it read <code class="language-plaintext highlighter-rouge">req.URL.Query()["jobs[]"]</code> and called <code class="language-plaintext highlighter-rouge">exporter.SetJobFilters(jobFilters)</code> on that shared exporter.</p>

<p>The next step looked reassuring: <code class="language-plaintext highlighter-rouge">exporter.WithContext(ctx)</code> supplied a copy for gathering. In <code class="language-plaintext highlighter-rouge">exporter.go</code>, the interface describes that copy as single use. But its constructor copies <code class="language-plaintext highlighter-rouge">jobFilters</code> from the original. Another request could change that field after the setter and before the copy. The copy then carried the wrong selection into <code class="language-plaintext highlighter-rouge">filteredTargets</code>, which chooses the database targets to collect.</p>

<p>There was another read later. The handler gathered metrics from the copy and the registry, then called <code class="language-plaintext highlighter-rouge">exporter.FilterScrapeErrorsTotal(mfs)</code> on the original exporter. That method filters the error counter’s series by their <code class="language-plaintext highlighter-rouge">job</code> labels. Another request could replace the filter during gathering, so even a request that collected the right targets could select the wrong error series at the end.</p>

<p>I found two chances to use another request’s input: before copying, and before filtering the error counter. The setter also wrote <code class="language-plaintext highlighter-rouge">jobFilters</code> without a lock while other requests read it. The PR’s race-detector run reported a data race.</p>

<h2 id="why-the-gap-existed">Why the gap existed</h2>

<p><a href="https://github.com/burningalchemist/sql_exporter/pull/433">PR #433</a> added <code class="language-plaintext highlighter-rouge">jobs[]</code> selection and the setter-before-copy order. <a href="https://github.com/burningalchemist/sql_exporter/pull/915">#915</a> added the later error-counter filter. <a href="https://github.com/burningalchemist/sql_exporter/pull/916">#916</a> made target selection return a filtered slice instead of replacing the exporter’s target slice.</p>

<p>Returning a filtered target slice did not make the handler’s <code class="language-plaintext highlighter-rouge">jobFilters</code> request-local. The handler still wrote that field on the shared object and read it again later.</p>

<h2 id="the-fix">The fix</h2>

<p>I took the copy before setting the filter, then used that same copy at both consumers. These are the changed calls, with intervening code omitted:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gi">+exp := exporter.WithContext(ctx)
</span><span class="gd">-if err := exporter.SetJobFilters(jobFilters); err != nil {
</span><span class="gi">+if err := exp.SetJobFilters(jobFilters); err != nil {
</span><span class="gd">-gatherer := prometheus.Gatherers{exporter.WithContext(ctx), registry}
</span><span class="gi">+gatherer := prometheus.Gatherers{exp, registry}
</span><span class="gd">-mfs = exporter.FilterScrapeErrorsTotal(mfs)
</span><span class="gi">+mfs = exp.FilterScrapeErrorsTotal(mfs)
</span></code></pre></div></div>

<p>The handler now sets the request’s selection on <code class="language-plaintext highlighter-rouge">exp</code>, gathers from <code class="language-plaintext highlighter-rouge">exp</code>, and filters the error counter with <code class="language-plaintext highlighter-rouge">exp</code>. In multi-job mode, an unknown job name still returns a 400 response; single target mode still ignores job filters. <code class="language-plaintext highlighter-rouge">WithContext</code> makes a shallow copy, but <code class="language-plaintext highlighter-rouge">SetJobFilters</code> assigns the filter field on that copy rather than changing a shared slice’s contents.</p>

<h2 id="the-test">The test</h2>

<p>I added <code class="language-plaintext highlighter-rouge">TestExporterHandlerJobFilters</code> in <code class="language-plaintext highlighter-rouge">cmd/sql_exporter/promhttp_test.go</code>. It runs two goroutines, each making up to 10 requests through the same handler and stopping early if a check fails. One selects <code class="language-plaintext highlighter-rouge">a</code>, the other <code class="language-plaintext highlighter-rouge">b</code>. The database targets are deliberately unreachable: <code class="language-plaintext highlighter-rouge">up</code> and <code class="language-plaintext highlighter-rouge">scrape_errors_total</code> still carry job labels, so the test can check selection without a working database.</p>

<p>Each response must contain its own job and contain none of the other job. Checking only for foreign series would let an empty error response pass.</p>

<p>My PR records the local before/after results: before the fix, 20 of 20 runs failed; after it, 20 of 20 passed. The race-detector run with <code class="language-plaintext highlighter-rouge">-count=5</code> failed before and passed after. The full suite, its race-detector run, formatting, and vet also passed after the fix. These are the checks recorded with the PR, rather than fresh runs for this post.</p>

<h2 id="what-the-maintainer-did">What the maintainer did</h2>

<p>@burningalchemist approved with “Great catch! Thanks a lot! 👏” and merged 1 minute and 36 seconds later. There were no inline review comments or issue-thread comments. The PR was one commit.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>Find the first shared write, not just the later read. Copying after that write is too late to isolate requests.</li>
  <li>Follow the selection through every consumer. Correct target collection does not prove the later response filter used the same selection.</li>
  <li>Assert that the expected data is present as well as foreign data absent. An empty response should not pass an isolation test.</li>
</ul>

<p>If request input escaped into another response in your own code, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Two scrapes of sql_exporter could ask for different jobs and receive each other’s metrics. I fixed the handler’s shared filter state in PR #1116, merged 2026-10-17, about 4 hours and 7 minutes after I opened it. The filter was request input, but the handler stored it on the exporter that all requests shared.]]></summary></entry><entry><title type="html">Two directories for one adapter: a Hugging Face download fix in AIBrix</title><link href="https://spicyshark.co/2026/10/16/aibrix-lora-adapter-directory.html" rel="alternate" type="text/html" title="Two directories for one adapter: a Hugging Face download fix in AIBrix" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/16/aibrix-lora-adapter-directory</id><content type="html" xml:base="https://spicyshark.co/2026/10/16/aibrix-lora-adapter-directory.html"><![CDATA[<p>AIBrix’s runtime handed the inference engine a different directory for the same LoRA adapter, depending on whether the adapter had been downloaded before. I fixed it by changing one argument (<a href="https://github.com/vllm-project/aibrix/pull/3126">PR #3126</a>, merged 2026-10-16, under three hours after I opened it). I found it while reading the runtime’s artifact service. I found no open issue for it.</p>

<h2 id="two-exits-two-values">Two exits, two values</h2>

<p><code class="language-plaintext highlighter-rouge">download_artifact</code> in <code class="language-plaintext highlighter-rouge">python/aibrix/aibrix/runtime/artifact_service.py</code> has two ways to return a path. On the first call it runs the downloader and returns the path the downloader reports. Once a <code class="language-plaintext highlighter-rouge">.aibrix_download_complete</code> marker exists, it returns <code class="language-plaintext highlighter-rouge">local_path</code>, the directory it gave the downloader. For S3, GCS, TOS and HTTP those are the same directory. For Hugging Face they were not.</p>

<p>The Hugging Face downloader called <code class="language-plaintext highlighter-rouge">snapshot_download(repo_id=..., cache_dir=local_path)</code>. With <code class="language-plaintext highlighter-rouge">cache_dir</code>, <code class="language-plaintext highlighter-rouge">huggingface_hub</code> writes the files under <code class="language-plaintext highlighter-rouge">local_path/models--&lt;org&gt;--&lt;name&gt;/snapshots/&lt;revision&gt;/</code> and returns that snapshot directory. So the first load gave the engine the snapshot directory, and every later load gave it <code class="language-plaintext highlighter-rouge">local_path</code>, which has no <code class="language-plaintext highlighter-rouge">adapter_config.json</code> at its root. Later loads happen: a retry after an engine error, a repeated load request, a restart that keeps the adapter volume, an unload with <code class="language-plaintext highlighter-rouge">cleanup_local=false</code>.</p>

<h2 id="the-fix">The fix</h2>

<p><code class="language-plaintext highlighter-rouge">cache_dir</code> means “keep a cache here”. The option for “put the files in this directory” is <code class="language-plaintext highlighter-rouge">local_dir</code>, and the repo’s model downloader in <code class="language-plaintext highlighter-rouge">aibrix/downloader/huggingface.py</code> already used it. The whole change to <code class="language-plaintext highlighter-rouge">downloaders.py</code>:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">-            # Download model/adapter
</span><span class="gi">+            # Download model/adapter. Use local_dir so the files land directly
+            # in local_path, like the other downloaders. With cache_dir they
+            # would sit under models--&lt;org&gt;--&lt;name&gt;/snapshots/&lt;revision&gt;/, a
+            # path the artifact service does not return once the completion
+            # marker exists.
</span>             download_path = snapshot_download(
                 repo_id=repo_id,
<span class="gd">-                cache_dir=local_path,
</span><span class="gi">+                local_dir=local_path,
</span>                 token=token,
             )
</code></pre></div></div>

<h2 id="evidence">Evidence</h2>

<ul>
  <li>I checked the layouts on <code class="language-plaintext highlighter-rouge">huggingface_hub</code> 0.24.x with a small public repo. <code class="language-plaintext highlighter-rouge">cache_dir=/x/a</code> returned <code class="language-plaintext highlighter-rouge">/x/a/models--&lt;org&gt;--&lt;name&gt;/snapshots/&lt;sha&gt;</code>, and <code class="language-plaintext highlighter-rouge">local_dir=/x/b</code> returned <code class="language-plaintext highlighter-rouge">/x/b</code> with <code class="language-plaintext highlighter-rouge">config.json</code> directly inside.</li>
  <li>With the change, I called <code class="language-plaintext highlighter-rouge">download_artifact</code> twice against the real Hub. Both calls returned the same directory containing the file.</li>
  <li>A new test file uses a stand-in for <code class="language-plaintext highlighter-rouge">snapshot_download</code> that follows both layouts. One test checks that the files land in the adapter’s directory. The other calls <code class="language-plaintext highlighter-rouge">download_artifact</code> twice and compares the results. Before the change both failed: one got the snapshot path where it expected <code class="language-plaintext highlighter-rouge">.../adapter</code>, the other got a different path on the reload. After it, 2 passed. The review also ran all of <code class="language-plaintext highlighter-rouge">tests/runtime</code> with no failures.</li>
</ul>

<p>None of my checks involved an inference engine. That an engine would reject a directory without <code class="language-plaintext highlighter-rouge">adapter_config.json</code> at its root is an inference from the layout. The review listed a real engine loading an adapter among the things it had not run either.</p>

<h2 id="what-review-asked-for">What review asked for</h2>

<p><strong>A flag to add.</strong> A review bot <a href="https://github.com/vllm-project/aibrix/pull/3126#discussion_r4297357847">suggested</a> adding <code class="language-plaintext highlighter-rouge">local_dir_use_symlinks=False</code>, on the worry that <code class="language-plaintext highlighter-rouge">local_dir</code> leaves symlinks into a cache that breaks in containers. I <a href="https://github.com/vllm-project/aibrix/pull/3126#discussion_r4297426435">replied</a> that in the version <code class="language-plaintext highlighter-rouge">poetry.lock</code> pins, 0.24.7, the parameter is deprecated and ignored: <code class="language-plaintext highlighter-rouge">hf_hub_download</code> only warns, and the files are written into <code class="language-plaintext highlighter-rouge">local_path</code> itself. Adding the flag would add the warning and nothing else. The review, posted from the <code class="language-plaintext highlighter-rouge">varungup90</code> account, called that reply accurate and said it had read the 0.24.7 wheel to check. The repo’s model downloader does pass that flag, so the suggestion matched what the code next door does. The review agreed that leaving it out was right.</p>

<p><strong>Two strings for one directory.</strong> The review’s first optional follow-up: <code class="language-plaintext highlighter-rouge">snapshot_download(local_dir=...)</code> returns the real path of <code class="language-plaintext highlighter-rouge">local_dir</code>, while the marker path returns <code class="language-plaintext highlighter-rouge">local_path</code> as given. They are the same directory, but if the adapters directory is reached through a symlink they are different strings. The review suggested returning <code class="language-plaintext highlighter-rouge">local_path</code> from both branches. I <a href="https://github.com/vllm-project/aibrix/pull/3126#issuecomment-6223413663">agreed</a> and offered to add it to the PR or leave it for later, as the review had proposed. The merged PR has its one commit and no <code class="language-plaintext highlighter-rouge">realpath</code> change.</p>

<p><strong>What the fix does not repair.</strong> The second follow-up: a directory downloaded under the old layout, with its marker, keeps returning <code class="language-plaintext highlighter-rouge">local_path</code>. If a deployment keeps adapters on a persistent volume, the review wrote, this “will bite after the upgrade”, and a release note would help. My description already had a Compatibility section saying this. In my reply I added that nothing regresses, because the marker path returned <code class="language-plaintext highlighter-rouge">local_path</code> before the change too, so such an adapter was already broken on its first reload. It needs <code class="language-plaintext highlighter-rouge">cleanup_local</code>, or removing the directory, once.</p>

<p>The review also reproduced the failure with the base file and my tests. It checked that the resume files a download leaves under <code class="language-plaintext highlighter-rouge">.cache/huggingface/</code> cannot make a failed download look complete, because the content check runs only after the downloader returns successfully.</p>

<p>When the review looked, the Python test jobs and image builds were still queued. By the time I commented, they had passed. The review recommended approval but was posted as a comment, and @varungup90 merged the PR 30 minutes after my CI comment.</p>

<h2 id="the-lesson">The lesson</h2>

<ul>
  <li>If a function has a fast path and a slow path, test it by calling it twice and comparing the results. The reload test in this PR is two calls and a comparison.</li>
  <li>An option named <code class="language-plaintext highlighter-rouge">cache_dir</code> is not an output directory. Check what the call returns and where the files land, and read the source of the version your lockfile pins, not the latest documentation.</li>
  <li>When a bot suggests a flag, check whether your pinned version still honors it. Answer with the version and what its code does.</li>
  <li>Say what the change leaves behind. State written by the old code is where a fix goes quiet, and a reviewer is likely to ask about it.</li>
</ul>

<p>If a cached path in your own code came back different the second time, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[AIBrix’s runtime handed the inference engine a different directory for the same LoRA adapter, depending on whether the adapter had been downloaded before. I fixed it by changing one argument (PR #3126, merged 2026-10-16, under three hours after I opened it). I found it while reading the runtime’s artifact service. I found no open issue for it.]]></summary></entry><entry><title type="html">A verify target that rewrote the tree: four review points on a cri-tools script change</title><link href="https://spicyshark.co/2026/10/16/cri-tools-verify-does-not-write.html" rel="alternate" type="text/html" title="A verify target that rewrote the tree: four review points on a cri-tools script change" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/16/cri-tools-verify-does-not-write</id><content type="html" xml:base="https://spicyshark.co/2026/10/16/cri-tools-verify-does-not-write.html"><![CDATA[<p><code class="language-plaintext highlighter-rouge">make verify-docs</code> and <code class="language-plaintext highlighter-rouge">make verify-go-modules</code> in cri-tools rewrote files in your working tree, then failed if <code class="language-plaintext highlighter-rouge">git status</code> showed anything dirty. I changed them so they only check (<a href="https://github.com/kubernetes-sigs/cri-tools/pull/2212">PR #2212</a>, merged 2026-10-16). The PR fixes <a href="https://github.com/kubernetes-sigs/cri-tools/issues/2197">issue #2197</a>, which @SergeyKanzhelev filed, and it follows the plan @saschagrunert wrote in a comment there. @saschagrunert then reviewed the PR and raised four points. All four changed the merged scripts.</p>

<h2 id="what-verify-did-before">What verify did before</h2>

<p>The old <code class="language-plaintext highlighter-rouge">hack/verify-go-modules.sh</code> ran the commands that fix the tree, then asked git whether anything was left over:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go mod tidy <span class="o">&amp;&amp;</span> go mod vendor <span class="o">&amp;&amp;</span> go mod verify
<span class="c"># Test if we can resolve all go modules</span>
go list <span class="nt">-mod</span><span class="o">=</span><span class="nb">readonly</span> <span class="nt">-m</span> all

<span class="nv">STATUS</span><span class="o">=</span><span class="si">$(</span>git status <span class="nt">--porcelain</span><span class="si">)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="nv">$STATUS</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"tree is clean"</span>
<span class="k">else
    </span><span class="nb">echo</span> <span class="s2">"tree is dirty, please commit all changes"</span>
    <span class="nb">echo</span> <span class="s2">""</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$STATUS</span><span class="s2">"</span>
    <span class="nb">exit </span>1
<span class="k">fi</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">verify-docs.sh</code> had the same shape with <code class="language-plaintext highlighter-rouge">go-md2man</code> and <code class="language-plaintext highlighter-rouge">docs/crictl.1</code>. So a verify run edited your files, and it could not pass while you had uncommitted work. The issue puts it this way: “verify commands are both disruptive (override in-place) and cannot run on not-committed files.”</p>

<h2 id="the-plan-i-was-given">The plan I was given</h2>

<p>@saschagrunert’s <a href="https://github.com/kubernetes-sigs/cri-tools/issues/2197#issuecomment-6119691601">comment on the issue</a> chose a narrow version of the third option in the issue. Build into a temp location and diff against the tree, without copying the tree. For modules that means <code class="language-plaintext highlighter-rouge">go mod tidy -diff</code>, <code class="language-plaintext highlighter-rouge">go mod vendor -o "$tmp"</code> plus a diff against <code class="language-plaintext highlighter-rouge">vendor</code>, and <code class="language-plaintext highlighter-rouge">go mod verify</code>. For docs it means running the doc tool into a temp file. Then add a <code class="language-plaintext highlighter-rouge">make docs</code> target next to <code class="language-plaintext highlighter-rouge">make vendor</code>, so that “<code class="language-plaintext highlighter-rouge">verify-*</code> only checks and <code class="language-plaintext highlighter-rouge">docs</code>/<code class="language-plaintext highlighter-rouge">vendor</code> write files”.</p>

<p>The comment also warned that a bare entry in <code class="language-plaintext highlighter-rouge">.gitignore</code> matched a vendored file, so that file was not in git and a plain <code class="language-plaintext highlighter-rouge">diff -r</code> against <code class="language-plaintext highlighter-rouge">vendor/</code> would report it. The PR anchors that entry with a leading slash. The comment suggested committing the vendored file as well. At the version in <code class="language-plaintext highlighter-rouge">go.mod</code> the dependency ships no such file, so there was nothing to commit, and the anchor only stops a later bump from dropping one silently.</p>

<p>I wrote the PR to that plan. The tree stayed untouched, and in the PR description I wrote that I had checked a clean tree passes, that an edited <code class="language-plaintext highlighter-rouge">docs/crictl.md</code>, an untidy <code class="language-plaintext highlighter-rouge">go.mod</code> and an edited <code class="language-plaintext highlighter-rouge">vendor/modules.txt</code> each fail with a hint, and that the tree is byte-identical after every run. The description lists no run from another directory and none with an ignored file in the tree.</p>

<h2 id="what-review-changed">What review changed</h2>

<p>The review was on commit <code class="language-plaintext highlighter-rouge">b8c6f16</code>. The PR head that was merged is <code class="language-plaintext highlighter-rouge">9d7078f</code>; the PR is still one commit.</p>

<ul>
  <li>
    <p><strong>Run from anywhere.</strong> My <code class="language-plaintext highlighter-rouge">verify-go-modules.sh</code> ran <code class="language-plaintext highlighter-rouge">diff -ru vendor "$TMP_DIR"</code>. @saschagrunert pointed out that <code class="language-plaintext highlighter-rouge">vendor</code> is relative to the current directory, so <code class="language-plaintext highlighter-rouge">cd hack &amp;&amp; ./verify-go-modules.sh</code> fails with <code class="language-plaintext highlighter-rouge">diff: vendor: No such file or directory</code> and a misleading “run make vendor” hint. The old <code class="language-plaintext highlighter-rouge">git status</code> check worked from any subdirectory, so I had made it worse. My <code class="language-plaintext highlighter-rouge">verify-docs.sh</code> already had the fix, and I had not copied it into this script. The review comment carried a suggestion block, and the merged script has exactly those lines:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Root of the repository</span>
<span class="nv">REPO_ROOT</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2">/.."</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="si">)</span><span class="s2">"</span>
<span class="nb">cd</span> <span class="s2">"</span><span class="k">${</span><span class="nv">REPO_ROOT</span><span class="k">}</span><span class="s2">"</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>Compare what git will commit.</strong> <a href="https://github.com/kubernetes-sigs/cri-tools/pull/2212#discussion_r4291329961">This comment</a> is the one I learned most from. A diff against <code class="language-plaintext highlighter-rouge">vendor/</code> compares the files on disk, including files git ignores. @saschagrunert listed where that bites: a dependency bump can vendor a file matched by a root rule (<code class="language-plaintext highlighter-rouge">*.iml</code>, <code class="language-plaintext highlighter-rouge">*~</code>, <code class="language-plaintext highlighter-rouge">.*/</code>, <code class="language-plaintext highlighter-rouge">release-notes.md</code>) or by one of the 28 vendored <code class="language-plaintext highlighter-rouge">.gitignore</code> files. Their examples were otel ignoring <code class="language-plaintext highlighter-rouge">gen/</code>, wazero ignoring <code class="language-plaintext highlighter-rouge">build</code> and <code class="language-plaintext highlighter-rouge">dist</code>, and logrus ignoring <code class="language-plaintext highlighter-rouge">vendor</code>. Then <code class="language-plaintext highlighter-rouge">make vendor</code> writes the file and it never gets committed. In the reviewer’s words: “The check then passes locally and fails in CI with a hint that <code class="language-plaintext highlighter-rouge">make vendor</code> can’t fix.” Anchoring the one rule the plan comment named covers one instance. A root <code class="language-plaintext highlighter-rouge">!/vendor/**</code> negation would not cover the rest, because the vendored <code class="language-plaintext highlighter-rouge">.gitignore</code> files take precedence. The reviewer’s answer was a check after the diff, and the merged script contains it as written:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">ignored</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="nv">$TMP_DIR</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> find <span class="nb">.</span> <span class="nt">-type</span> f | <span class="nb">sed</span> <span class="s1">'s|^\./|vendor/|'</span> | git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$REPO_ROOT</span><span class="s2">"</span> check-ignore <span class="nt">--no-index</span> <span class="nt">--stdin</span> <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="s2">"</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="nv">$ignored</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"vendored files are gitignored and would not be committed:"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$ignored</span><span class="s2">"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">exit </span>1
<span class="k">fi</span>
</code></pre></div>    </div>

    <p>I did not have this idea. It is @saschagrunert’s.</p>
  </li>
  <li>
    <p><strong>Do not bury the hint.</strong> On vendor drift, <code class="language-plaintext highlighter-rouge">diff -ru</code> prints a unified diff of every changed vendored file. @saschagrunert measured it: bumping <code class="language-plaintext highlighter-rouge">golang.org/x/net</code> without re-vendoring gives about 1000 lines with <code class="language-plaintext highlighter-rouge">diff -ru</code> and 9 with <code class="language-plaintext highlighter-rouge">diff -rq</code>. “Since the fix is always <code class="language-plaintext highlighter-rouge">make vendor</code>, file names should be enough.” The merged script uses <code class="language-plaintext highlighter-rouge">diff -rq</code>. <code class="language-plaintext highlighter-rouge">verify-docs.sh</code> still uses <code class="language-plaintext highlighter-rouge">diff -u</code>, because it compares one file.</p>
  </li>
  <li>
    <p><strong>One copy of the doc-tool call.</strong> In my first version the Makefile <code class="language-plaintext highlighter-rouge">docs</code> target and <code class="language-plaintext highlighter-rouge">verify-docs.sh</code> each had their own <code class="language-plaintext highlighter-rouge">go run -C hack/tools github.com/cpuguy83/go-md2man/v2 ...</code> line. The review said defining it once keeps the two from drifting, and offered two ways: a variable, or “having the script take the output path and <code class="language-plaintext highlighter-rouge">make docs</code> call it”. I took the second. The merged Makefile has <code class="language-plaintext highlighter-rouge">CRICTL_MAN ?= $(CURDIR)/docs/crictl.1</code>, and the script runs:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make <span class="nt">-s</span> docs <span class="nv">CRICTL_MAN</span><span class="o">=</span><span class="s2">"</span><span class="nv">$TMP_DIR</span><span class="s2">/crictl.1"</span>
</code></pre></div>    </div>
  </li>
</ul>

<p>@saschagrunert approved with “Thanks!” and the merge bot merged the PR the same day.</p>

<h2 id="where-the-review-found-what-i-had-not">Where the review found what I had not</h2>

<p>None of the four points is covered by the checks the description lists. Each of those asks “does this fail when it should”. The review asked other questions. Where is the script run from? What will CI see that my disk does not? How much output does a failure produce? Is the doc-tool call written down once?</p>

<p>The second question is the hard one. A verify script that reads the working tree answers “is this tree consistent”, and CI needs the answer “is this commit consistent”. Those differ whenever the working tree holds anything git ignores. The same split shows up in tests. A test that reads the machine it runs on, such as a global git config, passes on the machine where it was written and fails on another. I met that in <a href="/2026/10/16/git-diff-stats-nul-separated.html">a git-stats change for oh-my-openagent</a>, where the CI run on Windows failed on a config path.</p>

<h2 id="before-a-pr-that-touches-a-verify-script">Before a PR that touches a verify script</h2>

<ul>
  <li>Run it from the repo root, from a subdirectory, and once with an unrelated dirty file. It must behave the same in all three.</li>
  <li>Hash or <code class="language-plaintext highlighter-rouge">git status</code> the tree before and after a run. A verify script that changes anything has failed.</li>
  <li>Write the script’s output to <code class="language-plaintext highlighter-rouge">mktemp -d</code> and remove it in a <code class="language-plaintext highlighter-rouge">trap ... EXIT</code>. Never write into the tree and diff afterwards.</li>
  <li>Compare against what git would commit. Run <code class="language-plaintext highlighter-rouge">git check-ignore --no-index</code> on the list of files the script wrote.</li>
  <li>Make a failure print the hint first and the shortest evidence second. If the fix is one command, file names are enough.</li>
  <li>If a script and a Makefile target both run a tool, make the script call the target.</li>
  <li>When replacing a check, list what the old one did that you did not mean to drop. The old <code class="language-plaintext highlighter-rouge">git status</code> check worked from any directory, and the replacement lost that.</li>
</ul>

<p>If a verify script of yours passed on your machine and failed in CI, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[make verify-docs and make verify-go-modules in cri-tools rewrote files in your working tree, then failed if git status showed anything dirty. I changed them so they only check (PR #2212, merged 2026-10-16). The PR fixes issue #2197, which @SergeyKanzhelev filed, and it follows the plan @saschagrunert wrote in a comment there. @saschagrunert then reviewed the PR and raised four points. All four changed the merged scripts.]]></summary></entry><entry><title type="html">Encrypting every record to read one name: a fix in external-dns</title><link href="https://spicyshark.co/2026/10/16/external-dns-txt-registry-reconcile.html" rel="alternate" type="text/html" title="Encrypting every record to read one name: a fix in external-dns" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/16/external-dns-txt-registry-reconcile</id><content type="html" xml:base="https://spicyshark.co/2026/10/16/external-dns-txt-registry-reconcile.html"><![CDATA[<p>external-dns synchronizes Kubernetes Services and Ingresses with DNS providers. With TXT encryption on, its TXT registry rendered and encrypted the labels of every record it owned, on every <code class="language-plaintext highlighter-rouge">Records()</code> call, only to read the name of the result. I sent the fix (<a href="https://github.com/kubernetes-sigs/external-dns/pull/6805">PR #6805</a>, merged 2026-10-16). In the PR’s benchmark, with encryption on and an in-memory provider, one <code class="language-plaintext highlighter-rouge">Records()</code> call over 1,000 endpoints went from 512.59 ms to 23.47 ms and allocated 96.06% less. Then @mloiseleur found a case that my version, and the code it replaced, both got wrong, and sent a follow-up commit. That commit is part of the merged PR.</p>

<h2 id="what-was-slow">What was slow</h2>

<p>The TXT registry keeps an ownership record for each DNS record it manages: a TXT record whose value holds labels such as the owner ID. <code class="language-plaintext highlighter-rouge">TXTRegistry.Records()</code> reads the provider’s records and attaches those labels to the endpoints. It also has a migration block, marked <code class="language-plaintext highlighter-rouge">TODO: remove this migration logic in some future release</code>, for TXT records created before the current naming format. For every record this instance owns, the block checks that the current-format ownership TXT exists. If it does not, it sets <code class="language-plaintext highlighter-rouge">txt/force-update</code> on the endpoint, so the next apply creates it.</p>

<p>To find the missing ones, the old code called <code class="language-plaintext highlighter-rouge">generateTXTRecord</code> for every owned endpoint. That builds the whole TXT endpoint: it serializes the labels and, with <code class="language-plaintext highlighter-rouge">--txt-encrypt-enabled</code>, encrypts them with AES-GCM. In <code class="language-plaintext highlighter-rouge">endpoint/crypto.go</code>, <code class="language-plaintext highlighter-rouge">EncryptText</code> gzips the text before it seals it. The caller then read <code class="language-plaintext highlighter-rouge">DNSName</code> from the result and dropped the rest.</p>

<p><code class="language-plaintext highlighter-rouge">Records()</code> runs on every reconcile loop, so a deployment with encryption on pays this on every loop, for every owned record. The <code class="language-plaintext highlighter-rouge">--txt-cache-interval</code> flag makes <code class="language-plaintext highlighter-rouge">Records()</code> return a cached result inside the interval, but it is off by default. I measured with it off.</p>

<h2 id="how-i-measured-it">How I measured it</h2>

<p>I added <code class="language-plaintext highlighter-rouge">BenchmarkTXTRegistryRecords</code>. One iteration is one <code class="language-plaintext highlighter-rouge">Records()</code> call in the steady state of a loop. An in-memory provider holds N endpoints, alternating A and CNAME. Every one is owned by the registry and already has its ownership TXT. I ran 1,000 and 10,000 endpoints, with encryption on and off, and compared <code class="language-plaintext highlighter-rouge">master</code> with the branch using benchstat:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go test ./registry/txt -run '^$' -bench BenchmarkTXTRegistryRecords -benchmem -count=10
</code></pre></div></div>

<p>Time and allocation per <code class="language-plaintext highlighter-rouge">Records()</code> call, from the PR description (n=10, p=0.000 for every difference):</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Time, before to after</th>
      <th>Allocated, before to after</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>encryption on, 1,000 endpoints</td>
      <td>512.59 ms to 23.47 ms (-95.42%)</td>
      <td>1137.14 MiB to 44.81 MiB (-96.06%)</td>
    </tr>
    <tr>
      <td>encryption on, 10,000 endpoints</td>
      <td>3076.0 ms to 244.3 ms (-92.06%)</td>
      <td>11369.5 MiB to 447.3 MiB (-96.07%)</td>
    </tr>
    <tr>
      <td>encryption off, 1,000 endpoints</td>
      <td>8.356 ms to 5.552 ms (-33.56%)</td>
      <td>3.071 MiB to 2.590 MiB (-15.68%)</td>
    </tr>
    <tr>
      <td>encryption off, 10,000 endpoints</td>
      <td>90.86 ms to 64.48 ms (-29.03%)</td>
      <td>29.86 MiB to 25.05 MiB (-16.11%)</td>
    </tr>
  </tbody>
</table>

<p>The benchmark checks its own fixture before timing. It reads <code class="language-plaintext highlighter-rouge">Records()</code> once and fails if the count is wrong, a record is not owned by the registry, or any record already carries the force-update flag.</p>

<p>Two limits. The provider is in memory, so there is no API latency. And the ratios are for this one step, not for a whole reconcile loop.</p>

<h2 id="the-fix-before-the-follow-up">The fix before the follow-up</h2>

<p>Compute the name of the ownership record and skip the value. This is the helper on my branch after the first review round, and the call site:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// txtRecordName returns the name generateTXTRecord gives the ownership TXT record of r, without</span>
<span class="c">// rendering its value. It returns "" when no such record can be generated.</span>
<span class="k">func</span> <span class="p">(</span><span class="n">im</span> <span class="o">*</span><span class="n">TXTRegistry</span><span class="p">)</span> <span class="n">txtRecordName</span><span class="p">(</span><span class="n">r</span> <span class="o">*</span><span class="n">endpoint</span><span class="o">.</span><span class="n">Endpoint</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
	<span class="n">txt</span> <span class="o">:=</span> <span class="n">endpoint</span><span class="o">.</span><span class="n">NewEndpoint</span><span class="p">(</span><span class="n">im</span><span class="o">.</span><span class="n">mapper</span><span class="o">.</span><span class="n">ToTXTName</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">DNSName</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">RecordType</span><span class="p">),</span> <span class="n">endpoint</span><span class="o">.</span><span class="n">RecordTypeTXT</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">txt</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="s">""</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="n">txt</span><span class="o">.</span><span class="n">DNSName</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">txtName</span> <span class="o">:=</span> <span class="n">im</span><span class="o">.</span><span class="n">txtRecordName</span><span class="p">(</span><span class="n">ep</span><span class="p">);</span> <span class="n">txtName</span> <span class="o">!=</span> <span class="s">""</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">txtRecordsSet</span><span class="o">.</span><span class="n">Has</span><span class="p">(</span><span class="n">txtName</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">ep</span><span class="o">.</span><span class="n">WithProviderSpecific</span><span class="p">(</span><span class="n">providerSpecificForceUpdate</span><span class="p">,</span> <span class="s">"true"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">endpoint.NewEndpoint</code> trims the trailing dot and returns nil for a label over 63 characters, so the name matches what <code class="language-plaintext highlighter-rouge">generateTXTRecord</code> gives. I added two tests. One checks that the computed name equals the generated record’s name for prefix, suffix, wildcard and record type variants. The other checks that the force-update flag is still set for a missing TXT record with encryption on.</p>

<h2 id="what-review-changed">What review changed</h2>

<p><strong>A return value.</strong> @ivankatliarchuk’s review on 2026-10-14 was marked as changes requested and had one inline comment, which began “Just a nit pick.” It said: “An empty string is already a reliable sentinel, there is no other logic empty - false, non-empty - true.” My helper had returned <code class="language-plaintext highlighter-rouge">(string, bool)</code>. I pushed the change about an hour later; that is the form above. That reviewer commented <code class="language-plaintext highlighter-rouge">/lgtm</code> on 2026-10-15.</p>

<p><strong>A case I missed.</strong> On 2026-10-16, @mloiseleur wrote: “Thanks, the speedup looks good. I opened spicysharkco/external-dns#1 against your branch with a fix for routed records and a few cleanups. Wdyt?” Their pull request, in my fork, had one commit, 5dfe52a. It made two changes.</p>

<p>The first is a bug fix. My check looked names up in <code class="language-plaintext highlighter-rouge">txtRecordsSet</code>, a set of TXT names. The old code had the same set. Take an A record that exists twice under one name, as two routed records with set identifiers <code class="language-plaintext highlighter-rouge">set-1</code> and <code class="language-plaintext highlighter-rouge">set-2</code>. With the default naming, each has a legacy ownership TXT named <code class="language-plaintext highlighter-rouge">foo.example.org</code>. Only <code class="language-plaintext highlighter-rouge">set-1</code> also has the current-format TXT, <code class="language-plaintext highlighter-rouge">a-foo.example.org</code>. The lookup “is there a TXT named <code class="language-plaintext highlighter-rouge">a-foo.example.org</code>?” answers yes because of <code class="language-plaintext highlighter-rouge">set-1</code>’s record. So <code class="language-plaintext highlighter-rouge">set-2</code> is never flagged, and this migration does not create its current-format TXT.</p>

<p>The fix looks the name up in <code class="language-plaintext highlighter-rouge">existingTXTs</code>, a map the registry already filled in the same loop. It keys on name and set identifier, and the commit deletes my duplicate set. The lookup became:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">txtName</span> <span class="o">:=</span> <span class="n">im</span><span class="o">.</span><span class="n">txtRecordName</span><span class="p">(</span><span class="n">ep</span><span class="p">);</span> <span class="n">txtName</span> <span class="o">!=</span> <span class="s">""</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">im</span><span class="o">.</span><span class="n">existingTXTs</span><span class="o">.</span><span class="n">has</span><span class="p">(</span><span class="n">txtName</span><span class="p">,</span> <span class="n">ep</span><span class="o">.</span><span class="n">SetIdentifier</span><span class="p">)</span> <span class="p">{</span>
</code></pre></div></div>

<p>The second change makes <code class="language-plaintext highlighter-rouge">generateTXTRecordWithFilter</code> build its name with <code class="language-plaintext highlighter-rouge">txtRecordName</code>. Before, both functions had their own <code class="language-plaintext highlighter-rouge">ToTXTName</code> call, and only my test said they agreed. <code class="language-plaintext highlighter-rouge">txtRecordName</code> also checks label lengths directly instead of allocating an <code class="language-plaintext highlighter-rouge">Endpoint</code>. The commit message adds that this “stops Records() from logging over-long names on every call”.</p>

<p>The commit also grew the force-update test from two cases to five, including routed records and an alias A record with a legacy <code class="language-plaintext highlighter-rouge">cname-</code> TXT. I fast-forwarded my branch to 5dfe52a at 21:19 UTC. @mloiseleur approved the PR at 21:22, and the merge bot merged it at 21:30.</p>

<p>My tests could not see this. Both of my force-update cases used one A record with no set identifier, so a lookup keyed on name alone passed them. My PR description says the set of flagged records is “unchanged”. That was true of my first version, which kept master’s blind spot. It is not true of the merged code: for routed records it flags a record that master did not. The description still says “unchanged”.</p>

<h2 id="advice-for-a-change-like-this">Advice for a change like this</h2>

<ul>
  <li>“Same behaviour” is only as good as the old behaviour. When you replace a computation but keep the lookup, check what the lookup is keyed on. A set of names works until two records share a name, so write a fixture where two things share part of an identity.</li>
  <li>Two copies of a derivation plus a test for equality is weaker than one function. The test catches drift in CI after the fact; one function cannot drift.</li>
  <li>Put assertions in a benchmark fixture, so the number stays about the path you named. Then write in the PR what the number covers: one call, one input size, in-memory provider, cache off.</li>
  <li>When a reviewer calls a return value redundant, check what the caller already knows. An empty string was already the answer to “no name”.</li>
</ul>

<p>If a lookup of yours turned out to be keyed on less than it should have been, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[external-dns synchronizes Kubernetes Services and Ingresses with DNS providers. With TXT encryption on, its TXT registry rendered and encrypted the labels of every record it owned, on every Records() call, only to read the name of the result. I sent the fix (PR #6805, merged 2026-10-16). In the PR’s benchmark, with encryption on and an in-memory provider, one Records() call over 1,000 endpoints went from 512.59 ms to 23.47 ms and allocated 96.06% less. Then @mloiseleur found a case that my version, and the code it replaced, both got wrong, and sent a follow-up commit. That commit is part of the merged PR.]]></summary></entry><entry><title type="html">Reading git output with -z, and the test that could not run on Windows</title><link href="https://spicyshark.co/2026/10/16/git-diff-stats-nul-separated.html" rel="alternate" type="text/html" title="Reading git output with -z, and the test that could not run on Windows" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/16/git-diff-stats-nul-separated</id><content type="html" xml:base="https://spicyshark.co/2026/10/16/git-diff-stats-nul-separated.html"><![CDATA[<p>oh-my-openagent prints a <code class="language-plaintext highlighter-rouge">[FILE CHANGES SUMMARY]</code> block after a task finishes. It lists modified, created and deleted files with their line counts. The block was wrong for renamed files, for non-ASCII names and for some untracked files. I fixed it by reading git’s output NUL-separated (<a href="https://github.com/code-yeongyu/oh-my-openagent/pull/10133">PR #10133</a>, merged 2026-10-15 as <a href="https://github.com/code-yeongyu/oh-my-openagent/commit/6c161a1383b0c6fa928fe71ca1ea08962d4cff5a">6c161a1</a>).</p>

<p>The parsing was not what review changed. The first Windows CI run failed on the test setup I had written, and the committed bundle went stale. @code-yeongyu diagnosed both. This post is about those two.</p>

<h2 id="what-the-summary-got-wrong">What the summary got wrong</h2>

<p><code class="language-plaintext highlighter-rouge">collectGitDiffStats</code> ran three commands without <code class="language-plaintext highlighter-rouge">-z</code>: <code class="language-plaintext highlighter-rouge">git diff --numstat</code>, <code class="language-plaintext highlighter-rouge">git status --porcelain</code> and <code class="language-plaintext highlighter-rouge">git ls-files --others</code>. It then joined the three lists by path. Git’s line output did not match how the code joined the three lists. The PR description lists the results:</p>

<ul>
  <li>A renamed file appeared under <code class="language-plaintext highlighter-rouge">old_name.txt =&gt; new_name.txt</code>, or <code class="language-plaintext highlighter-rouge">d/{sub =&gt; sub2}/x.txt</code> when only a directory changed, instead of its new name.</li>
  <li>A path with non-ASCII characters or a tab came out C-quoted, for example <code class="language-plaintext highlighter-rouge">"caf\303\251.ts"</code>. An untracked one also lost its line count (+0), because the quoted name could not be read from disk.</li>
  <li>An untracked file with a space in its name, or inside a new untracked directory, was listed under “Modified files” instead of “Created files”. Status collapses a new directory to a single <code class="language-plaintext highlighter-rouge">?? newdir/</code> entry, so its status entry did not match the per-file path.</li>
</ul>

<p>An excerpt of the before and after output from the PR description:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before (dev):
Modified files:
  d/{sub =&gt; sub2}/x.txt  (+0, -0)
  old_name.txt =&gt; new_name.txt  (+1, -0)
  newdir/one.txt  (+1, -0)
  newdir/two.txt  (+2, -0)
  with space.ts  (+2, -0)

Created files:
  "caf\303\251.ts"  (+0)

After (this branch):
Modified files:
  d/sub2/x.txt  (+0, -0)
  new_name.txt  (+1, -0)

Created files:
  café.ts  (+3)
  newdir/one.txt  (+1)
  newdir/two.txt  (+2)
  with space.ts  (+2)
</code></pre></div></div>

<p>The PR follows <a href="https://github.com/code-yeongyu/oh-my-openagent/pull/5690">#5690</a>, which mapped <code class="language-plaintext highlighter-rouge">R</code> status lines to the destination path in the line parser. That fixed the status side. The numstat side still printed <code class="language-plaintext highlighter-rouge">old =&gt; new</code>.</p>

<h2 id="the-fix">The fix</h2>

<p>All three commands now read NUL-separated, with rename detection pinned on so <code class="language-plaintext highlighter-rouge">diff.renames</code> and <code class="language-plaintext highlighter-rouge">status.renames</code> in the repository config cannot change the result:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git diff --numstat -z -M HEAD
git status --porcelain -z --find-renames --untracked-files=all
git ls-files -z --others --exclude-standard
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--untracked-files=all</code> gives every untracked file its own status entry, which fixes the new-directory case. The untracked line counts the collector builds by hand are NUL-terminated too.</p>

<p>A rename is the awkward record. With <code class="language-plaintext highlighter-rouge">-z</code>, numstat prints <code class="language-plaintext highlighter-rouge">added\tremoved\t\0old\0new\0</code>: the path field is empty and the two names follow as separate fields. The new parser handles that with one extra branch:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">path</span> <span class="o">=</span> <span class="nx">field</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">secondTab</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">path</span> <span class="o">===</span> <span class="dl">""</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">index</span> <span class="o">+=</span> <span class="mi">2</span>
  <span class="nx">path</span> <span class="o">=</span> <span class="nx">fields</span><span class="p">[</span><span class="nx">index</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A rename is reported once, under the destination. A binary file shows <code class="language-plaintext highlighter-rouge">-</code> for both counts, and <code class="language-plaintext highlighter-rouge">toCount</code> turns that into 0. The status parser does the same job for porcelain records: for a rename or copy it skips the source field, and it maps a copy destination to “added”, because <code class="language-plaintext highlighter-rouge">git diff -M</code> without <code class="language-plaintext highlighter-rouge">-C</code> reports a copy as a new file.</p>

<p>One consequence I had to handle. <code class="language-plaintext highlighter-rouge">-z</code> output is never quoted, so a file name that holds a newline would print as extra lines inside the summary. So <code class="language-plaintext highlighter-rouge">formatFileChanges</code> now prints a path with a control character, U+2028 or U+2029 JSON-quoted with escapes, and leaves every other path unchanged. <code class="language-plaintext highlighter-rouge">café.ts</code>, <code class="language-plaintext highlighter-rouge">한글.md</code> and <code class="language-plaintext highlighter-rouge">with space.ts</code> stay as they are.</p>

<h2 id="how-the-test-catches-it">How the test catches it</h2>

<p>The test file, <code class="language-plaintext highlighter-rouge">collect-git-diff-stats.real-git.test.ts</code>, has 12 cases that run against throwaway repositories with real git. They cover untracked names with spaces and non-ASCII characters, a staged rename with edits, a path with a tab, untracked files in a new directory, an intent-to-add rename, a copy with <code class="language-plaintext highlighter-rouge">status.renames=copies</code>, a binary rename, and <code class="language-plaintext highlighter-rouge">diff.renames</code> and <code class="language-plaintext highlighter-rouge">status.renames</code> set to false.</p>

<p>I ran the new tests against the unmodified <code class="language-plaintext highlighter-rouge">dev</code> sources and against the branch. On <code class="language-plaintext highlighter-rouge">dev</code>: 2 pass, 18 fail, and all 12 real-git tests fail. On the branch: 20 pass, 0 fail. The 20 are the 12 real-git tests and 8 formatter tests, and the 2 that pass on <code class="language-plaintext highlighter-rouge">dev</code> are formatter tests that check plain paths stay unchanged.</p>

<p>The setup keeps each test away from the machine’s git configuration. I set <code class="language-plaintext highlighter-rouge">GIT_CONFIG_NOSYSTEM</code>, author and committer names, and I pointed <code class="language-plaintext highlighter-rouge">GIT_CONFIG_GLOBAL</code> at <code class="language-plaintext highlighter-rouge">os.devNull</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">devNull</span><span class="p">,</span> <span class="nx">tmpdir</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:os</span><span class="dl">"</span>

<span class="kd">const</span> <span class="nx">HERMETIC_GIT_ENV</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">GIT_CONFIG_GLOBAL</span><span class="p">:</span> <span class="nx">devNull</span><span class="p">,</span>
  <span class="na">GIT_CONFIG_NOSYSTEM</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1</span><span class="dl">"</span><span class="p">,</span>
  <span class="c1">// author and committer names ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>CI reported two failing jobs, and only the Windows test job failed on this setup.</p>

<h2 id="what-review-changed">What review changed</h2>

<p>@code-yeongyu approved the workflow runs, and wrote that they “read the diff before approving CI”. They called the change “a careful fix” and said the real-git test “makes it believable”. Then CI ran, and they reported two failing jobs, “both small and both in this PR”.</p>

<ul>
  <li>
    <p><strong>The Windows <code class="language-plaintext highlighter-rouge">git init</code> failure.</strong> Every case in the real-git test file failed at <code class="language-plaintext highlighter-rouge">git init</code> on <code class="language-plaintext highlighter-rouge">windows-latest</code> with <code class="language-plaintext highlighter-rouge">fatal: unable to access '\\.\nul': Invalid argument</code>. In their words, <code class="language-plaintext highlighter-rouge">GIT_CONFIG_GLOBAL: devNull</code> “resolves to <code class="language-plaintext highlighter-rouge">\\.\nul</code> on Windows, and git can’t open that as a config file”. They proposed “Pointing <code class="language-plaintext highlighter-rouge">GIT_CONFIG_GLOBAL</code> at an empty file inside the temp dir (written in <code class="language-plaintext highlighter-rouge">beforeEach</code>)”, which “works on every OS and keeps the test hermetic”. That was their idea. I implemented it in <a href="https://github.com/code-yeongyu/oh-my-openagent/commit/4d1980e1ec7489c412b06d1a8f23b9cd7895caf2">4d1980e</a>:</p>

    <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">root</span> <span class="o">=</span> <span class="nx">mkdtempSync</span><span class="p">(</span><span class="nx">join</span><span class="p">(</span><span class="nx">tmpdir</span><span class="p">(),</span> <span class="dl">"</span><span class="s2">omo-diff-stats-</span><span class="dl">"</span><span class="p">))</span>
<span class="nx">writeFileSync</span><span class="p">(</span><span class="nx">join</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="dl">"</span><span class="s2">gitconfig</span><span class="dl">"</span><span class="p">),</span> <span class="dl">""</span><span class="p">)</span>
<span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">GIT_CONFIG_GLOBAL</span> <span class="o">=</span> <span class="nx">join</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="dl">"</span><span class="s2">gitconfig</span><span class="dl">"</span><span class="p">)</span>
<span class="nx">repo</span> <span class="o">=</span> <span class="nx">join</span><span class="p">(</span><span class="nx">root</span><span class="p">,</span> <span class="dl">"</span><span class="s2">repo</span><span class="dl">"</span><span class="p">)</span>
<span class="nx">mkdirSync</span><span class="p">(</span><span class="nx">repo</span><span class="p">)</span>
</code></pre></div>    </div>

    <p><code class="language-plaintext highlighter-rouge">afterEach</code> now removes <code class="language-plaintext highlighter-rouge">root</code>, which holds both the config file and the repository.</p>
  </li>
  <li>
    <p><strong>The stale bundle.</strong> The second failing job was <code class="language-plaintext highlighter-rouge">senpi-compatibility (ubuntu-latest)</code>: <code class="language-plaintext highlighter-rouge">omo-senpi extension build is not current: stale-output</code> for <code class="language-plaintext highlighter-rouge">packages/omo-senpi/plugin/extensions/omo-task.js</code>. @code-yeongyu explained that <code class="language-plaintext highlighter-rouge">packages/utils</code> is bundled into the committed extensions, so the bundle needs rebuilding, and that CI checks it “on Linux with node 24 and bun 1.4.2”, so a bundle built on another OS may not match byte for byte. They also noted the branch was about 100 commits behind <code class="language-plaintext highlighter-rouge">dev</code>, and that merging <code class="language-plaintext highlighter-rouge">dev</code> before rebuilding “avoids a second round”. My change added exports to <code class="language-plaintext highlighter-rouge">packages/utils/src/git-worktree/index.ts</code>, and the bundle carries a digest of every module the bundler read, so the digest changed. The bundled code did not.</p>
  </li>
  <li>
    <p><strong>An offer I did not need.</strong> They wrote that if I would rather not deal with the Linux bundle step, they could push the merge, the test fix and the rebuilt bundle on top of my branch, “keeping your commits and credit”. I did the work myself and reported it in a <a href="https://github.com/code-yeongyu/oh-my-openagent/pull/10133#issuecomment-6182750618">comment on the PR</a>.</p>
  </li>
</ul>

<p>I did the steps in the opposite order to the advice. I rebuilt the bundle in the same commit as the Windows fix, before merging <code class="language-plaintext highlighter-rouge">dev</code>. The merge then conflicted in the bundle and in <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code>. I resolved it and rebuilt the bundle on Linux in <a href="https://github.com/code-yeongyu/oh-my-openagent/commit/6b67ea718351dde8744f458dee4ce0c6240ae999">6b67ea7</a>.</p>

<p><code class="language-plaintext highlighter-rouge">dev</code> kept moving while the PR waited. I merged it into the branch three more times before the PR was merged, in 6b9b5b9, d547fbf and 78ca1f0. Two of the four merges conflicted in <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> and three in <code class="language-plaintext highlighter-rouge">omo-task.js</code>, and the bundle was rebuilt on Linux after three of them. The final diff of <code class="language-plaintext highlighter-rouge">omo-task.js</code> is one line added and one removed.</p>

<p>The review result @code-yeongyu posted for <code class="language-plaintext highlighter-rouge">e16a360</code> was “approved, with no blocking findings”. It said the <code class="language-plaintext highlighter-rouge">-z</code> parsing “handles renames (the destination path is kept), binary <code class="language-plaintext highlighter-rouge">-</code> counts and paths with tabs”. It said the real-git test “is hermetic: it uses a per-test empty <code class="language-plaintext highlighter-rouge">GIT_CONFIG_GLOBAL</code> and <code class="language-plaintext highlighter-rouge">GIT_CONFIG_NOSYSTEM</code>”. And it said that in the rebuilt <code class="language-plaintext highlighter-rouge">omo-task.js</code> “only the header digest changed; the bundled code is byte-identical”. The PR was merged on 2026-10-15, after the last <code class="language-plaintext highlighter-rouge">dev</code> merge and rebuild.</p>

<h2 id="what-i-did-not-check">What I did not check</h2>

<p><code class="language-plaintext highlighter-rouge">git status --find-renames</code> needs git 2.18, the release that added <code class="language-plaintext highlighter-rouge">status.renames</code>. On older git the status call fails, the existing <code class="language-plaintext highlighter-rouge">catch</code> returns an empty list, and the summary reads “No file changes detected.” I wrote that in the PR description and added that I had not tested it on old git.</p>

<h2 id="the-lesson">The lesson</h2>

<p>Two checks to make before the first push.</p>

<p><strong>Anything the platform supplies is not a constant.</strong> <code class="language-plaintext highlighter-rouge">os.devNull</code> is a path, and it is a different path on Windows. A test that needs “an empty file” should make one in its own temp directory. This costs three lines, and it removes a platform branch.</p>

<p><strong>CI is more than the test suite.</strong> I ran the tests I had written and the type check. The <code class="language-plaintext highlighter-rouge">senpi-compatibility</code> job checks a built file that lives in the repository and depends on files I had touched. Read the workflow list before pushing, find every job that compares a committed artifact with a rebuild, and run its check locally.</p>

<p>For committed build output, merge the target branch first, rebuild last, and make the rebuild a single command, because the target branch moves while the PR waits.</p>

<p>If you have been bitten by <code class="language-plaintext highlighter-rouge">\\.\nul</code> or a stale bundle, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[oh-my-openagent prints a [FILE CHANGES SUMMARY] block after a task finishes. It lists modified, created and deleted files with their line counts. The block was wrong for renamed files, for non-ASCII names and for some untracked files. I fixed it by reading git’s output NUL-separated (PR #10133, merged 2026-10-15 as 6c161a1).]]></summary></entry><entry><title type="html">Two goroutines, one receive: a leak in the k3s tunnel proxy</title><link href="https://spicyshark.co/2026/10/16/k3s-proxy-goroutine-leak.html" rel="alternate" type="text/html" title="Two goroutines, one receive: a leak in the k3s tunnel proxy" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicyshark.co/2026/10/16/k3s-proxy-goroutine-leak</id><content type="html" xml:base="https://spicyshark.co/2026/10/16/k3s-proxy-goroutine-leak.html"><![CDATA[<p>k3s leaked a goroutine, its 32 KiB buffer and both connections whenever the two copy directions of a tunnel connection failed at about the same time. The reporter counted 254 stuck goroutines after about 20.5 hours on one single-node server. I sent the fix (<a href="https://github.com/k3s-io/k3s/pull/14811">PR #14811</a>, merged 2026-10-15): it deletes a <code class="language-plaintext highlighter-rouge">bool</code> and a method, and gives a channel a buffer of 2. @bheemareddy reported the bug in <a href="https://github.com/k3s-io/k3s/issues/14787">issue #14787</a>, with the cause, a standalone reproducer and a <code class="language-plaintext highlighter-rouge">sync.Once</code> fix.</p>

<h2 id="which-proxy">Which proxy</h2>

<p>It is <code class="language-plaintext highlighter-rouge">Proxy(lconn, rconn io.ReadWriteCloser) error</code> in <code class="language-plaintext highlighter-rouge">pkg/daemons/control/proxy/proxy.go</code>. It copies bytes both ways between two connections, one goroutine and one 32 KiB buffer per direction. The caller is <code class="language-plaintext highlighter-rouge">TunnelServer.serveConnect</code> in <code class="language-plaintext highlighter-rouge">pkg/daemons/control/tunnel.go</code>. It answers HTTP <code class="language-plaintext highlighter-rouge">CONNECT</code>: dial the backend, hijack the client connection, call <code class="language-plaintext highlighter-rouge">proxy.Proxy</code>. It serves the apiserver egress selector, the tunnel the apiserver uses to reach kubelets (<a href="https://docs.k3s.io/networking/basic-network-options">k3s docs</a>) and, in the reporter’s setup, aggregated APIServices.</p>

<h2 id="the-symptom">The symptom</h2>

<p>The issue has a goroutine dump from that server. Every leaked goroutine had the same stack, parked in <code class="language-plaintext highlighter-rouge">chan send</code>; one showed as <code class="language-plaintext highlighter-rouge">goroutine 17335244 [chan send, 467 minutes]:</code>. The count grew by roughly 11 per hour. The server log showed lines like <code class="language-plaintext highlighter-rouge">Proxy error: write failed: ... write: connection reset by peer</code>. I did not find the leak; these numbers are the report’s.</p>

<h2 id="why-it-never-returned">Why it never returned</h2>

<p>The code before the fix (<a href="https://github.com/k3s-io/k3s/blob/22b0fb2309ab26e37d5ce93a3c70ada4e49a99ab/pkg/daemons/control/proxy/proxy.go#L26-L40">lines 26-40</a>):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	<span class="k">go</span> <span class="n">p</span><span class="o">.</span><span class="n">pipe</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">lconn</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">rconn</span><span class="p">)</span>
	<span class="k">go</span> <span class="n">p</span><span class="o">.</span><span class="n">pipe</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">rconn</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">lconn</span><span class="p">)</span>
	<span class="k">return</span> <span class="o">&lt;-</span><span class="n">p</span><span class="o">.</span><span class="n">errc</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">proxy</span><span class="p">)</span> <span class="n">err</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">done</span> <span class="p">{</span>
		<span class="k">return</span>
	<span class="p">}</span>
	<span class="k">if</span> <span class="o">!</span><span class="n">errors</span><span class="o">.</span><span class="n">Is</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="n">io</span><span class="o">.</span><span class="n">EOF</span><span class="p">)</span> <span class="p">{</span>
		<span class="n">logrus</span><span class="o">.</span><span class="n">Warnf</span><span class="p">(</span><span class="s">"Proxy error: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="n">p</span><span class="o">.</span><span class="n">done</span> <span class="o">=</span> <span class="no">true</span>
	<span class="n">p</span><span class="o">.</span><span class="n">errc</span> <span class="o">&lt;-</span> <span class="n">err</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">errc</code> was unbuffered and <code class="language-plaintext highlighter-rouge">Proxy</code> receives from it once. <code class="language-plaintext highlighter-rouge">done</code> was meant to keep a second goroutine from sending, but it is a plain bool written by both goroutines. Both can pass <code class="language-plaintext highlighter-rouge">if p.done</code> before either sets it. The <code class="language-plaintext highlighter-rouge">Warnf</code> call sits in that gap.</p>

<p>The goroutine that leaks is not stuck in <code class="language-plaintext highlighter-rouge">Read</code> or <code class="language-plaintext highlighter-rouge">Write</code>. Those already failed. It is parked on <code class="language-plaintext highlighter-rouge">p.errc &lt;- err</code>. The <a href="https://go.dev/ref/spec#Send_statements">Go spec</a> says “A send on an unbuffered channel can proceed if a receiver is ready.” <code class="language-plaintext highlighter-rouge">Proxy</code> has returned, so none ever is. The deferred <code class="language-plaintext highlighter-rouge">Close</code> calls wake a goroutine blocked in <code class="language-plaintext highlighter-rouge">Read</code> or <code class="language-plaintext highlighter-rouge">Write</code>, not one blocked on a channel. Per the PR description, it holds its buffer and both connections.</p>

<h2 id="the-fix">The fix</h2>

<p>From the merged diff of <code class="language-plaintext highlighter-rouge">pkg/daemons/control/proxy/proxy.go</code> (excerpt):</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">-		errc:  make(chan error),
</span><span class="gi">+		errc:  make(chan error, 2), // one send per pipe goroutine, so neither can block
</span> 	}
 
 	defer p.rconn.Close()
 	defer p.lconn.Close()
 	go p.pipe(p.lconn, p.rconn)
 	go p.pipe(p.rconn, p.lconn)
<span class="gd">-	return &lt;-p.errc
-}
-
-func (p *proxy) err(err error) {
-	if p.done {
-		return
-	}
-	if !errors.Is(err, io.EOF) {
</span><span class="gi">+	err := &lt;-p.errc
+	if !isConnTeardown(err) {
</span> 		logrus.Warnf("Proxy error: %v", err)
 	}
<span class="gd">-	p.done = true
-	p.errc &lt;- err
</span><span class="gi">+	return err
+}
+
+// isConnTeardown reports errors that are expected when one side closes or resets the connection.
+func isConnTeardown(err error) bool {
+	return errors.Is(err, io.EOF) ||
+		errors.Is(err, net.ErrClosed) ||
+		errors.Is(err, syscall.ECONNRESET) ||
+		errors.Is(err, syscall.EPIPE)
</span> }
</code></pre></div></div>

<p>In <code class="language-plaintext highlighter-rouge">pipe</code>, <code class="language-plaintext highlighter-rouge">p.err(errors.WithMessage(err, "read failed"))</code> became <code class="language-plaintext highlighter-rouge">p.errc &lt;- err</code>, and the same for the write. Two slots for two senders means no send can block, whatever <code class="language-plaintext highlighter-rouge">Proxy</code> does. The guard is gone, so the race is gone. I kept “return on the first error” instead of waiting for both directions as remotedialer does. Per the PR description, that way <code class="language-plaintext highlighter-rouge">Proxy</code> cannot hang on a connection whose <code class="language-plaintext highlighter-rouge">Close</code> does not unblock a pending <code class="language-plaintext highlighter-rouge">Read</code>.</p>

<h2 id="what-review-changed">What review changed</h2>

<ul>
  <li>@bheemareddy commented inline that the warning is noisy and should be debug, with a sketch that used a helper named <code class="language-plaintext highlighter-rouge">isConnTeardown</code>. Their own PR, #14810, took the remotedialer approach. They called this one “smaller fix and easily back portable”, and @brandond went with it.</li>
  <li>@brandond liked filtering <code class="language-plaintext highlighter-rouge">ECONNRESET</code> and <code class="language-plaintext highlighter-rouge">EPIPE</code> and suggested dropping the <code class="language-plaintext highlighter-rouge">errors.WithMessage</code> wrap. They gave no reason. Their <code class="language-plaintext highlighter-rouge">isConnTeardown</code> used a type assertion, <code class="language-plaintext highlighter-rouge">err.(syscall.Errno)</code>.</li>
  <li>I replied with <code class="language-plaintext highlighter-rouge">errors.As</code>, because a TCP error is not a bare <code class="language-plaintext highlighter-rouge">syscall.Errno</code>. That part was my suggestion, not the reviewer’s. On a loopback reset I got a <code class="language-plaintext highlighter-rouge">*net.OpError</code>, the assertion gave <code class="language-plaintext highlighter-rouge">false</code>, and <code class="language-plaintext highlighter-rouge">errors.Is(err, syscall.ECONNRESET)</code> gave <code class="language-plaintext highlighter-rouge">true</code>.</li>
  <li>Then brandond said the intent was to ignore normal closure, so these errors should not be logged even at debug. They pushed that and dropped the log-level test. The merged helper uses <code class="language-plaintext highlighter-rouge">errors.Is</code>, and it also keeps <code class="language-plaintext highlighter-rouge">net.ErrClosed</code>, which I had dropped.</li>
</ul>

<h2 id="how-the-test-catches-it">How the test catches it</h2>

<p><code class="language-plaintext highlighter-rouge">Test_UnitProxyBothDirectionsFail</code> uses a <code class="language-plaintext highlighter-rouge">failConn</code> whose <code class="language-plaintext highlighter-rouge">Read</code> and <code class="language-plaintext highlighter-rouge">Write</code> always fail. Both directions fail together:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	<span class="n">conn</span> <span class="o">:=</span> <span class="n">failConn</span><span class="p">{</span><span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"i/o error"</span><span class="p">)}</span>
	<span class="n">before</span> <span class="o">:=</span> <span class="n">runtime</span><span class="o">.</span><span class="n">NumGoroutine</span><span class="p">()</span>
	<span class="k">for</span> <span class="k">range</span> <span class="m">10000</span> <span class="p">{</span>
		<span class="n">_</span> <span class="o">=</span> <span class="n">Proxy</span><span class="p">(</span><span class="n">conn</span><span class="p">,</span> <span class="n">conn</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="n">g</span><span class="o">.</span><span class="n">Eventually</span><span class="p">(</span><span class="n">runtime</span><span class="o">.</span><span class="n">NumGoroutine</span><span class="p">,</span> <span class="m">5</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span><span class="o">.</span><span class="n">Should</span><span class="p">(</span><span class="n">gomega</span><span class="o">.</span><span class="n">BeNumerically</span><span class="p">(</span><span class="s">"&lt;="</span><span class="p">,</span> <span class="n">before</span><span class="p">))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Eventually</code> is there because the second goroutine exits just after <code class="language-plaintext highlighter-rouge">Proxy</code> returns. I ran this test against the old <code class="language-plaintext highlighter-rouge">proxy.go</code>. It failed five runs in five, leaving roughly 900 to 1,900 goroutines against a start of 2, depending on the machine, and <code class="language-plaintext highlighter-rouge">-race</code> printed <code class="language-plaintext highlighter-rouge">WARNING: DATA RACE</code>. Against the merged code, <code class="language-plaintext highlighter-rouge">go test -race -count=20</code> passed. With <code class="language-plaintext highlighter-rouge">GOMAXPROCS=1</code> the old code passed, as the PR warns, so run leak tests on more than one core.</p>

<h2 id="a-leak-check-you-can-copy">A leak check you can copy</h2>

<p>The old and new shape in miniature. It makes 10,000 calls on failing connections and counts goroutines:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"errors"</span>
	<span class="s">"fmt"</span>
	<span class="s">"io"</span>
	<span class="s">"os"</span>
	<span class="s">"runtime"</span>
	<span class="s">"time"</span>
<span class="p">)</span>

<span class="c">// failConn fails every Read and Write, like a connection that was reset.</span>
<span class="k">type</span> <span class="n">failConn</span> <span class="k">struct</span><span class="p">{}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">failConn</span><span class="p">)</span> <span class="n">Read</span><span class="p">([]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>  <span class="p">{</span> <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"reset"</span><span class="p">)</span> <span class="p">}</span>
<span class="k">func</span> <span class="p">(</span><span class="n">failConn</span><span class="p">)</span> <span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"reset"</span><span class="p">)</span> <span class="p">}</span>

<span class="k">func</span> <span class="n">pipe</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span> <span class="n">io</span><span class="o">.</span><span class="n">ReadWriter</span><span class="p">,</span> <span class="n">report</span> <span class="k">func</span><span class="p">(</span><span class="kt">error</span><span class="p">))</span> <span class="p">{</span>
	<span class="n">buf</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="m">1</span><span class="o">&lt;&lt;</span><span class="m">15</span><span class="p">)</span>
	<span class="k">for</span> <span class="p">{</span>
		<span class="n">n</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">src</span><span class="o">.</span><span class="n">Read</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">dst</span><span class="o">.</span><span class="n">Write</span><span class="p">(</span><span class="n">buf</span><span class="p">[</span><span class="o">:</span><span class="n">n</span><span class="p">])</span>
		<span class="p">}</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="n">report</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
	<span class="p">}</span>
<span class="p">}</span>

<span class="c">// proxy returns the first error from either direction. With old set it has</span>
<span class="c">// the pre-fix shape: an unbuffered channel and a plain bool as the guard.</span>
<span class="k">func</span> <span class="n">proxy</span><span class="p">(</span><span class="n">old</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
	<span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="o">:=</span> <span class="n">failConn</span><span class="p">{},</span> <span class="n">failConn</span><span class="p">{}</span>
	<span class="n">errc</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span>
	<span class="n">report</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">errc</span> <span class="o">&lt;-</span> <span class="n">err</span> <span class="p">}</span>
	<span class="k">if</span> <span class="n">old</span> <span class="p">{</span>
		<span class="n">errc</span> <span class="o">=</span> <span class="nb">make</span><span class="p">(</span><span class="k">chan</span> <span class="kt">error</span><span class="p">)</span>
		<span class="n">done</span> <span class="o">:=</span> <span class="no">false</span>
		<span class="n">report</span> <span class="o">=</span> <span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
			<span class="k">if</span> <span class="n">done</span> <span class="p">{</span>
				<span class="k">return</span>
			<span class="p">}</span>
			<span class="n">done</span> <span class="o">=</span> <span class="no">true</span>
			<span class="n">errc</span> <span class="o">&lt;-</span> <span class="n">err</span>
		<span class="p">}</span>
	<span class="p">}</span>
	<span class="k">go</span> <span class="n">pipe</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">report</span><span class="p">)</span>
	<span class="k">go</span> <span class="n">pipe</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">report</span><span class="p">)</span>
	<span class="k">return</span> <span class="o">&lt;-</span><span class="n">errc</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">old</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Args</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">os</span><span class="o">.</span><span class="n">Args</span><span class="p">[</span><span class="m">1</span><span class="p">]</span> <span class="o">==</span> <span class="s">"old"</span>
	<span class="n">before</span> <span class="o">:=</span> <span class="n">runtime</span><span class="o">.</span><span class="n">NumGoroutine</span><span class="p">()</span>
	<span class="k">for</span> <span class="k">range</span> <span class="m">10000</span> <span class="p">{</span>
		<span class="n">proxy</span><span class="p">(</span><span class="n">old</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="n">time</span><span class="o">.</span><span class="n">Sleep</span><span class="p">(</span><span class="m">500</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Millisecond</span><span class="p">)</span> <span class="c">// let goroutines that can exit, exit</span>
	<span class="n">fmt</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"before=%d after=%d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">before</span><span class="p">,</span> <span class="n">runtime</span><span class="o">.</span><span class="n">NumGoroutine</span><span class="p">())</span>
<span class="p">}</span>
</code></pre></div></div>

<p>On Go 1.24.5, <code class="language-plaintext highlighter-rouge">go run . new</code> printed <code class="language-plaintext highlighter-rouge">before=1 after=1</code> in 15 of 15 runs. <code class="language-plaintext highlighter-rouge">go run . old</code> left a different count every run: 83 to 117 in one batch of 15, 12 to 64 in another. Any count above 1 is the leak. My copy leaks less than the real test. I did not test why; it omits the logging and error wrapping. <code class="language-plaintext highlighter-rouge">go run -race . old</code> reports the race.</p>

<h2 id="the-lesson-for-iocopy-style-proxies">The lesson for io.Copy-style proxies</h2>

<ul>
  <li>Count senders and receivers on every channel, on every path. If the receiver may leave early, size the buffer for all senders.</li>
  <li>A plain bool shared by two goroutines is not a guard. Buffer the channel or use <code class="language-plaintext highlighter-rouge">sync.Once</code>.</li>
  <li>Test the failure path: connections that fail at once, many calls, then <code class="language-plaintext highlighter-rouge">runtime.NumGoroutine</code>. Use <code class="language-plaintext highlighter-rouge">-race</code> and several cores.</li>
</ul>

<h2 id="how-others-solve-it">How others solve it</h2>

<p><code class="language-plaintext highlighter-rouge">net/http/httputil.ReverseProxy</code> handles protocol upgrades with the same shape (<a href="https://github.com/golang/go/blob/a5ae3a45aa44ad33e0c03d2d4093c1cd7bbd5a81/src/net/http/httputil/reverseproxy.go#L915-L964">source</a>): <code class="language-plaintext highlighter-rouge">errc := make(chan error, 1)</code>, two copier goroutines, each sending exactly once. The parent reads one value, and a second only if the first was <code class="language-plaintext highlighter-rouge">nil</code>. A buffer of 1 is enough because the parent always reads at least once. The k3s buffer of 2 does not depend on that. The copiers also pass on a half-close with <code class="language-plaintext highlighter-rouge">CloseWrite</code> when the connection supports it.</p>

<p>rancher/remotedialer’s <code class="language-plaintext highlighter-rouge">pipe</code> uses no channel: a <code class="language-plaintext highlighter-rouge">sync.WaitGroup</code>, a <code class="language-plaintext highlighter-rouge">closePipe</code> that closes both ends, then <code class="language-plaintext highlighter-rouge">wg.Wait()</code> (<a href="https://github.com/rancher/remotedialer/blob/1e594259f9fc168abf5c4311ebb4309892b1c6ab/client_dialer.go#L37-L62">source</a>). It waits for both directions. #14810 followed it.</p>

<p>If you have chased a leak like this one, reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[k3s leaked a goroutine, its 32 KiB buffer and both connections whenever the two copy directions of a tunnel connection failed at about the same time. The reporter counted 254 stuck goroutines after about 20.5 hours on one single-node server. I sent the fix (PR #14811, merged 2026-10-15): it deletes a bool and a method, and gives a channel a buffer of 2. @bheemareddy reported the bug in issue #14787, with the cause, a standalone reproducer and a sync.Once fix.]]></summary></entry></feed>