<?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://spicysharkco.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://spicysharkco.github.io/" rel="alternate" type="text/html" /><updated>2026-10-16T16:58:46+00:00</updated><id>https://spicysharkco.github.io/feed.xml</id><title type="html">spicysharkco</title><subtitle>Faster, cheaper, harder to break.</subtitle><entry><title type="html">Two goroutines, one receive: a leak in the k3s tunnel proxy</title><link href="https://spicysharkco.github.io/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://spicysharkco.github.io/2026/10/16/k3s-proxy-goroutine-leak</id><content type="html" xml:base="https://spicysharkco.github.io/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. He gave no reason. His <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 his intent was to ignore normal closure, so these errors should not be logged even at debug. He 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><entry><title type="html">An index that trusted the disparity map: the validateDisparity fix in OpenCV</title><link href="https://spicysharkco.github.io/2026/10/16/opencv-validatedisparity-out-of-bounds.html" rel="alternate" type="text/html" title="An index that trusted the disparity map: the validateDisparity fix in OpenCV" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://spicysharkco.github.io/2026/10/16/opencv-validatedisparity-out-of-bounds</id><content type="html" xml:base="https://spicysharkco.github.io/2026/10/16/opencv-validatedisparity-out-of-bounds.html"><![CDATA[<p>My fix for an out-of-bounds access in OpenCV’s <code class="language-plaintext highlighter-rouge">cv::validateDisparity</code> was merged into the 4.x branch today as <a href="https://github.com/opencv/opencv/pull/30316">PR #30316</a>. It answers <a href="https://github.com/opencv/opencv/issues/25367">issue #25367</a>, filed in April 2024 as “crash and Unknown C++ exception when I invoke cv2.validateDisparity”. Two earlier PRs for it were closed. The issue’s snippet does not crash for me. The bug is real anyway, and a different input shows it.</p>

<h2 id="what-the-function-does">What the function does</h2>

<p>It is the left-right consistency check for a disparity map. The header comment in <code class="language-plaintext highlighter-rouge">modules/calib3d/include/opencv2/calib3d.hpp</code> says:</p>

<blockquote>
  <p>validates disparity using the left-right check. The matrix “cost” should be computed by the stereo correspondence algorithm</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">StereoBM</code> calls it after matching when <code class="language-plaintext highlighter-rouge">disp12MaxDiff &gt;= 0</code>, with its disparity map and a <code class="language-plaintext highlighter-rouge">CV_16S</code> map of the best SAD score per pixel (<code class="language-plaintext highlighter-rouge">modules/calib3d/src/stereobm.cpp</code>, lines 1122-1123 and 1237). <code class="language-plaintext highlighter-rouge">StereoSGBM</code> does its own check inline, in <code class="language-plaintext highlighter-rouge">stereosgbm.cpp</code> from line 2147.</p>

<p>Disparities are <code class="language-plaintext highlighter-rouge">CV_16S</code> fixed point, 16 units per pixel. For each row, <code class="language-plaintext highlighter-rouge">validateDisparity</code> (<a href="https://github.com/opencv/opencv/blob/607d01b1ddf8278008d6410d1522a248f26c6e90/modules/calib3d/src/stereosgbm.cpp#L2514"><code class="language-plaintext highlighter-rouge">stereosgbm.cpp</code>, line 2514</a>) makes two passes:</p>

<ol>
  <li>For each pixel x with disparity d, find where it lands in the other image, <code class="language-plaintext highlighter-rouge">x2 = x - ((d + 8) &gt;&gt; 4)</code>. Per landing column, keep the disparity of the lowest-cost pixel. Two <code class="language-plaintext highlighter-rouge">cols</code>-long int arrays, <code class="language-plaintext highlighter-rouge">disp2buf</code> and <code class="language-plaintext highlighter-rouge">disp2cost</code>, hold this in one allocation.</li>
  <li>For each pixel, round d down and up to two columns and look both up. If both entries exist and both differ from d by more than <code class="language-plaintext highlighter-rouge">disp12MaxDiff</code>, the pixel becomes invalid, which is <code class="language-plaintext highlighter-rouge">(minDisparity - 1) * 16</code>.</li>
</ol>

<p>The first pass runs x from <code class="language-plaintext highlighter-rouge">max(maxD, 0)</code> to <code class="language-plaintext highlighter-rouge">cols + min(minD, 0)</code>. A disparity inside <code class="language-plaintext highlighter-rouge">[minDisparity*16, (minDisparity+numberOfDisparities)*16)</code> always gives <code class="language-plaintext highlighter-rouge">x2</code> in <code class="language-plaintext highlighter-rouge">[0, cols)</code>. I checked 5,433,296 cases by brute force (columns 20, 50 and 160, <code class="language-plaintext highlighter-rouge">minDisparity</code> -6 to 6, 1 to 16 disparities). The bounds hold only if the data keeps that promise.</p>

<h2 id="the-input-that-broke-it">The input that broke it</h2>

<p>A disparity outside that range, for example from a map computed with other parameters, or a negative one. Then <code class="language-plaintext highlighter-rouge">x2</code> can go negative or reach <code class="language-plaintext highlighter-rouge">cols</code>, and the code indexed with it unchecked, in both the <code class="language-plaintext highlighter-rouge">CV_16S</code> and the <code class="language-plaintext highlighter-rouge">CV_32S</code> cost branches.</p>

<p>Two cases from my test, on a row of 160 columns. With <code class="language-plaintext highlighter-rouge">minDisparity = 2</code> and <code class="language-plaintext highlighter-rouge">numberOfDisparities = 16</code>, pixel 159 with d = 2672 gives x2 = 159 - 167 = -8. With <code class="language-plaintext highlighter-rouge">minDisparity = 0</code>, pixel 159 with d = -32 gives x2 = 161.</p>

<p>The two directions fail differently. <code class="language-plaintext highlighter-rouge">disp2cost</code> starts at <code class="language-plaintext highlighter-rouge">disp2buf + cols</code>, so <code class="language-plaintext highlighter-rouge">disp2cost[-8]</code> is <code class="language-plaintext highlighter-rouge">disp2buf[152]</code>, a live entry for other pixels: the write silently changes another pixel’s answer. Its partner write, <code class="language-plaintext highlighter-rouge">disp2buf[-8]</code>, lands 32 bytes before the buffer. <code class="language-plaintext highlighter-rouge">disp2cost[161]</code> is 4 bytes past the end of the 320-int buffer.</p>

<p>The issue’s snippet uses random 100x100 maps with values 0 to 255, <code class="language-plaintext highlighter-rouge">minDisparity = 3</code> and <code class="language-plaintext highlighter-rouge">numberOfDisparities = 7</code>. Enumerating that range, x2 runs from -6 to 99, so only the negative side is reachable. With 100 columns the buffer is 200 ints, which fits inline in <code class="language-plaintext highlighter-rouge">AutoBuffer&lt;int&gt;</code>, 264 by default (<code class="language-plaintext highlighter-rouge">modules/core/include/opencv2/core/utility.hpp</code>, line 100). The <code class="language-plaintext highlighter-rouge">disp2cost[x2]</code> writes land on slots 94 to 99 of the same array. The paired <code class="language-plaintext highlighter-rouge">disp2buf[x2]</code> writes land before the array: x2 of -4 and -3 overwrite the <code class="language-plaintext highlighter-rouge">ptr</code> field of the <code class="language-plaintext highlighter-rouge">AutoBuffer</code> object, and -2 and -1 overwrite <code class="language-plaintext highlighter-rouge">sz</code>. <code class="language-plaintext highlighter-rouge">~AutoBuffer</code> calls <code class="language-plaintext highlighter-rouge">delete[] ptr</code> when <code class="language-plaintext highlighter-rouge">ptr != buf</code>, so a damaged <code class="language-plaintext highlighter-rouge">ptr</code> can end in a bad free. That would fit the reporter’s exit code, 0xC0000374 (heap corruption), but I could not test their MSVC build. I ran the snippet 100 times each on the 4.9.0 and 5.0.0 Python wheels, macOS arm64: all 200 runs ended with exit status 0. In the 4.9.0 wheel the compiled code keeps <code class="language-plaintext highlighter-rouge">ptr</code> in a register and never reloads it, so the damage goes unseen.</p>

