<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Concurrency on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/concurrency/</link><description>Recent content in Concurrency on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 03 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/concurrency/index.xml" rel="self" type="application/rss+xml"/><item><title>Two telemetry events, one mangled line</title><link>https://phpboyscout.uk/two-events-one-mangled-line/</link><pubDate>Sun, 03 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-events-one-mangled-line/</guid><description>&lt;img src="https://phpboyscout.uk/two-events-one-mangled-line/cover-two-events-one-mangled-line.png" alt="Featured image of post Two telemetry events, one mangled line" /&gt;&lt;p&gt;A line in a log file that no parser would touch. Not a wrong value, not a missing field. Half of one telemetry event spliced into the middle of another, like two people typing into the same text box at once. Which, it turns out, is pretty much exactly what had happened.&lt;/p&gt;
&lt;h2 id="a-format-with-exactly-one-rule"&gt;A format with exactly one rule
&lt;/h2&gt;&lt;p&gt;rust-tool-base writes its telemetry to a file as JSONL: one JSON object per line, newline at the end, next object on the next line. It&amp;rsquo;s a lovely format to work with precisely because it has one rule, and the rule is simple. Every line is a complete object. Honour that and you can &lt;code&gt;tail&lt;/code&gt; it, &lt;code&gt;grep&lt;/code&gt; it, stream it into anything. Break it once and the whole file is suspect, because now a reader can&amp;rsquo;t trust that a line is a line.&lt;/p&gt;
&lt;p&gt;So the one job the file sink has, beyond writing the right bytes, is to never let two events end up sharing a line.&lt;/p&gt;
&lt;h2 id="appending-is-atomic-though-isnt-it"&gt;&amp;ldquo;Appending is atomic, though, isn&amp;rsquo;t it?&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The mental model I started with, and I suspect I&amp;rsquo;m not alone, was this: open the file with &lt;code&gt;O_APPEND&lt;/code&gt;, write the serialised event, and the operating system tacks it onto the end atomically. Two writers can&amp;rsquo;t tread on each other because each &lt;code&gt;write&lt;/code&gt; goes to wherever the end currently is, no questions asked. I&amp;rsquo;d half-remembered &lt;code&gt;O_APPEND&lt;/code&gt; as the thing that makes concurrent appending safe, full stop.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s half true, and the half that&amp;rsquo;s missing is the half that bit me.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; does guarantee one thing: the seek-to-end and the write happen as a unit, so you never get the classic lost-update where two writers compute the same offset and clobber each other. Good. What it does &lt;em&gt;not&lt;/em&gt; guarantee, on POSIX, is that a single &lt;code&gt;write()&lt;/code&gt; of arbitrary size is atomic with respect to other writers. That atomicity has a ceiling, and the ceiling is &lt;code&gt;PIPE_BUF&lt;/code&gt;: 4096 bytes on Linux. Under it, a write is all-or-nothing against other writes to the same file. Over it, the kernel is entirely within its rights to split your write into chunks, and another writer&amp;rsquo;s bytes can land in the gap between them.&lt;/p&gt;
&lt;h2 id="the-fat-event-that-went-over-the-edge"&gt;The fat event that went over the edge
&lt;/h2&gt;&lt;p&gt;For a long time nothing went wrong, which is the most dangerous way for a bug like this to behave. A typical event, a command name, a duration, a status, an attribute or two, serialises to a few hundred bytes. Comfortably under four kilobytes, so comfortably inside the atomic window. Hundreds of them a day, never a problem.&lt;/p&gt;
&lt;p&gt;Then an event came along with a lot of attributes on it, and its serialised form sailed past 4 KiB. Two of &lt;em&gt;those&lt;/em&gt; emitted at roughly the same moment, both over the line, and &lt;code&gt;O_APPEND&lt;/code&gt; did the only thing it had ever promised: it put each write at the end. It said nothing about not interleaving the bytes on the way, because past &lt;code&gt;PIPE_BUF&lt;/code&gt; that was never on offer. One spliced line, one file a parser would now choke on.&lt;/p&gt;
&lt;h2 id="the-fix-isnt-a-bigger-write-its-a-smaller-gate"&gt;The fix isn&amp;rsquo;t a bigger write, it&amp;rsquo;s a smaller gate
&lt;/h2&gt;&lt;p&gt;You can&amp;rsquo;t buy your way out of this with a bigger buffer, because there&amp;rsquo;s no buffer size that&amp;rsquo;s reliably atomic above &lt;code&gt;PIPE_BUF&lt;/code&gt;. The fix is to stop relying on the kernel for mutual exclusion you can do yourself: serialise the events through a lock, so only one &lt;code&gt;write&lt;/code&gt; is ever in flight at a time. The &lt;code&gt;FileSink&lt;/code&gt; carries a mutex for exactly that, and the doc comment on it is the whole post in a paragraph, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-telemetry/src/sink.rs#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-telemetry/src/sink.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;FileSink&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;: &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Serialises concurrent `emit` calls. Shared across `Clone`s of
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// the same `FileSink` so multiple handles to the same path also
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// serialise correctly.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;: &lt;span class="nc"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;tokio&lt;/span&gt;::&lt;span class="n"&gt;sync&lt;/span&gt;::&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you don&amp;rsquo;t write Rust day to day (the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;primer&lt;/a&gt; has the rest of the basics): &lt;code&gt;tokio::sync::Mutex&lt;/code&gt; is an async-aware lock, &lt;code&gt;.await&lt;/code&gt; is where a task waits its turn for that lock without blocking the whole thread, and the &lt;code&gt;Arc&lt;/code&gt; wrapper is shared ownership. That &lt;code&gt;Arc&lt;/code&gt; is the load-bearing bit: it means every clone of the &lt;code&gt;FileSink&lt;/code&gt; points at the &lt;em&gt;same&lt;/em&gt; gate, rather than each getting its own lock that guards nothing.&lt;/p&gt;
&lt;p&gt;The detail I like is &lt;em&gt;where&lt;/em&gt; the lock sits. The event is serialised to a string first, outside the critical section, because turning an event into JSON is the expensive part and there&amp;rsquo;s no reason to hold the gate while you do it. Only then does &lt;code&gt;emit&lt;/code&gt; take the lock, and it holds it across the whole open-write-flush, so no other emit can interleave a single byte:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Serialise the line outside the critical section.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;serde_json&lt;/span&gt;::&lt;span class="n"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;redacted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;_guard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...create parent dir, open with append(true)...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;write_all&lt;/code&gt; makes sure the whole line goes out as one logical write from our side, and the gate makes sure ours is the only one happening. The 4 KiB cliff is still there in the kernel. We just never walk near it any more, because we&amp;rsquo;ve serialised the writers ourselves rather than hoping the OS would.&lt;/p&gt;
&lt;h2 id="the-bit-even-the-lock-cant-fix"&gt;The bit even the lock can&amp;rsquo;t fix
&lt;/h2&gt;&lt;p&gt;There is however a genuine limit, and the comment is upfront about it. The mutex lives in the process. Two &lt;code&gt;FileSink&lt;/code&gt;s in two &lt;em&gt;different&lt;/em&gt; processes, both pointed at the same file, are back to relying on &lt;code&gt;O_APPEND&lt;/code&gt; alone, and back under the 4 KiB ceiling. The lock can&amp;rsquo;t reach across a process boundary, so it doesn&amp;rsquo;t pretend to. The guidance there is the older, duller, correct one: give each process its own file and aggregate them somewhere else. Don&amp;rsquo;t have two processes fighting over one log file and expect the filesystem to referee.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; is a real guarantee, just a much smaller one than its name talks you into. It keeps your write at the end of the file, and it keeps concurrent writes from interleaving only while they stay under &lt;code&gt;PIPE_BUF&lt;/code&gt;, which on Linux is 4096 bytes. A fat JSON event slides straight over that and takes your file&amp;rsquo;s one rule with it.&lt;/p&gt;
&lt;p&gt;The fix was never exotic. Serialise the line, take a mutex, do the write under it, and the interleave can&amp;rsquo;t happen because there&amp;rsquo;s only ever one writer at a time. The POSIX manual had all of this written down long before I went and learned it the interesting way, which is, I&amp;rsquo;m told, how most people meet &lt;code&gt;PIPE_BUF&lt;/code&gt; too.&lt;/p&gt;</description></item><item><title>Process isolation won't save you from the filesystem</title><link>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</guid><description>&lt;img src="https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/cover-process-isolation-wont-save-you-from-the-filesystem.png" alt="Featured image of post Process isolation won't save you from the filesystem" /&gt;&lt;p&gt;A test that passed every single time I ran it on its own, and failed maybe one run in five when I ran the whole suite. The failure was always the same: the self-update test downloaded a release archive, went to extract it, and found the archive corrupt. Half-written. As if something had been scribbling in the file while it read it. Something had.&lt;/p&gt;
&lt;h2 id="the-comfort-i-was-leaning-on"&gt;The comfort I was leaning on
&lt;/h2&gt;&lt;p&gt;The self-update tests are heavier than a unit test wants to be. They stand up a fake release, download the artefact, verify its checksum, extract it, swap a binary. Real files, real I/O. So they&amp;rsquo;d been built to run as separate &lt;em&gt;processes&lt;/em&gt;, not just separate threads, each one its own little world.&lt;/p&gt;
&lt;p&gt;And I&amp;rsquo;d quietly filed that under &amp;ldquo;solved&amp;rdquo;. Separate processes don&amp;rsquo;t share an address space. One can&amp;rsquo;t reach into another&amp;rsquo;s memory and corrupt a value mid-read. That whole category of data race, the kind you reach for a mutex to fix, simply can&amp;rsquo;t happen across a process boundary. So I&amp;rsquo;d stopped thinking about concurrency in these tests at all, because I&amp;rsquo;d convinced myself the isolation was total.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t total. It was isolation of &lt;em&gt;memory&lt;/em&gt;, and I&amp;rsquo;d let myself hear it as isolation of &lt;em&gt;everything&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-processes-one-path"&gt;Two processes, one path
&lt;/h2&gt;&lt;p&gt;The thing two processes very much do still share is the filesystem. And the self-update flow, sensibly, caches its download rather than re-fetching it. The default cache directory is computed from the tool&amp;rsquo;s name and the release version, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/flow.rs#L228" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/flow.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;directories&lt;/span&gt;::&lt;span class="n"&gt;ProjectDirs&lt;/span&gt;::&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;::&lt;span class="n"&gt;env&lt;/span&gt;::&lt;span class="n"&gt;temp_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;to_path_buf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that with two parallel test processes in mind. They&amp;rsquo;re testing the &lt;em&gt;same&lt;/em&gt; tool, against the &lt;em&gt;same&lt;/em&gt; fake release tag. So &lt;code&gt;tool_name&lt;/code&gt; matches and &lt;code&gt;version&lt;/code&gt; matches, which means &lt;code&gt;cache_dir_for&lt;/code&gt; hands both of them the &lt;em&gt;identical path&lt;/em&gt;. Two processes, isolated in every way that involves memory, both downloading and extracting into one shared directory on disk, at the same time. One writes the archive while the other is partway through reading it, and you get exactly the corrupt half-written file the test kept tripping over.&lt;/p&gt;
&lt;p&gt;Process isolation did nothing here, because the contention was never in memory. It was on a path string that came out the same for both of them.&lt;/p&gt;
&lt;h2 id="the-fix-is-to-stop-sharing-the-path"&gt;The fix is to stop sharing the path
&lt;/h2&gt;&lt;p&gt;Once it&amp;rsquo;s framed as &amp;ldquo;they share a path&amp;rdquo;, the fix writes itself: don&amp;rsquo;t share the path. Give each invocation its own cache directory. The updater builder already had the seam for it, and the doc comment now says exactly why it&amp;rsquo;s there, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/updater.rs#L396" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/updater.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Tools call this when they want isolation per-invocation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// (e.g. CI runners, tests with parallel processes) or to honour
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// a user-supplied `--cache-dir` flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;: &lt;span class="nc"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;Self&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each test now builds its updater with &lt;code&gt;cache_dir(its_own_tempdir)&lt;/code&gt;, so two parallel processes land on two different directories and never meet. No lock, no serialisation, no clever cross-process file mutex. Just the realisation that the shared thing was a directory, and the cure for shared mutable state is usually to stop sharing it, not to guard it.&lt;/p&gt;
&lt;h2 id="the-fix-that-turned-out-to-be-a-feature"&gt;The fix that turned out to be a feature
&lt;/h2&gt;&lt;p&gt;The part I&amp;rsquo;m quietly pleased about is that this didn&amp;rsquo;t stay a test-only hack. The override I needed to isolate the tests is exactly the override a real tool wants for its own reasons. A CI runner doing self-update wants a writable cache path it controls, not wherever &lt;code&gt;directories-rs&lt;/code&gt; decides the system cache lives. A user might reasonably want to point the whole thing somewhere specific. That&amp;rsquo;s a &lt;code&gt;--cache-dir&lt;/code&gt; flag, and &lt;code&gt;cache_dir()&lt;/code&gt; is precisely the hook you&amp;rsquo;d wire it to.&lt;/p&gt;
&lt;p&gt;So the thing I added to stop a flaky test is the same thing a downstream tool reaches for to expose &lt;code&gt;--cache-dir&lt;/code&gt;. The test forced the seam to exist, and the seam was worth having anyway. I&amp;rsquo;ll take that trade every time over a fix that only the test suite benefits from.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d treated &amp;ldquo;separate processes&amp;rdquo; as a synonym for &amp;ldquo;can&amp;rsquo;t race&amp;rdquo;, and it isn&amp;rsquo;t. Processes don&amp;rsquo;t share memory, so the memory races are gone. They absolutely still share the filesystem, the network, every named resource the OS will hand to anyone who asks for it by the same name. My two test processes computed the same cache path from the same tool and tag, and raced on the files in it, and no amount of address-space isolation was ever going to touch that.&lt;/p&gt;
&lt;p&gt;Shared mutable state on disk is still shared mutable state. The fix wasn&amp;rsquo;t a bigger hammer, it was giving each process its own directory and letting the isolation I thought I already had actually be true.&lt;/p&gt;</description></item><item><title>A mutex on a flag nobody writes twice</title><link>https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/</guid><description>&lt;img src="https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/cover-a-mutex-on-a-flag-nobody-writes-twice.png" alt="Featured image of post A mutex on a flag nobody writes twice" /&gt;&lt;p&gt;&amp;ldquo;Why is there a mutex around a boolean that only ever gets set once?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a fair question, and I&amp;rsquo;d half-asked it of myself before someone asked it of me. The answer turns out to be written, in as many words, in a code comment I&amp;rsquo;ve grown rather fond of.&lt;/p&gt;
&lt;h2 id="the-registry-and-its-one-way-latch"&gt;The registry and its one-way latch
&lt;/h2&gt;&lt;p&gt;go-tool-base keeps a feature registry: the initialisers, sub-commands, flags and checks that each feature adds to the CLI. Features register themselves into it at startup, from &lt;code&gt;init()&lt;/code&gt;, before &lt;code&gt;main&lt;/code&gt; runs. Once everything&amp;rsquo;s wired, the framework calls &lt;code&gt;SealRegistry()&lt;/code&gt; and the registry latches shut. Any &lt;code&gt;Register&lt;/code&gt; call after that point panics, on purpose, because a sub-command or flag that turns up &lt;em&gt;after&lt;/em&gt; the CLI has parsed its arguments is a bug I want to hear about at once, not discover three releases later.&lt;/p&gt;
&lt;p&gt;So there&amp;rsquo;s a &lt;code&gt;registrySealed&lt;/code&gt; bool. It starts &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;SealRegistry&lt;/code&gt; flips it to &lt;code&gt;true&lt;/code&gt; exactly once in normal operation, nothing flips it back outside of tests, and it&amp;rsquo;s read on every registration attempt. Written once, read many. The textbook shape of &amp;ldquo;you don&amp;rsquo;t need a lock for this.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="except-the-comment-disagrees-on-purpose"&gt;Except the comment disagrees, on purpose
&lt;/h2&gt;&lt;p&gt;Here is the actual declaration, in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/registry.go#L46-54" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/setup/registry.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// registryMu protects globalRegistry and registrySealed. Acquired for write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// by all Register* and Reset/Seal helpers; acquired for read by all Get*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// accessors. The mutex is required for memory visibility of registrySealed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// across goroutines, not only mutual exclusion on the maps.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;registryMu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RWMutex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;registrySealed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That last sentence is the entire post. The mutex has an obvious day job: the registry is a clutch of maps that get appended to during registration, and concurrent appends need genuine mutual exclusion. &lt;code&gt;registrySealed&lt;/code&gt; could have just hitched a ride on that lock and nobody would have thought twice. But the comment goes out of its way to say the lock is &lt;em&gt;also required&lt;/em&gt; for the flag, for visibility, not only exclusion.&lt;/p&gt;
&lt;h2 id="why-a-write-once-bool-still-needs-the-lock"&gt;Why a write-once bool still needs the lock
&lt;/h2&gt;&lt;p&gt;The Go memory model makes no promise that a goroutine reading &lt;code&gt;registrySealed&lt;/code&gt; will ever see the write &lt;code&gt;SealRegistry&lt;/code&gt; made, unless there is a happens-before relationship between them. No synchronisation, no guarantee. A reader can sit there seeing &lt;code&gt;false&lt;/code&gt; long after the seal happened on another goroutine, because the compiler may cache the read and the CPU may serve it from a core-local view. And a concurrent read and write of the same variable, with nothing ordering them, isn&amp;rsquo;t &amp;ldquo;probably fine&amp;rdquo;; it&amp;rsquo;s a data race, which Go defines as undefined behaviour.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;But registration is single-threaded, it&amp;rsquo;s all &lt;code&gt;init()&lt;/code&gt;.&amp;rdquo; It was, right up until we wanted the tests to run in parallel. This lock exists because of a deliberate campaign to restore &lt;code&gt;t.Parallel()&lt;/code&gt; across the codebase after a stack of races forced us to drop it (the same campaign that &lt;a class="link" href="https://phpboyscout.uk/the-test-mocking-pattern-that-races/" &gt;retired the package-level mocking hooks&lt;/a&gt;). Tests build, register, seal and reset this registry from parallel goroutines. The instant that&amp;rsquo;s true, the seal check has to stay correct &lt;em&gt;while racing&lt;/em&gt;, because the very thing it guards against is concurrency. So reads take &lt;code&gt;registryMu.RLock&lt;/code&gt;, the write takes &lt;code&gt;registryMu.Lock&lt;/code&gt;, and now there&amp;rsquo;s a happens-before edge: anyone who acquires the lock after &lt;code&gt;SealRegistry&lt;/code&gt; released it is guaranteed to see &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="what-the-lock-is-actually-for"&gt;What the lock is actually for
&lt;/h2&gt;&lt;p&gt;It isn&amp;rsquo;t there to stop two goroutines both sealing the registry. There&amp;rsquo;s only ever the one seal. It&amp;rsquo;s there so that every reader can trust what it reads. A value written exactly once is precisely the case where you&amp;rsquo;re most tempted to skip the synchronisation, and precisely the case where skipping it can leave a reader legally staring at the stale value for good. The comment spells it out so that the next person to glance at &lt;code&gt;registrySealed&lt;/code&gt;, think &amp;ldquo;that clearly doesn&amp;rsquo;t need a lock,&amp;rdquo; and reach for the delete key, reads the sentence first.&lt;/p&gt;
&lt;p&gt;(There&amp;rsquo;s a sibling &lt;code&gt;sealed&lt;/code&gt; flag in the middleware registry that follows the identical pattern, for the identical reason.)&lt;/p&gt;</description></item><item><title>The test-mocking pattern that races</title><link>https://phpboyscout.uk/the-test-mocking-pattern-that-races/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-test-mocking-pattern-that-races/</guid><description>&lt;img src="https://phpboyscout.uk/the-test-mocking-pattern-that-races/cover-the-test-mocking-pattern-that-races.png" alt="Featured image of post The test-mocking pattern that races" /&gt;&lt;p&gt;I&amp;rsquo;m going to tell you about a bug go-tool-base shipped, because it&amp;rsquo;s one of those bugs that&amp;rsquo;s so reasonable-looking you&amp;rsquo;ll find it in textbooks, conference talks, and an awful lot of otherwise excellent Go code. We had it too. It passed every test on my laptop, every single time, and then quietly fell over on CI while blaming an innocent bystander.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the classic Go trick for mocking a dependency, and it races.&lt;/p&gt;
&lt;h2 id="a-pattern-that-looks-completely-reasonable"&gt;A pattern that looks completely reasonable
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s a thing you need to do constantly in Go tests: stop a function from really shelling out. It calls &lt;code&gt;exec.LookPath&lt;/code&gt; to find a binary, or &lt;code&gt;exec.Command&lt;/code&gt; to run one, and your test very much does not want it touching the real &lt;code&gt;$PATH&lt;/code&gt; or spawning a real process.&lt;/p&gt;
&lt;p&gt;The Go community has a well-worn answer. Hoist the function into a package-level variable, call &lt;em&gt;that&lt;/em&gt;, and let tests reassign it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// production code&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;findTool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;execLookPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;sometool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// test&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;TestFindTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/fake/path&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...assert...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It&amp;rsquo;s tidy. No interface to thread through, no constructor to change. You&amp;rsquo;ll find it in a great deal of Go code, including some very respectable Go code indeed. go-tool-base had it too.&lt;/p&gt;
&lt;p&gt;And it works. It works on your machine, it works in code review, it works the first hundred times CI runs it. Which is precisely what makes it dangerous, because it&amp;rsquo;s wrong, and it&amp;rsquo;s just been biding its time.&lt;/p&gt;
&lt;h2 id="add-one-line-and-it-detonates"&gt;Add one line and it detonates
&lt;/h2&gt;&lt;p&gt;Go&amp;rsquo;s &lt;code&gt;t.Parallel()&lt;/code&gt; is more or less free performance. Mark your tests with it and the runner overlaps them instead of plodding through one at a time. On a package with a few hundred tests it&amp;rsquo;s a real, worthwhile speed-up, so naturally you reach for it.&lt;/p&gt;
&lt;p&gt;Now picture two tests, both using the pattern above, both marked &lt;code&gt;t.Parallel()&lt;/code&gt;. They run concurrently. Test A assigns its fake to &lt;code&gt;execLookPath&lt;/code&gt;. Test B assigns &lt;em&gt;its&lt;/em&gt; fake to &lt;code&gt;execLookPath&lt;/code&gt;. Test A reads &lt;code&gt;execLookPath&lt;/code&gt; expecting its own fake. Two goroutines, one variable, writes and reads with nothing synchronising them. That&amp;rsquo;s a textbook data race, and the textbook is right: the behaviour is undefined. Test A might see B&amp;rsquo;s fake. The deferred restore might land in the wrong order and leave the variable pointing at a fake after both tests have finished, poisoning a third one for good measure.&lt;/p&gt;
&lt;p&gt;The truly nasty part is the &lt;em&gt;intermittency&lt;/em&gt;. Whether the race actually bites depends on goroutine scheduling, which depends on machine load and core count. Your laptop running eight tests at once might never lose the coin-toss. A CI runner under load, scheduling differently, loses it and fails a test that has nothing obviously to do with the change in the commit. You re-run the pipeline, it passes, everyone shrugs and moves on. A test suite that fails one run in twenty trains your team to ignore it, and an ignored CI failure is worse than no CI at all.&lt;/p&gt;
&lt;p&gt;I can tell you this one from direct, slightly embarrassed experience, because go-tool-base shipped exactly this bug and CI caught it the honest way: green on the laptop, red on the runner, with the failure cheerfully pointing at innocent bystander tests rather than the global that was actually the culprit. &lt;code&gt;go test -race&lt;/code&gt; will name it for you if you crank the parallelism up high enough to lose the toss reliably&amp;hellip; but you have to go looking, and you only go looking once it&amp;rsquo;s already ruined an afternoon.&lt;/p&gt;
&lt;h2 id="the-fix-isnt-synchronisation-its-structure"&gt;The fix isn&amp;rsquo;t synchronisation, it&amp;rsquo;s structure
&lt;/h2&gt;&lt;p&gt;The instinct is to slap a mutex around the variable. Resist it. A mutex makes the race &lt;em&gt;defined&lt;/em&gt;, but it doesn&amp;rsquo;t make the design any good. You&amp;rsquo;ve still got global mutable state, you&amp;rsquo;ve just queued the fight instead of cancelling it. And tests that serialise on a shared lock aren&amp;rsquo;t really parallel any more, so you&amp;rsquo;ve also handed back the speed-up you came for in the first place.&lt;/p&gt;
&lt;p&gt;The real fix is to not have a shared variable at all. The dependency was always an &lt;em&gt;input&lt;/em&gt; to the code; the package-level var was just a way of avoiding saying so out loud. So say it. Inject it.&lt;/p&gt;
&lt;p&gt;A struct field:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Finder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;lookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// defaults to exec.LookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Finder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;sometool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or a functional option, if you&amp;rsquo;d rather keep the zero value clean. Either way, each test constructs its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;Finder&lt;/code&gt; with its &lt;em&gt;own&lt;/em&gt; fake. There&amp;rsquo;s no shared variable, so there&amp;rsquo;s no race, and &lt;code&gt;t.Parallel()&lt;/code&gt; is free again because the tests genuinely don&amp;rsquo;t touch each other.&lt;/p&gt;
&lt;p&gt;go-tool-base wrote this straight into its standing rules: no package-level mocking hooks, full stop. Dependencies come in through struct fields, functional options, or config fields. (The same injection discipline that makes &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt; so testable, applied one rung further down.) And to stop everyone hand-rolling the same &lt;code&gt;exec&lt;/code&gt; fakes, there&amp;rsquo;s a small internal package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/exectest/exectest.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;internal/exectest&lt;/code&gt;&lt;/a&gt;, with ready-made &lt;code&gt;LookPath&lt;/code&gt; and &lt;code&gt;CommandContext&lt;/code&gt; doubles you construct per-test. The pattern is gone, and the door it came in through is shut.&lt;/p&gt;
&lt;h2 id="the-rule-worth-taking-away"&gt;The rule worth taking away
&lt;/h2&gt;&lt;p&gt;A package-level variable that tests reassign is shared mutable state. It reads as a harmless convenience because in a single-threaded test run it behaves like one. &lt;code&gt;t.Parallel()&lt;/code&gt; is the thing that reveals it was never harmless, only unobserved.&lt;/p&gt;
&lt;p&gt;The general lesson is older than Go: &lt;strong&gt;if a value is an input to your code, make it an input.&lt;/strong&gt; Smuggling it in as a global is borrowing test-time convenience against a debt that comes due, with interest, the day someone wants their tests to run in parallel. Pay cash. Inject the dependency.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Mocking via a reassignable package-level variable is a beloved Go shortcut and a latent data race. It survives because single-threaded test runs hide it; &lt;code&gt;t.Parallel()&lt;/code&gt; exposes it as intermittent, bystander-blaming CI flake that&amp;rsquo;s miserable to trace. A mutex only makes the bad design &lt;em&gt;defined&lt;/em&gt;. The fix is structural: inject the dependency as a struct field or functional option, so each test owns its own double and there&amp;rsquo;s no shared state to race over. go-tool-base banned the global-hook pattern outright and ships &lt;code&gt;internal/exectest&lt;/code&gt; so nobody&amp;rsquo;s tempted back to it.&lt;/p&gt;
&lt;p&gt;If a piece of code depends on something, let it &lt;em&gt;say&lt;/em&gt; so in its signature. Your future self, staring at a CI failure that flatly refuses to reproduce, will thank you.&lt;/p&gt;</description></item></channel></rss>