<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Go on PHP Boy Scout</title>
        <link>https://phpboyscout.uk/categories/go/</link>
        <description>Recent content in Go on PHP Boy Scout</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-gb</language>
        <copyright>Matt Cockayne</copyright>
        <lastBuildDate>Sat, 17 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/categories/go/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>The library that appointed itself signal handler</title>
            <link>https://phpboyscout.uk/the-library-that-appointed-itself-signal-handler/</link>
            <pubDate>Sat, 17 Oct 2026 00:00:00 +0000</pubDate>
            <guid>https://phpboyscout.uk/the-library-that-appointed-itself-signal-handler/</guid>
            <description>&lt;img src=&#34;https://phpboyscout.uk/the-library-that-appointed-itself-signal-handler/cover-the-library-that-appointed-itself-signal-handler_hu_a20105945663a3b5.jpg&#34; alt=&#34;Featured image of post The library that appointed itself signal handler&#34; /&gt;&lt;p&gt;A commit had gone into &lt;a class=&#34;link&#34; href=&#34;https://gtb.phpboyscout.uk&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;go-tool-base&lt;/a&gt;&amp;rsquo;s root command about a month before I noticed it, and it had been quietly breaking things for anything downstream that used the &lt;code&gt;controls&lt;/code&gt; module the whole time. I didn&amp;rsquo;t find it. Someone raised it with me, which is not the order I&amp;rsquo;d have chosen, and it&amp;rsquo;s on me for not spotting it when it went in.&lt;/p&gt;&#xA;&lt;h2 id=&#34;two-right-answers-one-process&#34;&gt;Two right answers, one process&#xA;&lt;/h2&gt;&lt;p&gt;The commit gave the root command a signal-aware execution context: catch SIGINT, cancel the context, let the command wind itself up properly. That&amp;rsquo;s a good change, and an obviously right one. A CLI wants to flush buffered telemetry before it dies, clean up a half-written self-update, and let a long-running command notice it&amp;rsquo;s been asked to stop.&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;d have approved it. I &lt;em&gt;did&lt;/em&gt; approve it, apparently (without reading it anything like carefully enough), and the trouble is what was already sitting underneath. &lt;a class=&#34;link&#34; href=&#34;https://controls.go.phpboyscout.uk/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;go/controls&lt;/code&gt;&lt;/a&gt; is the supervisor that runs long-lived services and stops them in the right order, which I wrote up in &lt;a class=&#34;link&#34; href=&#34;https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/&#34; &gt;lifecycle management for long-running Go services&lt;/a&gt;. And for most of its life it caught SIGINT and SIGTERM itself, by default.&lt;/p&gt;&#xA;&lt;p&gt;That was also correct! A supervisor sat in a bare &lt;code&gt;main&lt;/code&gt; really &lt;em&gt;is&lt;/em&gt; the outermost thing in the process, and catching the interrupt to drive a graceful stop is its job. Doing it without being asked is a kindness. Two right decisions, eighteen months apart, and a collision that only exists where they overlap.&lt;/p&gt;&#xA;&lt;h2 id=&#34;nothing-errors-which-is-the-problem&#34;&gt;Nothing errors, which is the problem&#xA;&lt;/h2&gt;&lt;p&gt;Go will happily let two parts of one process both handle Ctrl-C, because &lt;code&gt;signal.Notify&lt;/code&gt; is additive: register a second channel and it doesn&amp;rsquo;t replace the first, it starts posting to both. No error, no warning, no log line saying &amp;ldquo;you appear to have two of these&amp;rdquo;. Just two shutdown drivers, racing. So a default about process-global state is less a default about your library and more a claim about your position in someone else&amp;rsquo;s process, and the post doesn&amp;rsquo;t arrive telling you when that position has changed.&lt;/p&gt;&#xA;&lt;h2 id=&#34;my-own-tools-had-already-told-me&#34;&gt;My own tools had already told me&#xA;&lt;/h2&gt;&lt;p&gt;So I went and looked at what the downstream tools were actually doing, and this is the part that smarted. Two of them had hit this unaided and written essentially the same workaround, with the same reasoning, without ever comparing notes: one had a comment explaining that the framework&amp;rsquo;s root command already installs the handler that cancels the context so a second one would race it, and the other said it drives its stop from context cancellation specifically to avoid a second handler.&lt;/p&gt;&#xA;&lt;p&gt;Same conclusion, same boilerplate, arrived at independently by two tools I wrote, working around a problem I&amp;rsquo;d shipped. And a third had never opted out at all. It had been running both handlers the whole time and I hadn&amp;rsquo;t noticed, because from the outside this looks like a slightly chatty shutdown.&lt;/p&gt;&#xA;&lt;p&gt;When two of your users pay the same tax and a third is silently broken, I think you can put the design principles away. The users had already told me the default was wrong, and they&amp;rsquo;d gone to the trouble of writing it in the comments (my comments, in my tools, which somehow makes it worse).&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-part-that-actually-hurt&#34;&gt;The part that actually hurt&#xA;&lt;/h2&gt;&lt;p&gt;Duplicated log lines are the visible symptom, and they&amp;rsquo;re the least of it. &lt;code&gt;controls&lt;/code&gt; publishes a contract. When a service&amp;rsquo;s context is done, &lt;code&gt;context.Cause(ctx)&lt;/code&gt; tells you &lt;em&gt;why&lt;/em&gt;: if it&amp;rsquo;s &lt;code&gt;ErrShutdown&lt;/code&gt;, your controller stopped you deliberately, and anything else means something upstream died and you should behave differently. That&amp;rsquo;s how a service decides whether to finish flushing or bail out now.&lt;/p&gt;&#xA;&lt;p&gt;With two handlers, that becomes a coin flip. &lt;code&gt;WithCancelCause&lt;/code&gt; hands you the &lt;em&gt;parent&amp;rsquo;s&lt;/em&gt; cause when the parent cancels first, so if the framework&amp;rsquo;s handler wins the race, the controller&amp;rsquo;s own &lt;code&gt;cancel(ErrShutdown)&lt;/code&gt; turns up late and does nothing at all.&lt;/p&gt;&#xA;&lt;p&gt;Same code, same signal, and a different answer depending on which goroutine the scheduler fancied. A published guarantee, broken by scheduling, with no error anywhere in sight. &amp;ldquo;It worked when I tested it&amp;rdquo; and &amp;ldquo;it is correct&amp;rdquo; have never been the same claim, and this is the gap that lives between them.&lt;/p&gt;&#xA;&lt;h2 id=&#34;somewhere-else-in-the-estate-id-got-it-right&#34;&gt;Somewhere else in the estate, I&amp;rsquo;d got it right&#xA;&lt;/h2&gt;&lt;p&gt;What convinced me this was a principle and not a signals quirk is that another module already handled it properly, by default, and had done from day one. &lt;code&gt;go/transport-metrics&lt;/code&gt; sets up Prometheus collectors, and its documentation is blunt about it:&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;The default is a fresh, private registry (never the global &lt;code&gt;prometheus.DefaultRegisterer&lt;/code&gt;), so a tool&amp;rsquo;s metrics are self-contained and tests do not collide.&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;Different process-global, identical reasoning, opposite instinct. Two libraries in one estate touching shared state, one grabbing it by default and one refusing to, and that comparison did more for the argument than anything I could have written in a spec.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-default-went-the-other-way-round&#34;&gt;The default went the other way round&#xA;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;NewController&lt;/code&gt; no longer installs a signal handler at all, and the doc comment explains itself so you don&amp;rsquo;t have to work it out:&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;It does NOT install an OS signal handler. Signal disposition is process-global state and belongs to whichever layer is outermost, typically the CLI framework or main. Pass &lt;code&gt;WithSignals&lt;/code&gt; when the controller genuinely is that outermost layer.&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;p&gt;The behaviour still exists, you just have to ask for it, and in a bare &lt;code&gt;main&lt;/code&gt; where the supervisor really is the outermost thing it&amp;rsquo;s &lt;code&gt;WithSignals()&lt;/code&gt; and you&amp;rsquo;re away. One small thing I got right by accident: the spec ended up raised against &lt;code&gt;controls&lt;/code&gt; and not against the framework, because that&amp;rsquo;s the module whose default was wrong. A boring filing decision, but it means the reasoning now lives next to the code it governs instead of in the repo of whoever happened to notice.&lt;/p&gt;&#xA;&lt;p&gt;And &lt;a class=&#34;link&#34; href=&#34;https://keryx.phpboyscout.uk&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;keryx&lt;/a&gt;&amp;rsquo;s comment has changed status without changing much text. It used to be a workaround describing what it was avoiding. It now reads as a note citing the decision. Same words, different thing.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-fix-i-didnt-ship&#34;&gt;The fix I didn&amp;rsquo;t ship&#xA;&lt;/h2&gt;&lt;p&gt;The obvious worry with handing signals to the CLI is whether the service still gets long enough to shut down cleanly if the framework owns the interrupt. I had a whole cooperative-drain protocol specced out for it. Handshakes, acknowledgements, the lot.&lt;/p&gt;&#xA;&lt;p&gt;Then I went and read three call sites, and found the graceful path already waits. &lt;code&gt;Wait()&lt;/code&gt; blocks &lt;code&gt;RunE&lt;/code&gt;, which blocks &lt;code&gt;ExecuteContext&lt;/code&gt;, so the root command &lt;em&gt;cannot physically reach its exit path&lt;/em&gt; until the controller has fully stopped. The problem I&amp;rsquo;d spent an evening designing around did not exist, and the only real gap was a single force-exit goroutine on the second Ctrl-C, which is a much smaller creature. An entire protocol, specced and binned, on the strength of going and reading the code instead of reasoning about it.&lt;/p&gt;&#xA;&lt;p&gt;That&amp;rsquo;s the second time in this story that the answer was sitting in a file I&amp;rsquo;d already written, once in my consumers&amp;rsquo; comments and once in my own call sites. I should probably read more and design less.&lt;/p&gt;</description>
        </item><item>
            <title>Merged in five days, open in seven hundred</title>
            <link>https://phpboyscout.uk/merged-in-five-days-open-in-seven-hundred/</link>
            <pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate>
            <guid>https://phpboyscout.uk/merged-in-five-days-open-in-seven-hundred/</guid>
            <description>&lt;img src=&#34;https://phpboyscout.uk/merged-in-five-days-open-in-seven-hundred/cover-merged-in-five-days-open-in-seven-hundred_hu_46b1cd30f9a17a09.jpg&#34; alt=&#34;Featured image of post Merged in five days, open in seven hundred&#34; /&gt;&lt;p&gt;Twice in two days I watched a hint vanish.&lt;/p&gt;&#xA;&lt;p&gt;The first time was in &lt;code&gt;chat&lt;/code&gt;. I found where it had gone, wrote the workaround, made a note in the spec and moved on, because that&amp;rsquo;s what you do with a small thing at the end of a day. The second time was in &lt;code&gt;forge&lt;/code&gt;, the following evening&amp;hellip; and a small thing that happens twice is not a small thing any more. So instead of writing the second workaround, I went and read the library.&lt;/p&gt;&#xA;&lt;p&gt;About four hours later the estate had an errors package of its own, public, with a docs site and a v0.1.0 tag on it. Which is a fair old distance to travel between tea and bedtime, so let me back up a bit.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-a-hint-is-and-where-it-went&#34;&gt;What a hint is, and where it went&#xA;&lt;/h2&gt;&lt;p&gt;This only lands if you know what a hint is, so bear with me for a paragraph.&lt;/p&gt;&#xA;&lt;p&gt;Go errors nest. You take a failure, wrap it with a bit of context, wrap that with a bit more, and what comes out the far end is a little chain you can walk back down. &lt;code&gt;cockroachdb/errors&lt;/code&gt;, which the whole estate has been built on since it was one framework, lets you hang extra things off that chain. A &lt;em&gt;hint&lt;/em&gt; is the useful one: not what went wrong, but what the person at the keyboard should actually &lt;strong&gt;do&lt;/strong&gt; about it. Something fails four layers down, the code that catches it walks the chain, collects the hints, and the user gets told to import their keys rather than just &amp;ldquo;permission denied&amp;rdquo;. I wrote about that idea back &lt;a class=&#34;link&#34; href=&#34;https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/&#34; &gt;when the estate first leaned into it&lt;/a&gt;, and it&amp;rsquo;s the one thing I really didn&amp;rsquo;t want to give up.&lt;/p&gt;&#xA;&lt;p&gt;Go 1.20 added &lt;code&gt;errors.Join&lt;/code&gt;, which bundles several errors together into one. The standard library gave those bundles a shape you can recognise on sight: an &lt;code&gt;Unwrap&lt;/code&gt; that hands back a &lt;em&gt;slice&lt;/em&gt; of errors instead of a single one. Plural. Go and look in all of them.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;cockroachdb/errors&lt;/code&gt; is older than that by some years, and its own &lt;code&gt;Join&lt;/code&gt; puts a stack-capturing wrapper around the bundle. So from the outside, a bundle looks exactly like any ordinary one-link error. Anything walking the chain arrives, sees a single link, follows it, and stops. Everything below the join is invisible: hints, details, context tags, telemetry keys, the lot.&lt;/p&gt;&#xA;&lt;p&gt;Which, it turns out, somebody had already gone to the trouble of reporting. &lt;a class=&#34;link&#34; href=&#34;https://github.com/cockroachdb/errors/issues/162&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Issue #162&lt;/a&gt;, raised on the 14th of May, and they put it more precisely than I would have. The standard library lets you do this:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;err.(interface{ Unwrap() []error }).Unwrap()&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and with &lt;code&gt;cockroachdb/errors&lt;/code&gt; you need this:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;err.(interface{ Unwrap() error }).Unwrap().(interface{ Unwrap() []error }).Unwrap()&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An extra hop, through a wrapper nobody asked for. And it does it even when you join a &lt;em&gt;single&lt;/em&gt; error, which is the part that actually bit me&amp;hellip; code that behaves itself all day breaks the moment it happens to take the aggregate path with one thing in the bag.&lt;/p&gt;&#xA;&lt;h2 id=&#34;it-isnt-a-bug-and-thats-worse&#34;&gt;It isn&amp;rsquo;t a bug, and that&amp;rsquo;s worse&#xA;&lt;/h2&gt;&lt;p&gt;So I wasn&amp;rsquo;t the first to find it, which was a small comfort right up until I looked at the date again. Getting on for three months, and the issue is still open. Nobody has replied to it at all.&lt;/p&gt;&#xA;&lt;p&gt;One stale issue tells you nothing, mind. Every project has those, mine included. So I went and read the rest of the tracker.&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a class=&#34;link&#34; href=&#34;https://github.com/cockroachdb/errors/issues/162&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;#162&lt;/a&gt;, the defect above&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;open since May, no reply&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a class=&#34;link&#34; href=&#34;https://github.com/cockroachdb/errors/pull/147&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;#147&lt;/a&gt;, a one-character docs typo&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;open since &lt;strong&gt;3 August 2024&lt;/strong&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a class=&#34;link&#34; href=&#34;https://github.com/cockroachdb/errors/pull/129&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;#129&lt;/a&gt;, &lt;code&gt;slog.LogValuer&lt;/code&gt; support&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;open since &lt;strong&gt;2023&lt;/strong&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;&lt;a class=&#34;link&#34; href=&#34;https://github.com/cockroachdb/errors/pull/159&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;#159&lt;/a&gt;, a compatibility fix that broke CockroachDB&amp;rsquo;s own build&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;merged in &lt;strong&gt;five days&lt;/strong&gt;&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;That last row is the whole story. Five days! Because it was breaking something CockroachDB needed.&lt;/p&gt;&#xA;&lt;p&gt;Which is not neglect, and I want to be careful here, because it would be very easy to write the angry version of this post and the angry version would be wrong. That library is maintained, openly and perfectly rationally, for one consumer&amp;rsquo;s needs. Things CockroachDB needs move in days. Everything else waits years. That&amp;rsquo;s a perfectly sensible way to run a library and a poor thing to build fifty modules on top of.&lt;/p&gt;&#xA;&lt;h2 id=&#34;half-the-reasons-i-picked-it&#34;&gt;Half the reasons I picked it&#xA;&lt;/h2&gt;&lt;p&gt;I chose &lt;code&gt;cockroachdb/errors&lt;/code&gt; for exactly two reasons, and I know that because I wrote them down at the time: the feature set, which was enormous, and the fact that it was well maintained and I could rely on it.&lt;/p&gt;&#xA;&lt;p&gt;The features held. Every one of them is still there, still working, still better than what I&amp;rsquo;ve replaced them with, if all you&amp;rsquo;re counting is features. It is the second reason that quietly stopped being true, and half a premise is not a premise.&lt;/p&gt;&#xA;&lt;h2 id=&#34;i-have-been-here-before-and-i-picked-the-same-way&#34;&gt;I have been here before, and I picked the same way&#xA;&lt;/h2&gt;&lt;p&gt;Errors are the fourth thing go-tool-base has stood on, and if you find yourself choosing an error library for the fourth time you probably ought to be a bit suspicious of yourself.&lt;/p&gt;&#xA;&lt;p&gt;It started where everything starts, on the standard library. Then &lt;a class=&#34;link&#34; href=&#34;https://github.com/go-errors/errors&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;go-errors/errors&lt;/code&gt;&lt;/a&gt;, six years or so back, in the days of gtb&amp;rsquo;s various predecessors. It was great when I picked it up&amp;hellip; then it got old, rather quickly&amp;hellip; and then it stopped really being looked after at all. By the time gtb was taking its current shape I could feel it holding me back, so I went hunting for something newer, more mature, better maintained.&lt;/p&gt;&#xA;&lt;p&gt;That hunt was about eighteen months ago and it came down to two. &lt;code&gt;pkg/errors&lt;/code&gt; was the obvious candidate, and I passed on it. Not on features. On its long backlog of open issues. &lt;code&gt;cockroachdb/errors&lt;/code&gt; looked like the better-kept project of the pair, so that is where the estate went, and where it has been ever since.&lt;/p&gt;&#xA;&lt;p&gt;Go back and read that again, because I have, a few times. Eighteen months ago I rejected a library because too many of its issues were sitting unanswered, and picked the one that looked healthier. And I have just left &lt;em&gt;that&lt;/em&gt; one for the same reason. Same test. Same instinct. Different answer.&lt;/p&gt;&#xA;&lt;p&gt;Which isn&amp;rsquo;t a contradiction, and I&amp;rsquo;m not embarrassed by it. That&amp;rsquo;s all a dependency choice ever is: a bet you place on the evidence in front of you, on the day you place it. It was the right call. Things move.&lt;/p&gt;&#xA;&lt;p&gt;What has changed is what it costs to be wrong. That migration was done by hand, find-and-replace, no agent to help me. Dull and slow, but gtb was still a single monolithic framework back then, so it was one codebase and I could kinda hold the whole shape of it in my head. This time it&amp;rsquo;s fifty-plus modules, every last one built on the assumption that &lt;code&gt;cockroachdb/errors&lt;/code&gt; is simply what we use. That&amp;rsquo;s not a job you take on lightly, agent or no agent.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bar-and-whether-this-clears-it&#34;&gt;The bar, and whether this clears it&#xA;&lt;/h2&gt;&lt;p&gt;Now&amp;hellip; the bit I&amp;rsquo;m touchiest about.&lt;/p&gt;&#xA;&lt;p&gt;I cannot stand &amp;ldquo;Not Invented Here&amp;rdquo;. I call it out when I see it, probably harder than is entirely fair. And I&amp;rsquo;m well aware that a bloke with fifty modules to his name, announcing he&amp;rsquo;s gone and written his own errors package, is on fairly thin ice here.&lt;/p&gt;&#xA;&lt;p&gt;So there&amp;rsquo;s a bar, and everything I&amp;rsquo;ve built has had to get over it before I&amp;rsquo;d write a line:&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;It addresses a need nothing out there is meeting.&lt;/li&gt;&#xA;&lt;li&gt;It brings something genuinely new.&lt;/li&gt;&#xA;&lt;li&gt;I can realistically expand and maintain it.&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;&lt;code&gt;chat&lt;/code&gt; clears it: a much lighter-weight alternative to langchain, which is a wonderful thing and absolutely enormous. &lt;code&gt;config&lt;/code&gt; clears it, on provenance, layering and true writeback. &lt;code&gt;forge&lt;/code&gt; clears it, because the forge SDKs are wildly inconsistent where they exist at all. What you don&amp;rsquo;t see is the pile that didn&amp;rsquo;t clear it, and there is a pile. Only recently I put proper time into investigating a proposal, wrote the thing up, and then binned it, because it didn&amp;rsquo;t satisfy a single one of the three.&lt;/p&gt;&#xA;&lt;p&gt;And in the interests of not marking my own homework too generously: scoutdm exists largely because I wanted it to exist. That&amp;rsquo;s hubris and I know it. Only time will tell whether I was right.&lt;/p&gt;&#xA;&lt;p&gt;So. Does an errors package get over it?&lt;/p&gt;&#xA;&lt;p&gt;The first one, yes, and I went and checked properly rather than assuming, because assuming is exactly how you end up rebuilding something that already exists. Every alternative is a worse maintenance bet than the thing I&amp;rsquo;m leaving, and not one of them has a hint concept at all. The closest, &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/tozd/go/errors&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;tozd/go/errors&lt;/code&gt;&lt;/a&gt;, is genuinely good and I want to be fair to it: it takes &lt;code&gt;pkg/errors&lt;/code&gt;, keeps the API, and fixes a long list of things the archived original was never going to get to, because the fixes needed breaking changes. It even keeps a &lt;code&gt;pkg/errors&lt;/code&gt; import on purpose, so you can mix the two while you migrate off. There are just no hints in it, and hints are what I came for.&lt;/p&gt;&#xA;&lt;p&gt;The third one, comfortably. The core is 476 lines, and the whole of its &lt;code&gt;go.mod&lt;/code&gt; is this:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;module gitlab.com/phpboyscout/go/errors&#xA;&#xA;go 1.26.5&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Not &amp;ldquo;few dependencies&amp;rdquo;. None. And that&amp;rsquo;s &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go/errors/-/blob/cd76b0d/depfootprint_test.go#L9-19&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;asserted by a test&lt;/a&gt; rather than left to my good intentions, because every module in the estate imports errors, so whatever it drags in, they all drag in. That&amp;rsquo;s exactly how the library I&amp;rsquo;m leaving came to contribute forty-six of forge&amp;rsquo;s three hundred and seventy-six transitive packages, a deprecated &lt;code&gt;gogo/protobuf&lt;/code&gt; among them, in return for the ten or so functions I actually call.&lt;/p&gt;&#xA;&lt;p&gt;The second one is where it wobbles, and I&amp;rsquo;d rather say so myself than have you spot it. There&amp;rsquo;s nothing novel in here. It&amp;rsquo;s a small, dull errors package that does what the estate needs and then stops. Two out of three, and the one it&amp;rsquo;s short on is the one I&amp;rsquo;d hold somebody else to.&lt;/p&gt;&#xA;&lt;h2 id=&#34;where-i-left-the-holes&#34;&gt;Where I left the holes&#xA;&lt;/h2&gt;&lt;p&gt;The one thing I did dig my heels in about, before a line was written, was where the gaps go. I&amp;rsquo;ve paid for this migration twice now and I&amp;rsquo;d quite like the third to be the last.&lt;/p&gt;&#xA;&lt;p&gt;Wire serialization was a headline reason I picked cockroachdb in the first place. Nothing I run today needs it, but plenty of what I want to build will, and losing the ability to add it later would make this whole move a downgrade. So it comes later, as a sibling module, and the core is already shaped to take it: every layer carries a stable kind, and nothing anywhere type-switches on a concrete type. Which means a decoder that meets a kind it has never heard of can still carry that layer along without snapping the chain.&lt;/p&gt;&#xA;&lt;p&gt;OpenTelemetry got the better question, and I asked it against my own proposal: should an error fire an OTEL call at all? An error that reports itself is doing two jobs and holding opinions about your observability stack. Deciding a thing is worth recording belongs where a span is in scope, not where the error got made. So the package ships no &lt;code&gt;RecordError&lt;/code&gt;, only the contract you&amp;rsquo;d build one from. &lt;code&gt;go/observability&lt;/code&gt; already owns that relationship and it can keep it.&lt;/p&gt;&#xA;&lt;p&gt;Not YAGNI, and not building a cathedral for an imaginary congregation either. Somewhere in between: work out which of the things you might want later would force you to tear it up and start again, and leave holes the right shape for those. Those only.&lt;/p&gt;&#xA;&lt;p&gt;The cutover is running as I write this. Leaves first, working upward, which isn&amp;rsquo;t the order you&amp;rsquo;d guess, and is a story for another day.&lt;/p&gt;&#xA;&lt;p&gt;It lives at &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go/errors&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;gitlab.com/phpboyscout/go/errors&lt;/code&gt;&lt;/a&gt;, with docs at &lt;a class=&#34;link&#34; href=&#34;https://errors.go.phpboyscout.uk&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;errors.go.phpboyscout.uk&lt;/a&gt;. MIT, no dependencies, and it kept the cockroachdb names on purpose, so if you happen to be in the same boat the move is mostly one import path and a &lt;code&gt;sed&lt;/code&gt;. I make no grander claim for it than that it&amp;rsquo;s what my estate runs on now, which is either a recommendation or a warning depending on how the next year goes.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;cockroachdb/errors&lt;/code&gt; is still there, and it&amp;rsquo;s still good. Still maintained exactly as carefully as it was last week, by somebody doing a perfectly reasonable job for the people he&amp;rsquo;s doing it for. That one-character docs typo is two years old today, to the day, and nobody is going to mark the occasion. Somebody is still waiting on it, and none of that is my problem any more.&lt;/p&gt;&#xA;&lt;p&gt;I just stopped being one of the people it was for.&lt;/p&gt;</description>
        </item><item>
            <title>The version pin you can&#39;t lower</title>
            <link>https://phpboyscout.uk/the-version-pin-you-cant-lower/</link>
            <pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate>
            <guid>https://phpboyscout.uk/the-version-pin-you-cant-lower/</guid>
            <description>&lt;img src=&#34;https://phpboyscout.uk/the-version-pin-you-cant-lower/cover-the-version-pin-you-cant-lower_hu_db4884785751b4ee.jpg&#34; alt=&#34;Featured image of post The version pin you can&#39;t lower&#34; /&gt;&lt;p&gt;A security fix landed in the Go standard library, the kind you don&amp;rsquo;t get to think very hard about. You bump &lt;code&gt;go.mod&lt;/code&gt; to the patched toolchain, 1.26.4, and you move on. Or you would, if your release didn&amp;rsquo;t immediately fall over the moment you tagged it.&lt;/p&gt;&#xA;&lt;p&gt;Here&amp;rsquo;s what greeted me when I cut the next krites release. The before-hooks, the &lt;code&gt;go mod tidy&lt;/code&gt; and friends that run ahead of the actual build, died on the spot:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;go.mod requires go &amp;gt;= 1.26.4 (running go 1.26.3; GOTOOLCHAIN=local)&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goreleaser image I was releasing from, a perfectly current &lt;code&gt;v2.16.0&lt;/code&gt;, bundled Go 1.26.3. One patch version behind. And &lt;code&gt;GOTOOLCHAIN=local&lt;/code&gt;, which is the sensible default for a release because it means &amp;ldquo;use exactly the Go that&amp;rsquo;s installed, don&amp;rsquo;t go fetching anything mid-build&amp;rdquo;, was doing precisely its job: it saw &lt;code&gt;go.mod&lt;/code&gt; asking for a toolchain it didn&amp;rsquo;t have, and refused to pretend otherwise.&lt;/p&gt;&#xA;&lt;p&gt;Now, the fix that&amp;rsquo;s quickest to reach for is the one you have to talk yourself out of. The error is a version mismatch, and the fastest way to make a version mismatch go away is to change one of the versions. The image has 1.26.3? Fine, drop &lt;code&gt;go.mod&lt;/code&gt; back to 1.26.3 and the red goes green. Except think about what that actually does. The whole reason &lt;code&gt;go.mod&lt;/code&gt; says 1.26.4 is that 1.26.4 carries a security fix I need. Lowering the pin to placate the build image doesn&amp;rsquo;t resolve the tension, it surrenders to it. It un-applies the security update to make a pipeline pass, which is about the worst trade in software, and it&amp;rsquo;s the one sitting right there with a green tick on it.&lt;/p&gt;&#xA;&lt;p&gt;So that pin is one you can&amp;rsquo;t lower. Which leaves making the &lt;em&gt;build&lt;/em&gt; find the right toolchain instead of making the requirement smaller.&lt;/p&gt;&#xA;&lt;p&gt;The quick version is one flag: flip &lt;code&gt;GOTOOLCHAIN&lt;/code&gt; from &lt;code&gt;local&lt;/code&gt; to &lt;code&gt;auto&lt;/code&gt;. Where &lt;code&gt;local&lt;/code&gt; means &amp;ldquo;only ever use what&amp;rsquo;s installed&amp;rdquo;, &lt;code&gt;auto&lt;/code&gt; means &amp;ldquo;if &lt;code&gt;go.mod&lt;/code&gt; asks for a newer toolchain than you&amp;rsquo;ve got, go and fetch that one and use it for this build&amp;rdquo;. So the release runs on the image&amp;rsquo;s 1.26.3, hits the &lt;code&gt;go.mod&lt;/code&gt; directive, fetches 1.26.4 and carries on. The mismatch is resolved by upgrading at build time rather than by downgrading the requirement, which is the right direction for it to resolve in.&lt;/p&gt;&#xA;&lt;p&gt;The better version, which is where krites ended up, is to not have the gap at all. The release now runs on &lt;a class=&#34;link&#34; href=&#34;https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/&#34; &gt;the dev-tools image&lt;/a&gt; I bake my CI tools into, and that image tracks the Go version my code actually targets, 1.26.4. When the toolchain in the image and the toolchain in &lt;code&gt;go.mod&lt;/code&gt; are the same thing by construction, there&amp;rsquo;s nothing to fetch and no flag to set, because there&amp;rsquo;s no disagreement to paper over.&lt;/p&gt;&#xA;&lt;p&gt;The toolchain your code &lt;em&gt;requires&lt;/em&gt; and the toolchain your build image &lt;em&gt;ships&lt;/em&gt; are two different facts, and they drift apart every time a point release with a security fix lands in one a good while before it reaches the other. When they disagree, the instinct to make them agree by lowering the requirement is exactly backwards: the requirement is the thing that&amp;rsquo;s right, and the environment is the thing that&amp;rsquo;s behind. The pin only ever moves one way. The build image is what had to catch up to it, not the code learning to want less.&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=&#34;https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/cover-a-mutex-on-a-flag-nobody-writes-twice_hu_a14a19d31aa0a888.jpg&#34; alt=&#34;Featured image of post A mutex on a flag nobody writes twice&#34; /&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-registry-and-its-one-way-latch&#34;&gt;The registry and its one-way latch&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;except-the-comment-disagrees-on-purpose&#34;&gt;Except the comment disagrees, on purpose&#xA;&lt;/h2&gt;&lt;p&gt;Here is the actual declaration, in &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/registry.go#L46-54&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;pkg/setup/registry.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;// registryMu protects globalRegistry and registrySealed. Acquired for write&#xA;// by all Register* and Reset/Seal helpers; acquired for read by all Get*&#xA;// accessors. The mutex is required for memory visibility of registrySealed&#xA;// across goroutines, not only mutual exclusion on the maps.&#xA;var (&#xA;&#x9;registryMu     sync.RWMutex&#xA;&#x9;registrySealed bool&#xA;)&#xA;&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;&#xA;&lt;h2 id=&#34;why-a-write-once-bool-still-needs-the-lock&#34;&gt;Why a write-once bool still needs the lock&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://phpboyscout.uk/the-test-mocking-pattern-that-races/&#34; &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;&#xA;&lt;h2 id=&#34;what-the-lock-is-actually-for&#34;&gt;What the lock is actually for&#xA;&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;&#xA;&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></channel>
</rss>