<p>This line has failed before. <a href="https://github.com/opencv/opencv/pull/4190">PR #4190</a>, merged in 2015, skipped <code class="language-plaintext highlighter-rouge">d == INVALID_DISP_SCALED</code> because “x2 could be out of bounds if, for instance, x==cols-1 and d==INVALID_DISP_SCALED”. Mine covers the other values.</p>

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

<p>Six added lines, the same three in each branch:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 int x2 = x - ((d + DISP_SCALE/2) &gt;&gt; DISP_SHIFT);
 
<span class="gi">+                if( (unsigned)x2 &gt;= (unsigned)cols )
+                    continue;
+
</span>                 if( disp2cost[x2] &gt; c )
</code></pre></div></div>

<p>Casting to unsigned turns a negative index into a huge one, so one comparison covers both ends. A skipped pixel does not vote in the reverse map, and is not itself changed: in the test it keeps its input value.</p>

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

<p><code class="language-plaintext highlighter-rouge">Calib3d_ValidateDisparity.out_of_range_disparity</code> runs one 160-column row for <code class="language-plaintext highlighter-rouge">CV_16S</code> and <code class="language-plaintext highlighter-rouge">CV_32S</code>, in both directions: four cases. It compares the whole row with the expected one. The negative case also shows up without a sanitizer:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pixels.push_back({154, 32, 0, 32});
pixels.push_back({155, 48, 10, invalid});
pixels.push_back({159, 2672, 0, 2672});
</code></pre></div></div>

<p>Pixels 154 and 155 both map to slot 152, and 154 is cheaper, so 155 fails the cross-check. Without the fix, pixel 159 overwrites slot 152 and 155 is not invalidated. Per the PR, without the fix all four cases fail under ASan, and the two negative ones also fail without a sanitizer. With the fix all four pass, and the existing <code class="language-plaintext highlighter-rouge">StereoBM</code> and <code class="language-plaintext highlighter-rouge">StereoSGBM</code> tests pass under ASan.</p>

<p>The work buffer is on the heap only above 132 columns (2 x 133 = 266 &gt; 264). A narrower row sits inside the <code class="language-plaintext highlighter-rouge">AutoBuffer</code> object, where ASan has no redzone.</p>

<h2 id="the-earlier-attempts-and-the-review">The earlier attempts and the review</h2>

<p><a href="https://github.com/opencv/opencv/pull/28507">#28507</a> was closed by its author the day it opened: “Closing this PR in favor of a clean re-submission.” <a href="https://github.com/opencv/opencv/pull/28508">#28508</a> was closed in March 2026, and its thread gives no reason. Before that, @asmorkalov saw no crash on Linux, with or without ASan, and asked for a test that reaches the guarded index.</p>

<p>On #30316, @asmorkalov approved with no text and merged. Right after the merge he asked: “Sounds like the same issue exists in 5.x. Could you submit complementary PR there?” It does: the 5.x copy in <code class="language-plaintext highlighter-rouge">modules/stereo/src/stereosgbm.cpp</code> had the same unguarded lines. I sent the port, with the same test, as <a href="https://github.com/opencv/opencv/pull/30344">#30344</a>.</p>

<h2 id="run-it">Run it</h2>

<p>The wheels I tried have no fix. With the fix, the PR’s test expects pixel 155 to be 16 in both columns; I did not rebuild OpenCV to print that.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">subprocess</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="kn">import</span> <span class="nn">cv2</span>
<span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="n">COLS</span><span class="p">,</span> <span class="n">MIN_D</span> <span class="o">=</span> <span class="mi">160</span><span class="p">,</span> <span class="mi">2</span>
<span class="n">INVALID</span> <span class="o">=</span> <span class="p">(</span><span class="n">MIN_D</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="mi">16</span>  <span class="c1"># "no disparity" marker
</span>

<span class="k">def</span> <span class="nf">pixel_155</span><span class="p">(</span><span class="n">bad</span><span class="p">,</span> <span class="n">dtype</span><span class="p">):</span>
    <span class="n">disp</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">full</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="n">COLS</span><span class="p">),</span> <span class="n">INVALID</span><span class="p">,</span> <span class="n">np</span><span class="p">.</span><span class="n">int16</span><span class="p">)</span>
    <span class="n">cost</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="n">COLS</span><span class="p">),</span> <span class="n">dtype</span><span class="p">)</span>
    <span class="n">disp</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">154</span><span class="p">]</span> <span class="o">=</span> <span class="mi">32</span>                    <span class="c1"># 154 and 155 both map to column 152
</span>    <span class="n">disp</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">155</span><span class="p">],</span> <span class="n">cost</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">155</span><span class="p">]</span> <span class="o">=</span> <span class="mi">48</span><span class="p">,</span> <span class="mi">10</span>  <span class="c1"># 154 is cheaper, so 155 must lose
</span>    <span class="k">if</span> <span class="n">bad</span><span class="p">:</span>
        <span class="n">disp</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">159</span><span class="p">]</span> <span class="o">=</span> <span class="mi">2672</span>              <span class="c1"># valid range is [32, 288)
</span>    <span class="k">return</span> <span class="nb">int</span><span class="p">(</span><span class="n">cv2</span><span class="p">.</span><span class="n">validateDisparity</span><span class="p">(</span><span class="n">disp</span><span class="p">,</span> <span class="n">cost</span><span class="p">,</span> <span class="n">MIN_D</span><span class="p">,</span> <span class="mi">16</span><span class="p">,</span> <span class="mi">0</span><span class="p">)[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">155</span><span class="p">])</span>


<span class="k">for</span> <span class="n">dt</span> <span class="ow">in</span> <span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">int16</span><span class="p">,</span> <span class="n">np</span><span class="p">.</span><span class="n">int32</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">dtype</span><span class="p">(</span><span class="n">dt</span><span class="p">).</span><span class="n">name</span><span class="p">,</span> <span class="n">pixel_155</span><span class="p">(</span><span class="bp">False</span><span class="p">,</span> <span class="n">dt</span><span class="p">),</span> <span class="n">pixel_155</span><span class="p">(</span><span class="bp">True</span><span class="p">,</span> <span class="n">dt</span><span class="p">))</span>

<span class="c1"># x2 past the last column, in a child process
</span><span class="n">child</span> <span class="o">=</span> <span class="p">(</span>
    <span class="s">"import cv2, numpy as np</span><span class="se">\n</span><span class="s">"</span>
    <span class="s">"disp = np.full((1, 160), -16, np.int16); disp[0, 159] = -32</span><span class="se">\n</span><span class="s">"</span>
    <span class="s">"cv2.validateDisparity(disp, np.zeros((1, 160), np.int16), 0, 16, 0)</span><span class="se">\n</span><span class="s">"</span>
<span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"exit status:"</span><span class="p">,</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">run</span><span class="p">([</span><span class="n">sys</span><span class="p">.</span><span class="n">executable</span><span class="p">,</span> <span class="s">"-c"</span><span class="p">,</span> <span class="n">child</span><span class="p">]).</span><span class="n">returncode</span><span class="p">)</span>
</code></pre></div></div>

<p>On 4.9.0 and on 5.0.0 it printed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int16 16 48
int32 16 48
exit status: 0
</code></pre></div></div>

<p>The two numbers are pixel 155 without and with the far-off pixel; it should be 16. The past-the-end case exits with status 0 and no message.</p>

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

<p>If a per-pixel loop turns a value from the map into an index, check the index where you use it. The loop bounds came from parameters, and parameters do not constrain the data. A map comes from another stage with other settings. Its invalid marker may fall outside this call’s range, and only this call’s own marker is skipped.</p>

<p>Make the test fail first: run it against the unfixed code. Mine needed 160 columns and a pixel whose result another pixel can change.</p>

<p>For how others solve it, look at the neighbours. The final loop of this same function range-checks <code class="language-plaintext highlighter-rouge">x0</code> and <code class="language-plaintext highlighter-rouge">x1</code>, and the <code class="language-plaintext highlighter-rouge">StereoSGBM</code> inline check does the same with <code class="language-plaintext highlighter-rouge">0 &lt;= _x &amp;&amp; _x &lt; width</code>. The 2015 fix and this one both check at the use site.</p>

<p>If you have hit a disparity or depth loop that read past a row, I would like to hear how you found it. Reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[My fix for an out-of-bounds access in OpenCV’s cv::validateDisparity was merged into the 4.x branch today as PR #30316. It answers issue #25367, filed in April 2024 as “crash and Unknown C++ exception when I invoke cv2.validateDisparity”. Two earlier PRs for it were closed. The issue’s snippet does not crash for me. The bug is real anyway, and a different input shows it.]]></summary></entry><entry><title type="html">A colour convention that looked like a sim-to-real gap</title><link href="https://spicysharkco.github.io/2026/10/15/colour-convention-looked-like-domain-gap.html" rel="alternate" type="text/html" title="A colour convention that looked like a sim-to-real gap" /><published>2026-10-15T00:00:00+00:00</published><updated>2026-10-15T00:00:00+00:00</updated><id>https://spicysharkco.github.io/2026/10/15/colour-convention-looked-like-domain-gap</id><content type="html" xml:base="https://spicysharkco.github.io/2026/10/15/colour-convention-looked-like-domain-gap.html"><![CDATA[<p>I built a render generator and a 20,000-image benchmark to ask one question. When a perception model is trained on renders and used on a camera, what does the synthetic data buy, and what does it cost? The testbed is chess-position recognition. The labels come from the scene state, so they are exact for the scene, and I can change one factor at a time.</p>

<p>The first result was not about the domain gap. A public checkpoint scored zero exact boards on my renders, and I read that as sim-to-real shift. About two thirds of the wrong squares came from a colour convention, and most boards still fail after the fix. Changing one renderer expression moved per-square accuracy from 54.4 to 85.2. The code and receipts are in the <a href="https://github.com/spicysharkco/chess-sim2real-benchmark">GitHub repo</a>. The development renders are in the <a href="https://huggingface.co/datasets/spicysharkco/chess-sim2real-benchmark">Hugging Face dataset</a>. The dataset holds the 16,000 development images. The 4,000 sealed images are not released, and the 192 probe renders from the earlier lineage are not published, only their receipts.</p>

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

<p>The generator draws a procedural board, a piece family, a perspective camera, and explicit lighting and materials. Labels come from the scene description, never from a detector. A label is a piece-placement FEN, which names all 64 squares. The model under test is the public ChessQueries checkpoint. Its weights are released, so I could score it as is.</p>

<h2 id="what-the-gap-looked-like">What the gap looked like</h2>

<p>My early renders came in 18 splits from one generator lineage, with one board colour convention throughout. On every split the checkpoint produced zero exact boards. Per-square accuracy was 52.7 to 56.7. That is 4.6 to 9.0 points below a constant all-empty prediction, which also gets zero exact boards on these positions. So the model was not defaulting to empty. It was putting pieces on the wrong squares.</p>

<p>The same checkpoint, through the same code path, scored 99.62 exact on ChessReD (2,129 real photographs) and 98.54 on ChessCog, which is also rendered. The failure looked specific to my renders.</p>

<p>I tried the usual input fixes. A crop diagnostic fed the model 450 boards, 150 from each of three splits, four ways: full frame, bounding-box crop, square-padded crop, and a perspective warp to a square. None produced an exact board. Per-square accuracy moved by at most 1.2 points from the full frame. My notes at that point read: “the shift is the piece and board appearance itself.”</p>

<h2 id="why-i-believed-it">Why I believed it</h2>

<p>The model was good on photos and on another rendered set, and bad on mine. Cropping and warping did nothing. What was left was appearance, which is the textbook story for a sim-to-real gap. You can tell it without verifying anything.</p>

<h2 id="what-the-convention-audit-recorded">What the convention audit recorded</h2>

<p>I recorded the board colour convention of one representative image from each of 56 source directories (some are retry or copy lineages). I use <code class="language-plaintext highlighter-rouge">a1_light</code> for a1 and h8 light, a8 and h1 dark, and <code class="language-plaintext highlighter-rouge">a1_dark</code> for the reverse. The laws of chess put a dark square at a1. My renderer, following the convention of the data it had been matched to, made a1 light.</p>

<p>In the sampled image, ChessReD, ChessCog, CVChess and ChessRender360 have a8 and h1 light, so a1 is dark. The sampled SLCC frame has no printed coordinates, so I recorded it as unresolved. My own procedural sources were a1 light in every sampled record. I also read the reviewed loaders. None of them mirrors the labels to compensate for board colour.</p>

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

<p>The old renderer coloured a square from the parity of its row and column: <code class="language-plaintext highlighter-rouge">(r+c)%2===1?lm:dm</code>. Here <code class="language-plaintext highlighter-rouge">lm</code> is the light material and <code class="language-plaintext highlighter-rouge">dm</code> the dark one. The row <code class="language-plaintext highlighter-rouge">r</code> counts ranks in FEN order, so rank 8 is row 0, and <code class="language-plaintext highlighter-rouge">c</code> counts files from a. Square a1 is row 7, column 0. Seven is odd, so a1 got <code class="language-plaintext highlighter-rouge">lm</code>. Swapping the branches makes it dark. The probe applies the swap as a patch to the renderer source. The pattern tolerates whitespace, and the patch refuses to run unless it matches exactly once:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">swap_parity</span><span class="p">(</span><span class="n">source</span><span class="p">):</span>
    <span class="n">pattern</span> <span class="o">=</span> <span class="sa">rb</span><span class="s">"(\(r\+c\)%2===1\s*\?\s*)lm(\s*:\s*)dm(?![A-Za-z_0-9])"</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span> <span class="n">source</span><span class="p">))</span> <span class="o">!=</span> <span class="mi">1</span><span class="p">:</span>
        <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"expected exactly one original checkerboard assignment"</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span> <span class="k">lambda</span> <span class="n">match</span><span class="p">:</span> <span class="n">match</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">+</span> <span class="sa">b</span><span class="s">"dm"</span> <span class="o">+</span> <span class="n">match</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">+</span> <span class="sa">b</span><span class="s">"lm"</span><span class="p">,</span> <span class="n">source</span><span class="p">)</span>
</code></pre></div></div>

<p>I rendered 192 positions with and without the patch. The control renders had to be byte-identical to the frozen originals, or the run stopped. Position, camera, piece geometry and projected corners had to match inside each pair, and the two images had to differ. The patch changes the physical checkerboard colours. It does not touch the label order or the image orientation.</p>

<h2 id="before-and-after">Before and after</h2>

<p>The released checkpoint on 192 positions:</p>

<table>
  <thead>
    <tr>
      <th>Renders</th>
      <th>Model colour indices</th>
      <th>Exact boards</th>
      <th>Per-square</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>original (a1 light)</td>
      <td>released</td>
      <td>0/192</td>
      <td>54.36</td>
    </tr>
    <tr>
      <td>expression swapped (a1 dark)</td>
      <td>released</td>
      <td>18/192</td>
      <td>85.24</td>
    </tr>
    <tr>
      <td>original (a1 light)</td>
      <td>flipped in memory</td>
      <td>0/192</td>
      <td>75.09</td>
    </tr>
    <tr>
      <td>expression swapped (a1 dark)</td>
      <td>flipped in memory</td>
      <td>4/192</td>
      <td>82.37</td>
    </tr>
  </tbody>
</table>

<p>The swap removed 67.65% of the wrong squares. The 95% paired bootstrap interval on the +30.88 per-square gain is 28.91 to 32.76. On 288 positions from six other piece families, the swap took exact boards from 0/288 to 50/288 and per-square from 55.31 to 87.49.</p>

<p>The checkpoint carries a learned two-class colour index for its 64 square queries. Flipping it in memory on the original renders recovers 20.7 points but no exact board, and costs 2.9 points on the swapped ones. That fits a colour prior contributing to the failure. It does not isolate the mechanism.</p>

<p>Most boards still fail after the fix. Only 18 of 192 are exact, and in the lowest of four elevation bands none of 48 are. The convention explained about two thirds of the wrong squares. The rest is unexplained. It may be a real domain gap, or camera elevation, framing and piece geometry. This probe cannot say how much.</p>

<p>On the 400-group development partition of the v2 benchmark, the same checkpoint scores 15.48 exact pooled under a1 dark and 0.0 in all twenty cells under a1 light.</p>

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

<p>Check label and convention alignment before you blame the domain gap. Four checks are cheap.</p>

<ul>
  <li>Write down which corner is light in your data and in the data the model saw.</li>
  <li>Read the loaders for any reflection of images or labels.</li>
  <li>Treat a score below a constant baseline as a mapping error until shown otherwise. That is my rule of thumb, not a result from the repo.</li>
  <li>Make the convention a factor and render both. In v2 it is the fifth axis, so every scene exists in both conventions.</li>
</ul>

<h2 id="what-else-the-benchmark-measures">What else the benchmark measures</h2>

<p>The design has 500 position groups, 400 for development and 100 sealed. Five factors vary one at a time: camera elevation, piece geometry, board palette, framing, and the colour convention. Twenty factor cells at both conventions give 20,000 images, 18,000 of them unique renders. Positions are disjoint from 67 inventoried sources, exactly and under the eight board symmetries. “Sealed” is my no-scoring rule, not an access control. I scored no model on that split.</p>

<p>Three more findings:</p>

<ul>
  <li>Low-rank adaptation on 500 rendered positions lifted the checkpoint from 15.5 to 64.7 exact on the standard convention (dark-only training), and 8,000 positions reached 79.7 (one seed each). Every one of ten adapted checkpoints then lost 11 to 23 boards on the 2,129 real photos, against a retention gate fixed before training.</li>
  <li>The lowest-elevation a1-dark cell reaches only 14.5 exact after the largest both-convention budget. The reference-elevation cell reaches 86.5.</li>
  <li>A pipeline selected on a private leaderboard scored below its own single member on 16 of 18 controlled splits. Two sets named as holdouts shared 1,446 and 1,128 image identifiers with the training mixes.</li>
</ul>

<p>If a convention mismatch ever passed as a domain gap in your pipeline, I would like to hear how you found it. Reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I built a render generator and a 20,000-image benchmark to ask one question. When a perception model is trained on renders and used on a camera, what does the synthetic data buy, and what does it cost? The testbed is chess-position recognition. The labels come from the scene state, so they are exact for the scene, and I can change one factor at a time.]]></summary></entry><entry><title type="html">One String() call per pod: 320x faster in one kube-router function</title><link href="https://spicysharkco.github.io/2026/10/15/kube-router-netpol-sync-copy.html" rel="alternate" type="text/html" title="One String() call per pod: 320x faster in one kube-router function" /><published>2026-10-15T00:00:00+00:00</published><updated>2026-10-15T00:00:00+00:00</updated><id>https://spicysharkco.github.io/2026/10/15/kube-router-netpol-sync-copy</id><content type="html" xml:base="https://spicysharkco.github.io/2026/10/15/kube-router-netpol-sync-copy.html"><![CDATA[<p>I got a small change merged into kube-router (<a href="https://github.com/cloudnativelabs/kube-router/pull/2192">PR #2192</a>). The code change deletes five lines; I also added a test and a benchmark. On the benchmark I wrote, one function went from 152,895,797 ns/op to 477,097 ns/op, about 320x. Bytes allocated per op fell from 90,489,076 to 205,066, which is 99.77% less.</p>

<p>This post covers why those five lines cost so much and why they could go.</p>

<h2 id="what-network-policy-sync-does">What network-policy sync does</h2>

<p>kube-router’s network policy controller turns Kubernetes NetworkPolicy objects into iptables rules on every node. k3s embeds it: the k3s docs say “K3s includes an embedded network policy controller. The underlying implementation is kube-router’s netpol controller library”. k3s pins its own fork of kube-router in <code class="language-plaintext highlighter-rouge">go.mod</code> (<code class="language-plaintext highlighter-rouge">v2.6.3-k3s1</code>), and that tag has the same line I removed. The fix is on kube-router master. The latest release I can see as of 2026-10-15, v2.11.1 from August, predates the merge. So k3s clusters get it only after that pin moves.</p>

<p>The controller works by full sync. <code class="language-plaintext highlighter-rouge">fullPolicySync</code> does roughly this:</p>

<ol>
  <li>Run <code class="language-plaintext highlighter-rouge">iptables-save</code> for the filter table into a <code class="language-plaintext highlighter-rouge">bytes.Buffer</code>, one per IP family.</li>
  <li>Append the policy chains (<code class="language-plaintext highlighter-rouge">KUBE-NWPLCY-*</code>) and, for every pod on the node, a firewall chain (<code class="language-plaintext highlighter-rouge">KUBE-POD-FW-*</code>) with its rules.</li>
  <li>Strip stale rules from the buffer.</li>
  <li>Hand the buffer to <code class="language-plaintext highlighter-rouge">iptables-restore</code>.</li>
</ol>

<p>A pod event calls <code class="language-plaintext highlighter-rouge">RequestFullSync</code>, and so does a periodic timer. Requests go through a channel of capacity 1, and syncs run one at a time. The issue behind the PR (#2191) reports CPU profiles where <code class="language-plaintext highlighter-rouge">fullPolicySync</code> took 26-58% of each node’s k3s process CPU (the whole function, not only this check).</p>

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

<p>Inside <code class="language-plaintext highlighter-rouge">syncPodFirewallChains</code>, for every local pod and each IP family the pod has an address in, this ran before the pod’s log rule was written:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// This used to be AppendUnique when we were using iptables directly, this checks to make sure we didn't drop</span>
<span class="c">// unmarked for this chain already</span>
<span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">filterTableRules</span><span class="o">.</span><span class="n">String</span><span class="p">(),</span> <span class="n">strings</span><span class="o">.</span><span class="n">Join</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="s">" "</span><span class="p">))</span> <span class="p">{</span>
	<span class="k">continue</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The comment is the history. When kube-router drove iptables one command at a time, <code class="language-plaintext highlighter-rouge">AppendUnique</code> meant “add this rule unless it is already there”. Once the rules were built in a buffer, the check became a search of the buffer.</p>

<h2 id="why-it-cost-so-much">Why it cost so much</h2>

<p><code class="language-plaintext highlighter-rouge">bytes.Buffer.String()</code> ends in one line in the Go standard library:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="kt">string</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">buf</span><span class="p">[</span><span class="n">b</span><span class="o">.</span><span class="n">off</span><span class="o">:</span><span class="p">])</span>
</code></pre></div></div>

<p>That is a copy of the whole buffer. At this point the buffer holds the full <code class="language-plaintext highlighter-rouge">iptables-save</code> output plus every rule generated so far. Then <code class="language-plaintext highlighter-rouge">strings.Contains</code> scans the copy. The rule never matched, so every scan ran to the end.</p>

<p>So each pod cost one allocation, one copy and one scan of the entire table. Total work is pods times table size. The table also grows with pods, because each pod adds a chain and its rules. In the benchmark, 80 copies of a 1 MiB buffer is 83,886,080 bytes. The measured 90,489,076 B/op is close to that.</p>

<p>Using <code class="language-plaintext highlighter-rouge">bytes.Contains(buf.Bytes(), ...)</code> would have removed the copy, since <code class="language-plaintext highlighter-rouge">Bytes()</code> returns a slice that aliases the buffer. It would not have removed the scan. The cost would still scale with pods times size.</p>

<h2 id="the-fix-in-two-steps">The fix, in two steps</h2>

<p>My first version replaced the buffer search with a per-family set of the rules written in this sync:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logRule</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">Join</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="s">" "</span><span class="p">)</span>
<span class="k">if</span> <span class="n">loggedDrops</span><span class="p">[</span><span class="n">ipFamily</span><span class="p">][</span><span class="n">logRule</span><span class="p">]</span> <span class="p">{</span>
	<span class="k">continue</span>
<span class="p">}</span>
<span class="n">loggedDrops</span><span class="p">[</span><span class="n">ipFamily</span><span class="p">][</span><span class="n">logRule</span><span class="p">]</span> <span class="o">=</span> <span class="no">true</span>
</code></pre></div></div>

<p>On the benchmark it gave the same result as the final version. Then the maintainer, @aauren, reviewed it. The review comment said they would argue “we should just drop this check altogether”, because “as far as I can tell this is dead code and never has the potential of actually coming back with a match, unless you see it differently?”</p>

<p>That argument holds up. The rule text contains:</p>

<ul>
  <li>the pod’s chain name, which is <code class="language-plaintext highlighter-rouge">KUBE-POD-FW-</code> plus 16 characters of base32(sha256(namespace + podName + version));</li>
  <li>a <code class="language-plaintext highlighter-rouge">&lt;namespace&gt;/&lt;name&gt; log-drop</code> comment.</li>
</ul>

<p>The version is derived from the sync’s start time in nanoseconds, so rules left by an earlier sync carry a different chain name. For a match inside one sync, the same pod would have to come through the loop twice in the same family. The maintainer’s read was that a real cluster cannot do that. I could not find a way either.</p>

<p>My description had already said that rules from an earlier sync could never match, because the chain name contains the sync version and <code class="language-plaintext highlighter-rouge">iptables-save</code> prints the rule differently (<code class="language-plaintext highlighter-rouge">10/min</code> instead of <code class="language-plaintext highlighter-rouge">10/minute</code>, and a different match order). My set was guarding against a case that could not happen. I agreed, deleted the check and the set, and dropped the two tests that only covered them. The final diff in <code class="language-plaintext highlighter-rouge">pod.go</code> is five deletions and no additions. @aauren approved it and merged it on 2026-10-12.</p>

<p>A side effect: while checking whether pods with colliding chain names could trigger the check, I noticed that the hash joins namespace and name with no separator. Pods <code class="language-plaintext highlighter-rouge">a</code>/<code class="language-plaintext highlighter-rouge">bc</code> and <code class="language-plaintext highlighter-rouge">ab</code>/<code class="language-plaintext highlighter-rouge">c</code> get the same chain name. That is a separate bug, and the maintainer filed it as #2193, which covers more than pod chains. I left it alone in this PR. The <code class="language-plaintext highlighter-rouge">log-drop</code> comment still differs for those two pods, so the old check did not fire for them either.</p>

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

<p>First, a test to pin behaviour. <code class="language-plaintext highlighter-rouge">TestSyncPodFirewallChainsDropRules</code> asserts the exact rules written per chain and per family, for single-stack, dual-stack and colliding-name pods. The PR says it passes on master and on the branch. A change that deletes a check needs a test that agrees with the old code.</p>

<p>Then the benchmark. It builds 80 pods in one IP family and fills a buffer with synthetic policy-chain lines past 1 MiB, as if <code class="language-plaintext highlighter-rouge">iptables-save</code> had just run:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">existing</span><span class="o">.</span><span class="n">Len</span><span class="p">()</span> <span class="o">&lt;</span> <span class="m">1</span><span class="o">&lt;&lt;</span><span class="m">20</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
	<span class="n">fmt</span><span class="o">.</span><span class="n">Fprintf</span><span class="p">(</span><span class="o">&amp;</span><span class="n">existing</span><span class="p">,</span> <span class="s">"-A KUBE-NWPLCY-%016X -m comment --comment </span><span class="se">\"</span><span class="s">ns-%d/policy-%d ingress pods</span><span class="se">\"</span><span class="s"> "</span><span class="o">+</span>
		<span class="s">"-m set --match-set KUBE-SRC-%016X src -j MARK --set-xmark 0x10000/0x10000</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="o">%</span><span class="m">40</span><span class="p">,</span> <span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">for</span> <span class="n">b</span><span class="o">.</span><span class="n">Loop</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">buf</span><span class="o">.</span><span class="n">Reset</span><span class="p">()</span>
	<span class="n">buf</span><span class="o">.</span><span class="n">Write</span><span class="p">(</span><span class="n">existing</span><span class="o">.</span><span class="n">Bytes</span><span class="p">())</span>
	<span class="n">npc</span><span class="o">.</span><span class="n">syncPodFirewallChains</span><span class="p">(</span><span class="no">nil</span><span class="p">,</span> <span class="s">"bench"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The numbers are the median of <code class="language-plaintext highlighter-rouge">-count 5</code>. Allocations per op went from 2115 to 1954, which is 161 fewer. That fits two fewer per pod, the <code class="language-plaintext highlighter-rouge">String()</code> copy and one <code class="language-plaintext highlighter-rouge">strings.Join</code>.</p>

<p>Some limits. The loop also resets and refills the 1 MiB buffer on each pass, so that time is inside both numbers, and 477,097 ns/op is an upper bound for the function, so the 320x ratio is understated. The benchmark covers one function, not a whole sync. I did no cluster testing for this PR. The issue’s own standalone benchmark, at 84 pods and a 1.2 MB buffer, measured 180-188 ms per sync for the check, which is the same order as mine.</p>

<h2 id="what-i-take-from-it">What I take from it</h2>

<ul>
  <li>A call that looks like a read can allocate. Inside a loop, ask what it costs per iteration.</li>
  <li>Before making a check faster, ask whether it can ever be true. I built a set. The maintainer asked whether the check was needed at all, which was the better answer.</li>
  <li>Pin behaviour with a test that passes before and after, then change the code.</li>
</ul>

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

<p>kube-proxy in iptables mode has the same shape: build the rules in line buffers, then restore them with <code class="language-plaintext highlighter-rouge">NoFlushTables</code>. I found no search of those buffers in it. It tracks the chains it wants in a set (<code class="language-plaintext highlighter-rouge">activeNATChains := sets.New[utiliptables.Chain]()</code>). It reads <code class="language-plaintext highlighter-rouge">iptables-save</code> only to list chain names with <code class="language-plaintext highlighter-rouge">GetChainsFromTable</code> and diffs them against that set. In large clusters it does that at most once per sync period, because, as its own comment says, <code class="language-plaintext highlighter-rouge">iptables-save</code> “can take several seconds to run on hosts with lots of iptables rules”. It also has a <code class="language-plaintext highlighter-rouge">doFullSync</code> flag, so not every sync rebuilds everything.</p>

<p>Keep the state in a data structure, and do not search the output buffer once per item.</p>

<p>If you run kube-router or k3s with many pods per node, I would like to hear what your sync times look like. Reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I got a small change merged into kube-router (PR #2192). The code change deletes five lines; I also added a test and a benchmark. On the benchmark I wrote, one function went from 152,895,797 ns/op to 477,097 ns/op, about 320x. Bytes allocated per op fell from 90,489,076 to 205,066, which is 99.77% less.]]></summary></entry><entry><title type="html">One local variable, one reference cycle per error reply in redis-py</title><link href="https://spicysharkco.github.io/2026/10/15/redis-py-asyncio-error-reference-cycle.html" rel="alternate" type="text/html" title="One local variable, one reference cycle per error reply in redis-py" /><published>2026-10-15T00:00:00+00:00</published><updated>2026-10-15T00:00:00+00:00</updated><id>https://spicysharkco.github.io/2026/10/15/redis-py-asyncio-error-reference-cycle</id><content type="html" xml:base="https://spicysharkco.github.io/2026/10/15/redis-py-asyncio-error-reference-cycle.html"><![CDATA[<p>On 2026-10-15 redis-py merged a small fix I sent: <a href="https://github.com/redis/redis-py/pull/4434">#4434</a>, “Break the ResponseError reference cycle in asyncio read_response”. The fix adds four lines and removes one in <code class="language-plaintext highlighter-rouge">redis/asyncio/connection.py</code>. The rest is the test. The review from <a href="https://github.com/petyaslavova">@petyaslavova</a> was short: it matches the sync <code class="language-plaintext highlighter-rouge">Connection.read_response</code> fix from <a href="https://github.com/redis/redis-py/pull/2932">#2932</a>, and the test covers it.</p>

<p>The bug is one Python habit, not one Redis quirk. You may have it in your own code.</p>

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

<p>Turn the cyclic garbage collector off. Send 1,000 error replies through the asyncio client. Count how many <code class="language-plaintext highlighter-rouge">ResponseError</code> objects are still alive, using weakrefs. Before the fix: 1000 of 1000. After: 0 of 1000.</p>

<p>I ran this against a live server with two replies: NOSCRIPT from <code class="language-plaintext highlighter-rouge">EVALSHA</code>, and WRONGTYPE from <code class="language-plaintext highlighter-rouge">LPUSH</code> on a string. Both gave 1000 / 1000 before and 0 / 1000 after. The sync client already gave 0 / 1000.</p>

<p>The objects are freed once the collector runs. The question is how long that takes, and what else they hold in the meantime. The PR lists replies that hit this: NOSCRIPT from <code class="language-plaintext highlighter-rouge">EVALSHA</code> (which <code class="language-plaintext highlighter-rouge">AsyncScript</code> retries on), WRONGTYPE, BUSYGROUP, and MOVED/ASK in cluster mode.</p>

<h2 id="one-local-is-the-whole-bug">One local is the whole bug</h2>

<p>This is the code that was there:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">response</span><span class="p">,</span> <span class="n">ResponseError</span><span class="p">):</span>
    <span class="k">raise</span> <span class="n">response</span> <span class="k">from</span> <span class="bp">None</span>
<span class="k">return</span> <span class="n">response</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">response</code> is a local variable. When <code class="language-plaintext highlighter-rouge">raise</code> runs, the exception gets a <code class="language-plaintext highlighter-rouge">__traceback__</code>, and the traceback points at every frame between where it was raised and where it is caught. One of them is the <code class="language-plaintext highlighter-rouge">read_response</code> frame. That frame owns its locals, and one local is <code class="language-plaintext highlighter-rouge">response</code>, the exception itself.</p>

<p>So: exception, <code class="language-plaintext highlighter-rouge">__traceback__</code>, frame, locals, exception. Reference counts on a loop never reach zero. Only the cyclic collector can free it, and until it does, every frame on the traceback stays alive with its locals.</p>

<h2 id="why-except--as-e-does-not-do-this">Why <code class="language-plaintext highlighter-rouge">except ... as e</code> does not do this</h2>

<p>You write <code class="language-plaintext highlighter-rouge">except Boom as e:</code> all the time and do not leak. Python deletes <code class="language-plaintext highlighter-rouge">e</code> for you. From the language reference:</p>

<blockquote>
  <p>When an exception has been assigned using <code class="language-plaintext highlighter-rouge">as target</code>, it is cleared at the end of the <code class="language-plaintext highlighter-rouge">except</code> clause. This is as if:</p>

  <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">except</span> <span class="n">E</span> <span class="k">as</span> <span class="n">N</span><span class="p">:</span>
    <span class="n">foo</span>
</code></pre></div>  </div>

  <p>was translated to:</p>

  <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">except</span> <span class="n">E</span> <span class="k">as</span> <span class="n">N</span><span class="p">:</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">foo</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="k">del</span> <span class="n">N</span>
</code></pre></div>  </div>
</blockquote>

<p>The docs give the reason: “Exceptions are cleared because with the traceback attached to them, they form a reference cycle with the stack frame, keeping all locals in that frame alive until the next garbage collection occurs.” <a href="https://peps.python.org/pep-3110/">PEP 3110</a> added that cleanup in Python 3.0.</p>

<p>The cleanup covers one name. Any other name bound to the exception is yours to clear: a local you <code class="language-plaintext highlighter-rouge">raise</code>, like <code class="language-plaintext highlighter-rouge">response</code>, or a copy such as <code class="language-plaintext highlighter-rouge">saved = e</code>.</p>

<h2 id="why-it-matters">Why it matters</h2>

<p>The collector frees these cycles eventually. But the <a href="https://docs.python.org/3/library/gc.html">gc docs</a> say: “you can disable the collector if you are sure your program does not create reference cycles.” Their recipe for a process that calls <code class="language-plaintext highlighter-rouge">fork()</code> without <code class="language-plaintext highlighter-rouge">exec()</code> is <code class="language-plaintext highlighter-rouge">gc.disable()</code> early in the parent, <code class="language-plaintext highlighter-rouge">gc.freeze()</code> right before <code class="language-plaintext highlighter-rouge">fork()</code>, <code class="language-plaintext highlighter-rouge">gc.enable()</code> early in the children. Instagram Engineering has written about dismissing the collector entirely: <a href="https://instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172">Dismissing Python Garbage Collection at Instagram</a>.</p>

<p>An app that made that bet does not expect a library to make one cycle per error reply. Raise the thresholds, or turn the collector off, and each cycle is held longer, or for good.</p>

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

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">response</span><span class="p">,</span> <span class="n">ResponseError</span><span class="p">):</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">response</span> <span class="k">from</span> <span class="bp">None</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="k">del</span> <span class="n">response</span>  <span class="c1"># avoid creating ref cycles
</span><span class="k">return</span> <span class="n">response</span>
</code></pre></div></div>

<p>You cannot <code class="language-plaintext highlighter-rouge">del response</code> before the <code class="language-plaintext highlighter-rouge">raise</code>, because the raise needs it. The <code class="language-plaintext highlighter-rouge">finally</code> block runs while the exception is already in flight, so the delete comes after the raise has picked the object up. The frame no longer points at the exception. The exception still points at the frame, but a one-way link is not a cycle. When the caller drops the exception, the count hits zero and everything is freed on the spot. <code class="language-plaintext highlighter-rouge">from None</code> stays as it was: it only hides the implicit <code class="language-plaintext highlighter-rouge">__context__</code> in the traceback display and plays no part in the cycle.</p>

<p>The sync client has had the same pattern since #2932 (merged 2023-09-14), with the same comment.</p>

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

<p><code class="language-plaintext highlighter-rouge">test_read_response_error_reply_breaks_reference_cycle</code> needs no server. It swaps in a parser stub that returns a <code class="language-plaintext highlighter-rouge">ResponseError</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">error_refs</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">error_reply</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
        <span class="n">error</span> <span class="o">=</span> <span class="n">ResponseError</span><span class="p">(</span><span class="s">"NOSCRIPT No matching script. Please use EVAL."</span><span class="p">)</span>
        <span class="n">error_refs</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">weakref</span><span class="p">.</span><span class="n">ref</span><span class="p">(</span><span class="n">error</span><span class="p">))</span>
        <span class="k">return</span> <span class="n">error</span>

    <span class="n">conn</span><span class="p">.</span><span class="n">_read_response_from_parser</span> <span class="o">=</span> <span class="n">error_reply</span>

    <span class="n">gc</span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
    <span class="n">gc_was_enabled</span> <span class="o">=</span> <span class="n">gc</span><span class="p">.</span><span class="n">isenabled</span><span class="p">()</span>
    <span class="n">gc</span><span class="p">.</span><span class="n">disable</span><span class="p">()</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">with</span> <span class="n">pytest</span><span class="p">.</span><span class="n">raises</span><span class="p">(</span><span class="n">ResponseError</span><span class="p">):</span>
            <span class="k">await</span> <span class="n">conn</span><span class="p">.</span><span class="n">read_response</span><span class="p">()</span>

        <span class="p">(</span><span class="n">ref</span><span class="p">,)</span> <span class="o">=</span> <span class="n">error_refs</span>
        <span class="k">assert</span> <span class="n">ref</span><span class="p">()</span> <span class="ow">is</span> <span class="bp">None</span>
</code></pre></div></div>

<p>(Excerpt. <code class="language-plaintext highlighter-rouge">conn = Connection()</code> comes first, and a <code class="language-plaintext highlighter-rouge">finally</code> re-enables the collector if it was on.)</p>

<p>Three details make it honest. The error is built inside the stub, so the weakref is the only handle the test has. <code class="language-plaintext highlighter-rouge">pytest.raises</code> has no <code class="language-plaintext highlighter-rouge">as excinfo</code>, so the test does not hold the exception either. I checked: bind <code class="language-plaintext highlighter-rouge">excinfo</code> and the weakref stays alive. And the collector is off, so only reference counting can free it. If a cycle exists, <code class="language-plaintext highlighter-rouge">ref()</code> is not <code class="language-plaintext highlighter-rouge">None</code>. The PR says the test fails without the change and passes with it on Python 3.10, 3.11 and 3.14. It is skipped on PyPy, with the reason “Immediate reclamation is a refcounting property”.</p>

<p>I checked it a second way. I ran the same stub, 1,000 calls, against redis-py 8.1.0 from PyPI: 1000 / 1000 alive. With the four-line change applied to a copy: 0 / 1000.</p>

<h2 id="spot-it-in-your-own-code">Spot it in your own code</h2>

<p>Run a suspect function many times with the collector off, and ask what only a GC pass could free. <code class="language-plaintext highlighter-rouge">gc.DEBUG_SAVEALL</code> keeps collected objects in <code class="language-plaintext highlighter-rouge">gc.garbage</code>, so you can read their types:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">gc</span>


<span class="k">class</span> <span class="nc">Boom</span><span class="p">(</span><span class="nb">Exception</span><span class="p">):</span>
    <span class="k">pass</span>


<span class="k">def</span> <span class="nf">leaky</span><span class="p">():</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">Boom</span><span class="p">(</span><span class="s">"nope"</span><span class="p">)</span>
    <span class="k">raise</span> <span class="n">response</span>


<span class="k">def</span> <span class="nf">fixed</span><span class="p">():</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">Boom</span><span class="p">(</span><span class="s">"nope"</span><span class="p">)</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">response</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="k">del</span> <span class="n">response</span>


<span class="k">def</span> <span class="nf">with_as</span><span class="p">():</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">Boom</span><span class="p">(</span><span class="s">"nope"</span><span class="p">)</span>
    <span class="k">except</span> <span class="n">Boom</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="k">pass</span>


<span class="k">def</span> <span class="nf">saved_as</span><span class="p">():</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">Boom</span><span class="p">(</span><span class="s">"nope"</span><span class="p">)</span>
    <span class="k">except</span> <span class="n">Boom</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="n">saved</span> <span class="o">=</span> <span class="n">e</span>


<span class="k">def</span> <span class="nf">left_for_gc</span><span class="p">(</span><span class="n">fn</span><span class="p">,</span> <span class="n">calls</span><span class="o">=</span><span class="mi">1000</span><span class="p">):</span>
    <span class="n">gc</span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
    <span class="n">gc</span><span class="p">.</span><span class="n">disable</span><span class="p">()</span>
    <span class="n">gc</span><span class="p">.</span><span class="n">set_debug</span><span class="p">(</span><span class="n">gc</span><span class="p">.</span><span class="n">DEBUG_SAVEALL</span><span class="p">)</span>  <span class="c1"># collected objects go to gc.garbage
</span>    <span class="k">try</span><span class="p">:</span>
        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">calls</span><span class="p">):</span>
            <span class="k">try</span><span class="p">:</span>
                <span class="n">fn</span><span class="p">()</span>
            <span class="k">except</span> <span class="n">Boom</span><span class="p">:</span>
                <span class="k">pass</span>
        <span class="n">gc</span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
        <span class="n">errors</span> <span class="o">=</span> <span class="p">[</span><span class="n">o</span> <span class="k">for</span> <span class="n">o</span> <span class="ow">in</span> <span class="n">gc</span><span class="p">.</span><span class="n">garbage</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">o</span><span class="p">,</span> <span class="n">Boom</span><span class="p">)]</span>
        <span class="n">kinds</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">({</span><span class="nb">type</span><span class="p">(</span><span class="n">o</span><span class="p">).</span><span class="n">__name__</span> <span class="k">for</span> <span class="n">o</span> <span class="ow">in</span> <span class="n">gc</span><span class="p">.</span><span class="n">garbage</span><span class="p">})</span>
        <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="n">errors</span><span class="p">),</span> <span class="n">kinds</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="n">gc</span><span class="p">.</span><span class="n">set_debug</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
        <span class="n">gc</span><span class="p">.</span><span class="n">garbage</span><span class="p">.</span><span class="n">clear</span><span class="p">()</span>
        <span class="n">gc</span><span class="p">.</span><span class="n">enable</span><span class="p">()</span>


<span class="k">for</span> <span class="n">fn</span> <span class="ow">in</span> <span class="p">(</span><span class="n">leaky</span><span class="p">,</span> <span class="n">fixed</span><span class="p">,</span> <span class="n">with_as</span><span class="p">,</span> <span class="n">saved_as</span><span class="p">):</span>
    <span class="n">n</span><span class="p">,</span> <span class="n">kinds</span> <span class="o">=</span> <span class="n">left_for_gc</span><span class="p">(</span><span class="n">fn</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">fn</span><span class="p">.</span><span class="n">__name__</span><span class="si">:</span><span class="mi">8</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">n</span><span class="si">:</span><span class="mi">4</span><span class="si">}</span><span class="s"> exceptions left for the GC  </span><span class="si">{</span><span class="n">kinds</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>On Python 3.14.7 it prints:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>leaky    1000 exceptions left for the GC  ['Boom', 'frame', 'traceback']
fixed       0 exceptions left for the GC  []
with_as     0 exceptions left for the GC  []
saved_as 1000 exceptions left for the GC  ['Boom', 'frame', 'traceback']
</code></pre></div></div>

<p>On 3.14 the three type names are the cycle: the exception, its traceback and the frame. Python 3.13 and older also list a <code class="language-plaintext highlighter-rouge">tuple</code>, the exception’s <code class="language-plaintext highlighter-rouge">args</code>. <code class="language-plaintext highlighter-rouge">with_as</code> is clean because Python deletes <code class="language-plaintext highlighter-rouge">e</code>. <code class="language-plaintext highlighter-rouge">saved_as</code> leaks because <code class="language-plaintext highlighter-rouge">saved</code> is a second name.</p>

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

<p>The language solves it for <code class="language-plaintext highlighter-rouge">except ... as</code> by deleting the name. The standard library solves the other cases by hand. This is <code class="language-plaintext highlighter-rouge">Future.__get_result</code> in <code class="language-plaintext highlighter-rouge">concurrent/futures/_base.py</code> from Python 3.14.7:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">_exception</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="k">try</span><span class="p">:</span>
                <span class="k">raise</span> <span class="bp">self</span><span class="p">.</span><span class="n">_exception</span>
            <span class="k">finally</span><span class="p">:</span>
                <span class="c1"># Break a reference cycle with the exception in self._exception
</span>                <span class="bp">self</span> <span class="o">=</span> <span class="bp">None</span>
</code></pre></div></div>

<p>Same shape: raise inside <code class="language-plaintext highlighter-rouge">try</code>, clear the name in <code class="language-plaintext highlighter-rouge">finally</code>. There the loop runs through <code class="language-plaintext highlighter-rouge">self</code>, so it clears <code class="language-plaintext highlighter-rouge">self</code> instead of the exception.</p>

<p>A related PR, <a href="https://github.com/redis/redis-py/pull/4439">#4439</a>, was still open on 2026-10-15. It targets the same kind of cycle in <code class="language-plaintext highlighter-rouge">NodesManager.initialize</code>, where <code class="language-plaintext highlighter-rouge">exception = e</code> stays bound after a later startup node answers.</p>

<p>If you have chased a reference cycle like this one, tell me how you found it. Reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[On 2026-10-15 redis-py merged a small fix I sent: #4434, “Break the ResponseError reference cycle in asyncio read_response”. The fix adds four lines and removes one in redis/asyncio/connection.py. The rest is the test. The review from @petyaslavova was short: it matches the sync Connection.read_response fix from #2932, and the test covers it.]]></summary></entry><entry><title type="html">Agents on shared machines: four ways they quietly hurt the host</title><link href="https://spicysharkco.github.io/2026/10/14/agents-on-shared-machines.html" rel="alternate" type="text/html" title="Agents on shared machines: four ways they quietly hurt the host" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://spicysharkco.github.io/2026/10/14/agents-on-shared-machines</id><content type="html" xml:base="https://spicysharkco.github.io/2026/10/14/agents-on-shared-machines.html"><![CDATA[<p>I run a fleet of coding agents on machines I own. Most are shared: agents, my own work, and a few long-running services on the same box. Agents finish the task in front of them and do not notice what it did to the machine.</p>

<p>None of the four problems below failed a test. Each showed up elsewhere: a process table, a disk counter, a frozen terminal, a loop that never ended. Each became a rule checked before the command runs, not a note written after it breaks.</p>

<h2 id="1-zombies-from-containers-without-an-init">1. Zombies from containers without an init</h2>

<p><strong>What happened.</strong> One container, started by an agent with a plain <code class="language-plaintext highlighter-rouge">docker run</code>, ended up with 790 defunct <code class="language-plaintext highlighter-rouge">git</code> processes. They used no CPU, but they filled the process table and the count kept climbing.</p>

<p><strong>Why.</strong> A zombie is a child that has exited while its parent has not called <code class="language-plaintext highlighter-rouge">wait()</code> to collect its exit status. When a parent dies, its children are reparented to PID 1, and on a normal host PID 1 is an init that reaps them. Inside a container, PID 1 is whatever your entrypoint is, and most programs were never written to do init’s job. An agent’s shell spawns <code class="language-plaintext highlighter-rouge">git</code>, a timeout kills the shell, the orphan lands on PID 1, and nobody reaps it. You cannot kill a zombie, because it is already dead. You can only get its parent to reap it, or end the parent.</p>

<p><strong>The fix.</strong> Run containers with an init:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run --init --rm --pids-limit 512 my-image my-command
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--init</code> makes Docker run a tiny init as PID 1 that starts your command, forwards signals, and reaps orphans. In Compose it is <code class="language-plaintext highlighter-rouge">init: true</code>. <code class="language-plaintext highlighter-rouge">--pids-limit</code> is a seatbelt: a leak hits a ceiling instead of taking the host. Find zombies with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ps -eo stat,ppid,args | awk '$1 ~ /^Z/'
</code></pre></div></div>

<p><strong>How others solve it.</strong> Docker’s <code class="language-plaintext highlighter-rouge">--init</code> is built on tini, and you can put tini or dumb-init in the image with <code class="language-plaintext highlighter-rouge">ENTRYPOINT ["tini", "--"]</code>. That is the portable answer, since Kubernetes has no <code class="language-plaintext highlighter-rouge">--init</code> field on a container. Setting <code class="language-plaintext highlighter-rouge">shareProcessNamespace: true</code> on a pod makes its <code class="language-plaintext highlighter-rouge">pause</code> process PID 1 for every container, and it reaps orphans, at the price of containers seeing each other’s processes. Init containers run once before the app starts and reap nothing.</p>

<h2 id="2-a-test-database-that-wore-down-an-ssd">2. A test database that wore down an SSD</h2>

<p><strong>What happened.</strong> Agents kept starting a throwaway Postgres to test against. For about a week it wrote roughly 1.1 TB a day to an SSD. Every test passed, so nothing prompted anyone to look.</p>

<p><strong>Why.</strong> That is only about 13 MB/s on average, which looks unremarkable on a graph, but it is around 400 TB a year, more than many consumer drives are rated for. Postgres is built to survive a crash: every commit flushes the write-ahead log with <code class="language-plaintext highlighter-rouge">fsync</code>, and the first change to a page after a checkpoint writes the whole page to the log (<code class="language-plaintext highlighter-rouge">full_page_writes</code>). A suite committing thousands of tiny transactions pays that cost every time, to protect data deleted a minute later.</p>

<p><strong>The fix.</strong> Keep test databases in RAM and turn off the durability you do not need:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run --init --rm -p 127.0.0.1::5432 \
  --tmpfs /var/lib/postgresql/data:size=1g \
  -e POSTGRES_PASSWORD=test \
  postgres:17 \
  -c fsync=off -c synchronous_commit=off -c full_page_writes=off
</code></pre></div></div>

<p>On the official Postgres 18 images the data directory moved, so mount the tmpfs at <code class="language-plaintext highlighter-rouge">/var/lib/postgresql</code> instead. Binding to <code class="language-plaintext highlighter-rouge">127.0.0.1</code> keeps the database off the network; the empty host port lets Docker pick one (<code class="language-plaintext highlighter-rouge">docker port</code> shows it). The Postgres documentation is blunt that <code class="language-plaintext highlighter-rouge">fsync=off</code> can corrupt a cluster after a crash. That is fine here: on tmpfs a crash loses the database anyway.</p>

<p>To catch a heavy writer early, <code class="language-plaintext highlighter-rouge">iostat -dx 5</code> shows write throughput per device and <code class="language-plaintext highlighter-rouge">pidstat -d 5</code> per process. For the long view, <code class="language-plaintext highlighter-rouge">smartctl -A</code> on an NVMe drive reports “Data Units Written” with a terabyte figure beside it. Record it daily and diff it.</p>

<p><strong>How others solve it.</strong> CI systems commonly run service databases on tmpfs for the same reason. Many suites also roll back a transaction per test, or clone a template database with <code class="language-plaintext highlighter-rouge">CREATE DATABASE ... TEMPLATE</code>, so there is less to write.</p>

<h2 id="3-one-benchmark-froze-a-shared-box">3. One benchmark froze a shared box</h2>

<p><strong>What happened.</strong> A benchmark an agent launched used about 35 GB on a machine without that much to spare. There was no tidy out-of-memory kill. The machine froze for everyone on it, including services unrelated to the benchmark. A timeout in an agent’s tool layer does not help: it can abandon a command without killing it.</p>

<p><strong>Why.</strong> Linux does not act the moment memory runs out. It reclaims page cache, then swaps, and a box thrashing swap can be unusable for a long time before the OOM killer picks a victim. The victim is chosen system-wide, not necessarily the process that caused the problem. A cgroup limit changes the scope: when a cgroup hits its limit, the OOM killer runs inside that cgroup.</p>

<p><strong>The fix.</strong> Run heavy jobs in a transient systemd scope with a hard cap:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemd-run --user --scope --collect \
  -p MemoryMax=8G -p MemorySwapMax=0 -- ./run-benchmark
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MemoryMax</code> is the hard limit; per the systemd docs, the OOM killer is invoked inside the unit when usage cannot be held under it. <code class="language-plaintext highlighter-rouge">MemorySwapMax=0</code> keeps the job out of swap, so it dies quickly instead of dragging the machine down. <code class="language-plaintext highlighter-rouge">--collect</code> unloads the unit even if it failed, so failed scopes do not pile up. Pick the smallest cap that does the job. On a cgroup v2 distro this normally works as a user; if not, use <code class="language-plaintext highlighter-rouge">sudo</code> for a system scope. <code class="language-plaintext highlighter-rouge">MemoryHigh</code> is the softer option: it throttles instead of killing.</p>

<p><strong>How others solve it.</strong> Docker has <code class="language-plaintext highlighter-rouge">--memory=8g --memory-swap=8g</code>; equal values mean no swap. Kubernetes has <code class="language-plaintext highlighter-rouge">resources.limits.memory</code>, and a container over it is OOM-killed. Underneath, it is all the cgroup v2 files <code class="language-plaintext highlighter-rouge">memory.max</code>, <code class="language-plaintext highlighter-rouge">memory.high</code> and <code class="language-plaintext highlighter-rouge">memory.swap.max</code>. A host-wide backstop like <code class="language-plaintext highlighter-rouge">systemd-oomd</code> or <code class="language-plaintext highlighter-rouge">earlyoom</code> helps, but a per-job cap keeps the failure small.</p>

<h2 id="4-waiting-forever">4. Waiting forever</h2>

<p><strong>What happened.</strong> Agents wait on things: a build, a deploy, a service coming up. Their default is a loop of <code class="language-plaintext highlighter-rouge">sleep</code>, check, <code class="language-plaintext highlighter-rouge">sleep</code>, check. The loop had an exit for success and none for anything else, so a job that had failed, or never started, was polled until I noticed. Every check was also a model call, so waiting cost money too.</p>

<p><strong>Why.</strong> The loop encodes one hope. Nothing in it says how long is too long, and chained short sleeps are the same loop.</p>

<p><strong>The fix.</strong> Every wait gets a deadline and a growing delay, and waits on the event where possible:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wait_for() {   # bash
  local deadline=$((SECONDS + 180)) delay=2
  until "$@"; do
    [ "$SECONDS" -ge "$deadline" ] &amp;&amp; { echo "gave up: $*" &gt;&amp;2; return 1; }
    sleep "$delay"
    delay=$(( delay * 2 &gt; 30 ? 30 : delay * 2 ))
  done
}
wait_for curl -fsS http://localhost:8080/health
</code></pre></div></div>

<p>When the system can answer, ask it: <code class="language-plaintext highlighter-rouge">kubectl wait --for=condition=Ready pod/my-pod --timeout=120s</code> blocks until the condition holds or the timeout hits. <code class="language-plaintext highlighter-rouge">timeout 240 &lt;command&gt;</code> caps anything, and exit status 124 means the cap was hit.</p>

<p><strong>How others solve it.</strong> Most infrastructure has deadlines built in: Kubernetes Jobs have <code class="language-plaintext highlighter-rouge">activeDeadlineSeconds</code>, CI systems have per-job timeouts, systemd has <code class="language-plaintext highlighter-rouge">TimeoutStartSec</code>. Retrying clients use exponential backoff, often with jitter so a crowd does not retry in step.</p>

<h2 id="the-common-thread">The common thread</h2>

<p>Each of these is something a tired human could also have done. Agents do it faster, more often, with nobody watching the machine. So I stopped writing postmortems and started writing preconditions: the container has <code class="language-plaintext highlighter-rouge">--init</code>, the test database is on tmpfs, the heavy job has a memory cap, the wait has a deadline. Each is checked before the command runs.</p>

<p>If you run agents on machines you care about, I would like to hear what yours have done to them. What is the strangest resource burn you have caught? Reply on <a href="https://x.com/spicysharkco">X</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I run a fleet of coding agents on machines I own. Most are shared: agents, my own work, and a few long-running services on the same box. Agents finish the task in front of them and do not notice what it did to the machine.]]></summary></entry></feed>