<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <updated>2026-06-07T18:51:03.532833+00:00</updated>
  
  <author>
    <name>Alex Chan</name>
    <email>alex@alexwlchan.net</email>
  </author>
  
  <link href="https://alexwlchan.net/notes/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://alexwlchan.net/notes/" rel="alternate" type="text/html" />
  <id>https://alexwlchan.net/</id>
  <title type="html">alexwlchan’s notes</title>
  <subtitle>Alex Chan’s notes</subtitle>

<entry>
  <title type="html">Notes from The Cornishman No. 176 (Spring 2026)</title>
  <link
    href="https://alexwlchan.net/notes/2026/cornishment-176/"
    rel="alternate"
    type="text/html"
    title="Notes from _The Cornishman_ No. 176 (Spring 2026)"
  />
  <published>2026-06-07T13:44:59+01:00</published>
  <updated>2026-06-07T13:44:59+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/cornishment-176/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/cornishment-176/">
    <![CDATA[<p><strong>Sheep at the station, trains on the road, and cracks in the boiler.</strong></p><p>The <a href="https://gwsr.vticket.co.uk/product.php/1294/cornishman-gwsr">Cornishman magazine</a> is a magazine sent to members of the Gloucestershire Warwickshire Steam Railway (GWSR), a heritage railway in the Cotswolds.
My granddad used to volunteer with the railway and still gives me a copy after he’s read it; these are my favourite parts from a recent issue.</p>
<picture>
<source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/cornishman-176_1x.avif 250w, https://alexwlchan.net/images/2026/cornishman-176_2x.avif 500w, https://alexwlchan.net/images/2026/cornishman-176_3x.avif 750w" type="image/avif"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/cornishman-176_1x.webp 250w, https://alexwlchan.net/images/2026/cornishman-176_2x.webp 500w, https://alexwlchan.net/images/2026/cornishman-176_3x.webp 750w" type="image/webp"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/cornishman-176_1x.jpg 250w, https://alexwlchan.net/images/2026/cornishman-176_2x.jpg 500w, https://alexwlchan.net/images/2026/cornishman-176_3x.jpg 750w" type="image/jpeg"/><img alt="Cover of issue 176. A steam train on a clear winter day is approaching the camera, pulling half a dozen coaches in a variety of coours, with steam billowing from the chimney and engine." src="https://alexwlchan.net/images/2026/cornishman-176_1x.jpg" width="250"/>
</picture>
<p>This passage about Toddington, by Rose Phillips (page 22) reminded me of a similar incident described in <a href="https://alexwlchan.net/book-reviews/false-starts-near-misses-and-dangerous-goods/"><em>False Starts, Near Misses and Dangerous Goods</em></a>:</p>
<blockquote>
<p>
    One autumn day, luckily not a running day, a call was received to help round up some sheep which had found their way into the station.
    No grass-cutting needed that week!
    But after the sheep had been safely removed, the incident prompt`ed a careful check of the fences.
  </p>
<figure>
<picture>
<source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/sheep-at-toddington_1x.avif 500w, https://alexwlchan.net/images/2026/sheep-at-toddington_2x.avif 1000w, https://alexwlchan.net/images/2026/sheep-at-toddington_3x.avif 1500w" type="image/avif"/><source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/sheep-at-toddington_1x.webp 500w, https://alexwlchan.net/images/2026/sheep-at-toddington_2x.webp 1000w, https://alexwlchan.net/images/2026/sheep-at-toddington_3x.webp 1500w" type="image/webp"/><source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/sheep-at-toddington_1x.jpg 500w, https://alexwlchan.net/images/2026/sheep-at-toddington_2x.jpg 1000w, https://alexwlchan.net/images/2026/sheep-at-toddington_3x.jpg 1500w" type="image/jpeg"/><img src="https://alexwlchan.net/images/2026/sheep-at-toddington_1x.jpg" width="500"/>
</picture>
<figcaption>
      Wooly helpers at Toddington. Photo by Peter Nash.
    </figcaption>
</figure>
</blockquote>
<p>Chris Bambridge’s account of “Construction &amp; Maintenance” (page 23) caught my eye, because it names a railway feature that I often see, but didn’t know the name of.</p>
<blockquote>
<p>
    We are also becoming proficient in making dagger boards for station canopies where these have rotted away.
    Painting the individual components takes several weeks and will when installed be food for a significant period of time.
    Several station canopies are to be addressed.
  </p>
<p>
<picture>
<source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/dagger-boards_1x.avif 250w, https://alexwlchan.net/images/2026/dagger-boards_2x.avif 500w, https://alexwlchan.net/images/2026/dagger-boards_3x.avif 750w" type="image/avif"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/dagger-boards_1x.webp 250w, https://alexwlchan.net/images/2026/dagger-boards_2x.webp 500w, https://alexwlchan.net/images/2026/dagger-boards_3x.webp 750w" type="image/webp"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/dagger-boards_1x.jpg 250w, https://alexwlchan.net/images/2026/dagger-boards_2x.jpg 500w, https://alexwlchan.net/images/2026/dagger-boards_3x.jpg 750w" type="image/jpeg"/><img alt="A row of wooden boards which form a triangle point at the end, stacked in a workshop." src="https://alexwlchan.net/images/2026/dagger-boards_1x.jpg" width="250"/>
</picture>
</p>
</blockquote>
<p>On page 29, I was fascinated by Pete Mason’s explanation of how locomotive 35006 was transported to another railway for a gala event:</p>
<blockquote>
<p>One of the main risks of transporting a steam locomotive by road is the process of loading the loco onto the road transporter.
As the loco rises up the ramp, the weight is transferred off some wheelsets and onto others, greatly exceeding the loads that the springs are designed for.
To avoid damage, we insist that stop blocks are placed to limit the movement of the axleboxes so that the weight of the loco doesn’t cause the springs to move beyond their limit.
This transfers the weight of the loco directly to the wheels, without the springs exceeding their design load.
These blocks have to ben removed again as soon as the loco is unloaded so that the loco is once again sitting on its own suspension.</p>
<p>At Kidderminster, access to the site is very restricted and the loco was delivered the opposite way round to what was required.
Once the loco and tender had been coupled together, the loco was shunted onto the turntable.
The crew then used the manual handles to crank the loco round so that it faced the desired direction – the first time the loco had been on a turntable since the 1960s.</p>
</blockquote>
<p>The news was not so good for locomotive 7903, which John Cruxon explained on page 30:</p>
<blockquote>
<p>
    I am sure most volunteers and shareholders will have heard that 7903 has been withdrawn for overhaul following an unsuccessful boiler extension extension examination. [...]
  </p>
<p>
    To obtain that extension, the boiler had to undergo non-destructive testing of the firebox stays.
    These are the copper and steel rods, known as stays, that hold the inner copper firebox apart from the outer steel firebox.
    In normal service, when we wash out a boiler and test for broken stays, we tap them with a small hammer and listen for a clear ringing of "pinging" sound.
  </p>
<p>
    This test had been carried out and all stays appeared to be satisfactory.
    However, when the BES engineer carried out testing using electronic equipment, which sends a signal down each stay to confirm it is intact, five copper stays failed to register any indication, suggesting that they are broken.
  </p>
<p>
<picture>
<source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/boiler-7903_1x.avif 500w, https://alexwlchan.net/images/2026/boiler-7903_2x.avif 1000w" type="image/avif"/><source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/boiler-7903_1x.webp 500w, https://alexwlchan.net/images/2026/boiler-7903_2x.webp 1000w" type="image/webp"/><source sizes="(max-width:500px)100vw,500px" srcset="https://alexwlchan.net/images/2026/boiler-7903_1x.jpg 500w, https://alexwlchan.net/images/2026/boiler-7903_2x.jpg 1000w" type="image/jpeg"/><img alt="A large metal boiler made of an inner chamber and an outer skin, with thin rods visible connecting the two." src="https://alexwlchan.net/images/2026/boiler-7903_1x.jpg" width="500"/>
</picture>
</p>
</blockquote>
<p>This reminded me of the film <em>Oh, Mr Porter!</em> in which Will Hay plays a <a href="https://en.wikipedia.org/wiki/Wheel_tapper">wheeltapper</a> – using a hammer to test stays feels very similar.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/cornishment-176/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="The world around us" />

    <summary type="html">Sheep at the station, trains on the road, and cracks in the boiler.</summary>
</entry><entry>
  <title type="html">GitUp can’t diff text files larger than 8MB</title>
  <link
    href="https://alexwlchan.net/notes/2026/gitup-diff-8mb/"
    rel="alternate"
    type="text/html"
    title="GitUp can't diff text files larger than 8MB"
  />
  <published>2026-05-27T00:27:03+01:00</published>
  <updated>2026-05-27T00:27:03+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/gitup-diff-8mb/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/gitup-diff-8mb/">
    <![CDATA[<p><strong>Larger files don’t appear in the diff view; they’re just shown as a file icon.</strong></p><p>I use <a href="https://github.com/git-up/GitUp">GitUp</a> as my Git interface, and I was wondering why some of my text files were no longer showing a graphical diff in the interface.</p>
<p>I tried bisecting the files, thinking it might be some special characters that cause the file to be registering as a binary file, and discovered that 8,388,608 bytes is the magic number – it doesn’t produce diffs for files larger than that.
Note that
<math>
<mn>8,388,608</mn>
<mo>=</mo>
<mn>8</mn>
<mo>×</mo>
<msup>
<mn>2</mn>
<mn>20</mn>
</msup>
</math> – that is, 8MB.</p>
<p>A quick search shows other tools having bugs or edge cases at 8,388,608 bytes.</p>
<p>I’m not going to dive into the GitUp source code to work out what the issue is here; for now it’s enough to know I can fix my diffs by reducing the size of my source files.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/gitup-diff-8mb/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Git" />

    <summary type="html">Larger files don't appear in the diff view; they're just shown as a file icon.</summary>
</entry><entry>
  <title type="html">Format “human-readable” JSON with FracturedJSON &amp;rarr;</title>
  <link
    href="https://github.com/j-brooke/FracturedJson/wiki"
    rel="alternate"
    type="text/html"
    title="Format &quot;human-readable&quot; JSON with FracturedJSON"
  />
  <published>2026-05-15T08:56:07+01:00</published>
  <updated>2026-05-15T08:56:07+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/fractured-json/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/fractured-json/">
    <![CDATA[<p><strong>Short objects and arrays are compressed onto a single line; larger objects are displayed as a table. Available in a variety of languages.</strong></p><p>Here’s an interesting project that balances compact and human-readable JSON, automatically applying certain sensibilities that somebody might add if they were writing JSON by hand:</p>
<blockquote>
<p>FracturedJson is a family of utilities that format JSON data in a way that’s easy for humans to read, but fairly compact. Arrays and objects are written on single lines, as long as they’re neither too long nor too complex. When several such lines are similar in structure, they’re written with fields aligned like a table. Long arrays are written with multiple items per line across multiple lines.</p>
</blockquote>
<p>Here’s an example of the output it produces:</p>
<pre class="lng-json"><code><span class="p">{</span>
    "BasicObject"   <span class="p">:</span> <span class="p">{</span>
        "ModuleId"   <span class="p">:</span> <span class="s2">"armor"</span><span class="p">,</span>
        "Name"       <span class="p">:</span> <span class="s2">""</span><span class="p">,</span>
        "Locations"  <span class="p">:</span> <span class="p">[</span>
            <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">4</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">5</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">6</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">7</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">8</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span>  <span class="mi">9</span><span class="p">],</span>
            <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">10</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">11</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">12</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">13</span><span class="p">],</span> <span class="p">[</span><span class="mi">11</span><span class="p">,</span> <span class="mi">14</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">14</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">13</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">12</span><span class="p">],</span>
            <span class="p">[</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">11</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">10</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">9</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">8</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">7</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">6</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">5</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">4</span><span class="p">],</span>
            <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span> <span class="mi">1</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">4</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">5</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">6</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">7</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">8</span><span class="p">,</span>  <span class="mi">2</span><span class="p">],</span> <span class="p">[</span> <span class="mi">8</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span>
            <span class="p">[</span> <span class="mi">7</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span> <span class="mi">6</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span> <span class="mi">5</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span> <span class="mi">4</span><span class="p">,</span>  <span class="mi">3</span><span class="p">],</span> <span class="p">[</span> <span class="mi">0</span><span class="p">,</span>  <span class="mi">4</span><span class="p">],</span> <span class="p">[</span> <span class="mi">0</span><span class="p">,</span>  <span class="mi">5</span><span class="p">],</span> <span class="p">[</span> <span class="mi">0</span><span class="p">,</span>  <span class="mi">6</span><span class="p">],</span> <span class="p">[</span> <span class="mi">0</span><span class="p">,</span>  <span class="mi">7</span><span class="p">],</span>
            <span class="p">[</span> <span class="mi">0</span><span class="p">,</span>  <span class="mi">8</span><span class="p">],</span> <span class="p">[</span><span class="mi">12</span><span class="p">,</span>  <span class="mi">8</span><span class="p">],</span> <span class="p">[</span><span class="mi">12</span><span class="p">,</span>  <span class="mi">7</span><span class="p">],</span> <span class="p">[</span><span class="mi">12</span><span class="p">,</span>  <span class="mi">6</span><span class="p">],</span> <span class="p">[</span><span class="mi">12</span><span class="p">,</span>  <span class="mi">5</span><span class="p">],</span> <span class="p">[</span><span class="mi">12</span><span class="p">,</span>  <span class="mi">4</span><span class="p">]</span>
        <span class="p">],</span>
        "Orientation"<span class="p">:</span> <span class="s2">"Fore"</span><span class="p">,</span>
        "Seed"       <span class="p">:</span> <span class="mi">272691529</span>
    <span class="p">},</span>
    "SimilarArrays" <span class="p">:</span> <span class="p">{</span>
        "Katherine"<span class="p">:</span> <span class="p">[</span><span class="s2">"blue"</span><span class="p">,</span>       <span class="s2">"lightblue"</span><span class="p">,</span> <span class="s2">"black"</span>       <span class="p">],</span>
        "Logan"    <span class="p">:</span> <span class="p">[</span><span class="s2">"yellow"</span><span class="p">,</span>     <span class="s2">"blue"</span><span class="p">,</span>      <span class="s2">"black"</span><span class="p">,</span> <span class="s2">"red"</span><span class="p">],</span>
        "Erik"     <span class="p">:</span> <span class="p">[</span><span class="s2">"red"</span><span class="p">,</span>        <span class="s2">"purple"</span>                   <span class="p">],</span>
        "Jean"     <span class="p">:</span> <span class="p">[</span><span class="s2">"lightgreen"</span><span class="p">,</span> <span class="s2">"yellow"</span><span class="p">,</span>    <span class="s2">"black"</span>       <span class="p">]</span>
    <span class="p">},</span>
    "SimilarObjects"<span class="p">:</span> <span class="p">[</span>
        <span class="p">{</span> "type"<span class="p">:</span> <span class="s2">"turret"</span><span class="p">,</span>    "hp"<span class="p">:</span> <span class="mi">400</span><span class="p">,</span> "loc"<span class="p">:</span> <span class="p">{</span>"x"<span class="p">:</span> <span class="mi">47</span><span class="p">,</span> "y"<span class="p">:</span>  <span class="mi">-4</span><span class="p">},</span> "flags"<span class="p">:</span> <span class="s2">"S"</span>   <span class="p">},</span>
        <span class="p">{</span> "type"<span class="p">:</span> <span class="s2">"assassin"</span><span class="p">,</span>  "hp"<span class="p">:</span>  <span class="mi">80</span><span class="p">,</span> "loc"<span class="p">:</span> <span class="p">{</span>"x"<span class="p">:</span> <span class="mi">12</span><span class="p">,</span> "y"<span class="p">:</span>   <span class="mi">6</span><span class="p">},</span> "flags"<span class="p">:</span> <span class="s2">"Q"</span>   <span class="p">},</span>
        <span class="p">{</span> "type"<span class="p">:</span> <span class="s2">"berserker"</span><span class="p">,</span> "hp"<span class="p">:</span> <span class="mi">150</span><span class="p">,</span> "loc"<span class="p">:</span> <span class="p">{</span>"x"<span class="p">:</span>  <span class="mi">0</span><span class="p">,</span> "y"<span class="p">:</span>   <span class="mi">0</span><span class="p">}</span>                 <span class="p">},</span>
        <span class="p">{</span> "type"<span class="p">:</span> <span class="s2">"pittrap"</span><span class="p">,</span>              "loc"<span class="p">:</span> <span class="p">{</span>"x"<span class="p">:</span> <span class="mi">10</span><span class="p">,</span> "y"<span class="p">:</span> <span class="mi">-14</span><span class="p">},</span> "flags"<span class="p">:</span> <span class="s2">"S,I"</span> <span class="p">}</span>
    <span class="p">]</span>
<span class="p">}</span></code></pre>
<p>I haven’t tried the code at all, but noting so I can find the project again.</p>
<p>There are implementations in several languages, including .NET, JavaScript, Rust, and Python.
The Python libraries are thin wrappers around the .NET and Rust versions, or there’s a <a href="https://github.com/masaccio/compact-json">deprecated <code>compact-json</code> project</a> that’s written in pure Python.
It also reminds me of Tailscale’s <a href="https://github.com/tailscale/hujson">HuJSON library</a>, which lines up object keys in a similar way when you format your JSON.</p>
<p>For a while I’ve wanted something like this for <a href="https://alexwlchan.net/projects/javascript-data-files/">javascript-data-files</a>, and I had a brief attempt at it – but this is a much harder problem than I initially realised.
I don’t want this behaviour enough to add a dependency, and it’s more complicated than I want to implement or maintain on my own (even if I vendor an existing project).</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/fractured-json/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Computers and code" />

    <summary type="html">Short objects and arrays are compressed onto a single line; larger objects are displayed as a table. Available in a variety of languages.</summary>
</entry><entry>
  <title type="html">Testing the width of a page on a mobile device using Playwright</title>
  <link
    href="https://alexwlchan.net/notes/2026/test-width-of-page/"
    rel="alternate"
    type="text/html"
    title="Testing the width of a page on a mobile device using Playwright"
  />
  <published>2026-05-06T19:27:20+01:00</published>
  <updated>2026-05-06T19:27:20+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/test-width-of-page/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/test-width-of-page/">
    <![CDATA[<p><strong>Create a new browser context with a narrow screen, then get <code>document.body.scrollWidth</code> to get the width of the displayed page.</strong></p><p>One perennial source of bugs on this site is incorrect page widths on mobile devices: something isn’t wrapping or cropping properly, and it forces the whole page to be too wide.</p>
<p>For a long time I’ve been playing whack-a-mole with these bugs, but after writing about <a href="https://alexwlchan.net/2026/playwright/">using Playwright</a>, I realised I could write a regression test.</p>
<p>Here’s the approximate test I came up with.
It creates a new browser context with a mobile screen size, opens the page I’m interested in, then runs some JavaScript on the page to measure the scroll width:</p>
<pre class="lng-python"><code><span class="k">def</span> <span class="n">test_page_is_right_size_on_narrow_screens</span><span class="p">(</span><span class="n">browser</span><span class="p">:</span> Browser<span class="p">,</span> <span class="n">base_url</span><span class="p">:</span> str<span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
    <span class="sd">"""</span>
<span class="sd">    Check that on narrow screens, pages size to fit the screen.</span>

<span class="sd">    This is a regression test for issues I've had in the past where</span>
<span class="sd">    some wide element breaks the page when the window is narrower than it.</span>
<span class="sd">    """</span>
    <span class="n">width</span> <span class="o">=</span> <span class="mi">350</span>
    <span class="n">height</span> <span class="o">=</span> <span class="mi">650</span>

    <span class="n">context</span> <span class="o">=</span> browser<span class="o">.</span>new_context<span class="p">(</span>viewport<span class="o">=</span><span class="p">{</span><span class="s2">"width"</span><span class="p">:</span> width<span class="p">,</span> <span class="s2">"height"</span><span class="p">:</span> height<span class="p">})</span>

    <span class="n">page</span> <span class="o">=</span> context<span class="o">.</span>new_page<span class="p">()</span>
    page<span class="o">.</span>goto<span class="p">(</span>base_url <span class="o">+</span> <span class="s2">"/computers-and-code/"</span><span class="p">)</span>

    <span class="n">scroll_width</span> <span class="o">=</span> page<span class="o">.</span>evaluate<span class="p">(</span><span class="s2">"document.body.scrollWidth"</span><span class="p">)</span>

    <span class="k">assert</span> scroll_width <span class="o">==</span> width</code></pre>
<p>I don’t think I’d want to write a regression test for every CSS change, but it’s cool I can do it for this class of especially annoying bugs.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/test-width-of-page/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Software testing" />
    <category term="Blogging about blogging" />

    <summary type="html">Create a new browser context with a narrow screen, then get `document.body.scrollWidth` to get the width of the displayed page.</summary>
</entry><entry>
  <title type="html">Disable AirPods charging notifications</title>
  <link
    href="https://alexwlchan.net/notes/2026/disable-airpods-charging-notifications/"
    rel="alternate"
    type="text/html"
    title="Disable AirPods charging notifications"
  />
  <published>2026-05-02T06:45:10+01:00</published>
  <updated>2026-05-02T06:45:10+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/disable-airpods-charging-notifications/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/disable-airpods-charging-notifications/">
    <![CDATA[<p><strong>In Settings &gt; Bluetooth, I can stop my iPhone telling me that my AirPods have charged overnight.</strong></p><p>Every morning, I wake up to a notification on my iPhone:</p>
<blockquote>
<p>Alex’s AirPods Pro 2 are fully charged.</p>
</blockquote>
<p>This is not a surprise.
This is not news.
This is something iOS could have worked out will always be the case, because I have a charging stand where I put my AirPods overnight.
My AirPods are <em>always</em> fully charged when I wake up.</p>
<p>I have, on multiple occasions, searched the “Notifications” area of the iPhone Settings app to find where I can disable this notification, to no avail.</p>
<p>Today, I got a respite: I found an <a href="https://support.apple.com/en-gb/119912">Apple Support document</a> that explains where to configure charging notifications:</p>
<blockquote>
<p>You can use your iPhone or iPad to […] notify you when charging is complete.</p>
<ol>
<li>Put your AirPods in your ears, and make sure they’re connected to your iPhone or iPad.</li>
<li>On your iPhone or iPad, go to Settings, select your AirPods, then tap Battery.</li>
<li>Turn on Charging Notifications.</li>
</ol>
</blockquote>
<p>I have no idea why these aren’t also available in the Notifications settings, but I can <em>finally</em> stop these pointless messages.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/disable-airpods-charging-notifications/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Computers and code" />

    <summary type="html">In Settings &gt; Bluetooth, I can stop my iPhone telling me that my AirPods have charged overnight.</summary>
</entry><entry>
  <title type="html">Start a Caddy server in a subprocess during a Python session</title>
  <link
    href="https://alexwlchan.net/notes/2026/caddy-in-pytest/"
    rel="alternate"
    type="text/html"
    title="Start a Caddy server in a subprocess during a Python session"
  />
  <published>2026-04-30T09:04:38+01:00</published>
  <updated>2026-04-30T09:04:38+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/caddy-in-pytest/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/caddy-in-pytest/">
    <![CDATA[<p><strong>Start the server with <code>subprocess.Popen</code>, poll until it’s available, yield the base URL, then clean up the process when you’re done.</strong></p><p>For local developemnt and testing of this site, I’ve started to run a Caddy server with my real config to be a better replica of my real setup.
I wanted to start a Caddy server in my automated tests, and shut it down when I’m done.</p>
<p>Here’s the fixture I came up with:</p>
<pre class="lng-python"><code><span class="kn">from</span> <span class="n">collections</span><span class="p">.</span><span class="n">abc</span> <span class="kn">import</span> <span class="n">Iterator</span>
<span class="kn">import</span> <span class="n">subprocess</span>
<span class="kn">from</span> <span class="n">subprocess</span> <span class="kn">import</span> <span class="n">PIPE</span>
<span class="kn">import</span> <span class="n">time</span>
<span class="kn">import</span> <span class="n">urllib</span><span class="p">.</span><span class="n">error</span>
<span class="kn">import</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span>

<span class="kn">import</span> <span class="n">pytest</span>


@pytest<span class="o">.</span>fixture<span class="p">(</span>scope<span class="o">=</span><span class="s2">"session"</span><span class="p">)</span>
<span class="k">def</span> <span class="n">caddy_server_url</span><span class="p">()</span> <span class="o">-&gt;</span> Iterator<span class="p">[</span>str<span class="p">]:</span>
    <span class="sd">"""</span>
<span class="sd">    Start an instance of Caddy running in the current directory, and</span>
<span class="sd">    return the base URL.</span>
<span class="sd">    """</span>
    <span class="n">port</span> <span class="o">=</span> <span class="mi">5858</span>
    <span class="n">cmd</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"caddy"</span><span class="p">,</span> <span class="s2">"file-server"</span><span class="p">,</span> <span class="s2">"--listen"</span><span class="p">,</span> <span class="sa">f</span><span class="s2">":</span><span class="si">{</span>port<span class="si">}</span><span class="s2">"</span><span class="p">]</span>

    <span class="k">with</span> subprocess<span class="o">.</span>Popen<span class="p">(</span>cmd<span class="p">,</span> stdout<span class="o">=</span>PIPE<span class="p">,</span> stderr<span class="o">=</span>PIPE<span class="p">)</span> <span class="k">as</span> <span class="n">proc</span><span class="p">:</span>
        <span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"http://localhost:</span><span class="si">{</span>port<span class="si">}</span><span class="s2">/"</span>

        <span class="c1"># Wait for up to a second waiting for the server to start.</span>
        <span class="c1">#</span>
        <span class="c1"># If we get a ConnectionRefusedError, the server hasn't started yet.</span>
        <span class="c1"># If we get an HTTPError or a 200 OK, we've connected to the server</span>
        <span class="c1"># and it's serving HTTP traffic, so it's started.</span>
        <span class="n">t0</span> <span class="o">=</span> time<span class="o">.</span>time<span class="p">()</span>
        <span class="k">while</span> time<span class="o">.</span>time<span class="p">()</span> <span class="o">-</span> t0 <span class="o">&lt;</span> <span class="mi">1</span><span class="p">:</span>
            <span class="k">try</span><span class="p">:</span>
                urllib<span class="o">.</span>request<span class="o">.</span>urlopen<span class="p">(</span>url<span class="p">)</span>
            <span class="k">except</span> urllib<span class="o">.</span>error<span class="o">.</span>HTTPError<span class="p">:</span>
                <span class="k">break</span>
            <span class="k">except</span> urllib<span class="o">.</span>error<span class="o">.</span>URLError <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
                <span class="k">if</span> exc<span class="o">.</span>args <span class="ow">and</span> isinstance<span class="p">(</span>exc<span class="o">.</span>args<span class="p">[</span><span class="mi">0</span><span class="p">],</span> ConnectionRefusedError<span class="p">):</span>
                    <span class="k">pass</span>
                <span class="k">else</span><span class="p">:</span>
                    <span class="k">raise</span>
            <span class="k">else</span><span class="p">:</span>
                <span class="k">break</span>

        <span class="k">assert</span> <span class="ow">not</span> proc<span class="o">.</span>poll<span class="p">()</span>

        <span class="k">yield</span> url

        proc<span class="o">.</span>terminate<span class="p">()</span>
        proc<span class="o">.</span>wait<span class="p">(</span>timeout<span class="o">=</span><span class="mi">1</span><span class="p">)</span></code></pre>
<p>And here’s what a test looks like:</p>
<pre class="lng-python"><code><span class="k">def</span> <span class="n">test_can_start_web_server</span><span class="p">(</span><span class="n">caddy_server_url</span><span class="p">:</span> str<span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
    <span class="sd">"""</span>
<span class="sd">    Fetch a page from the running web server.</span>
<span class="sd">    """</span>
    <span class="n">resp</span> <span class="o">=</span> urllib<span class="o">.</span>request<span class="o">.</span>urlopen<span class="p">(</span>caddy_server_url <span class="o">+</span> <span class="s2">"example.py"</span><span class="p">)</span>
    <span class="k">assert</span> <span class="sa">b</span><span class="s2">"test_can_start_web_server"</span> <span class="ow">in</span> resp<span class="o">.</span>read<span class="p">()</span></code></pre>
<p>The fixture starts a file server with <code>subprocess</code>, then polls until the server is available.
On my Mac mini, Caddy takes ~0.01s to start – long enough I can’t start running tests immediately, fast enough that any fixed sleep would be inefficient (especially as it’ll be slower in CI).</p>
<p>At the end of the fixture, I call <code>proc.terminate()</code> and <code>proc.wait()</code> to clean everything up.
The <code>terminate()</code> sends a SIGTERM; the <code>wait()</code> blocks until the child process terminates.
The process shuts down quickly, but I do need to wait or I get warnings from pytest that I have an unterminated process.</p>
<p>The fixture is session-scoped so I only have to start/stop the server once across my test suite.</p>
<p>In my real codebase, this code is split across two functions – a function that starts the server, and a function that wraps it in a pytest fixture.
I reuse the server function in my <code>serve_site.py</code> script, which runs a local development server for the site.
I’m also pointing it at a <code>Caddyfile</code> with my site config, rather than running a bare <code>file_server</code>.</p>
<p>This is similar to <a href="https://til.simonwillison.net/pytest/subprocess-server">Simon Willison’s recipe</a>.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/caddy-in-pytest/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Python" />
    <category term="Caddy" />
    <category term="Software testing" />

    <summary type="html">Start the server with `subprocess.Popen`, poll until it's available, yield the base URL, then clean up the process when you're done.</summary>
</entry><entry>
  <title type="html">Filter a list of JSON object based on a list of tags</title>
  <link
    href="https://alexwlchan.net/notes/2026/jq-filter-by-array/"
    rel="alternate"
    type="text/html"
    title="Filter a list of JSON object based on a list of tags"
  />
  <published>2026-04-28T22:52:44+01:00</published>
  <updated>2026-04-28T22:52:44+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/jq-filter-by-array/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/jq-filter-by-array/">
    <![CDATA[<p><strong>Use the <code>arrays</code> filter to remove empty values, the <code>any(…)</code> filter to check for set inclusion, then wrap the whole thing in square brackets.</strong></p><p>Here’s a problem I’ve had several times recently: I have an array of JSON objects which have an array of string tags, and I want to filter for objects with matching tags.
Sometimes the array of tags is <code>null</code> rather than an empty list.</p>
<p>Here’s an example:</p>
<pre class="lng-json"><code><span class="p">[</span>
  <span class="p">{</span>"id"<span class="p">:</span> <span class="s2">"square"</span><span class="p">,</span>    "tags"<span class="p">:</span> <span class="p">[</span><span class="s2">"quadrilateral"</span><span class="p">,</span> <span class="s2">"2d"</span><span class="p">]},</span>
  <span class="p">{</span>"id"<span class="p">:</span> <span class="s2">"rectangle"</span><span class="p">,</span> "tags"<span class="p">:</span> <span class="p">[</span><span class="s2">"quadrilateral"</span><span class="p">,</span> <span class="s2">"2d"</span><span class="p">]},</span>
  <span class="p">{</span>"id"<span class="p">:</span> <span class="s2">"triangle"</span><span class="p">,</span>  "tags"<span class="p">:</span> <span class="p">[</span><span class="s2">"2d"</span><span class="p">]},</span>
  <span class="p">{</span>"id"<span class="p">:</span> <span class="s2">"blob"</span><span class="p">,</span>      "tags"<span class="p">:</span> <span class="kc">null</span><span class="p">},</span>
  <span class="p">{</span>"id"<span class="p">:</span> <span class="s2">"tagless"</span><span class="p">}</span>
<span class="p">]</span></code></pre>
<p>(The field isn’t always called <code>tags</code>, but this general pattern is common.)</p>
<p>Here’s the jq filter I need:</p>
<pre class="lng-shell wrap"><code>jq <span class="s1">'[ .[] | select(.tags | arrays and any(. == "quadrilateral")) ]'</span></code></pre>
<p>whcih returns the following output:</p>
<pre class="lng-json"><code><span class="p">[</span>
  <span class="p">{</span>
    "id"<span class="p">:</span> <span class="s2">"square"</span><span class="p">,</span>
    "tags"<span class="p">:</span> <span class="p">[</span>
      <span class="s2">"quadrilateral"</span><span class="p">,</span>
      <span class="s2">"2d"</span>
    <span class="p">]</span>
  <span class="p">},</span>
  <span class="p">{</span>
    "id"<span class="p">:</span> <span class="s2">"rectangle"</span><span class="p">,</span>
    "tags"<span class="p">:</span> <span class="p">[</span>
      <span class="s2">"quadrilateral"</span><span class="p">,</span>
      <span class="s2">"2d"</span>
    <span class="p">]</span>
  <span class="p">}</span>
<span class="p">]</span></code></pre>
<h2 id="how-it-works">How it works</h2>
<ul>
<li><p><code>.[]</code> is the <a href="https://jqlang.org/manual/#array-object-value-iterator">array value iterator</a>, which iterates over the objects in the array.</p>
</li>
<li><p>the <a href="https://jqlang.org/manual/#select"><code>select(…)</code> function</a> filters the array for matching objects.</p>
</li>
<li><p><code>.tags</code> is an <a href="https://jqlang.org/manual/#object-identifier-index">object identifier-index</a>; it looks up the <code>"tags"</code> key in the object.</p>
</li>
<li><p><code>arrays</code> is a <a href="https://jqlang.org/manual/#arrays-objects-iterables-booleans-numbers-normals-finites-strings-nulls-values-scalars">built-in filter</a> that filters for objects where <code>.tags</code> is an array, so it discards objects where <code>tags</code> is missing or null.</p>
</li>
<li><p>the <a href="https://jqlang.org/manual/#any"><code>any(…)</code> filter</a> filters for tag arrays where one of the items is equal to <code>"quadrilateral"</code>.</p>
</li>
<li><p>the <code>[…]</code> around the whole expression wraps the result in a new array.
Skip them if you want one object per line.</p>
</li>
</ul>
<p>Notably, I’m not using the <a href="https://jqlang.org/manual/#contains"><code>contains(…)</code> filter</a>.
Although it sounds useful, it can only test items of the same type – it can test if a string contains a substring, or if an array is a superset of another array, but it can’t test if an array contains a string.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/jq-filter-by-array/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="jq" />

    <summary type="html">Use the `arrays` filter to remove empty values, the `any(…)` filter to check for set inclusion, then wrap the whole thing in square brackets.</summary>
</entry><entry>
  <title type="html">HOME_GET_ME_HOME is a Citymapper Shortcuts action</title>
  <link
    href="https://alexwlchan.net/notes/2026/home-get-me-home/"
    rel="alternate"
    type="text/html"
    title="HOME_GET_ME_HOME is a Citymapper Shortcuts action"
  />
  <published>2026-04-07T20:55:47+00:00</published>
  <updated>2026-04-07T20:55:47+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/home-get-me-home/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/home-get-me-home/">
    <![CDATA[<p><strong>It’s a Citymapper action that gives directions to my home, which can be triggered using the Shortcuts app.</strong></p><p>The other day, I pulled up Spotlight on my iPhone, and I saw a Siri Suggestion I didn’t recognise – it suggested <code>HOME_GET_ME_HOME</code> with Citymapper, which looks like a programmer’s variable name:</p>
<picture>
<source media="(prefers-color-scheme: dark)" sizes="(max-width:562px)100vw,562px" srcset="https://alexwlchan.net/images/2026/home-get-me-home.dark_1x.png 562w, https://alexwlchan.net/images/2026/home-get-me-home.dark_2x.png 1124w" type="image/png"/><source sizes="(max-width:562px)100vw,562px" srcset="https://alexwlchan.net/images/2026/home-get-me-home_1x.png 562w, https://alexwlchan.net/images/2026/home-get-me-home_2x.png 1124w" type="image/png"/><img alt="Siri Suggestions, which show four apps along the top, a Kindle action to 'Play current', and a Citymapper action to 'HOME_GET_ME_HOME'." class="screenshot dark_aware" src="https://alexwlchan.net/images/2026/home-get-me-home_1x.png" width="562"/>
</picture>
<p>Maybe this is a variable name which has leaked, and there’s meant to be some more user-friendly text here – but the meaning is also pretty obvious to a non-programmer.</p>
<p>I did a bit of digging, and I found <code>HOME_GET_ME_HOME</code> as one of two Citymapper actions in the Shortcuts app, and it’s an action to “Get directions to your saved home in Citymapper”.
You can use it in a shortcut – I imagine it might be useful for, say, a “leaving the office” shortcut which starts a playlist, texts your partner, and starts getting directions home.</p>
<p>More confusing is <code>HOME_GET_ME_TO_WORK</code>, an action to “Get directions to your saved work in Citymapper”.</p>
<p>(I’m running Citymapper 11.40.1; who knows if this will change in a future version.)</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/home-get-me-home/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Computers and code" />

    <summary type="html">It's a Citymapper action that gives directions to my home, which can be triggered using the Shortcuts app.</summary>
</entry><entry>
  <title type="html">The FileExistsError exception exposes a filename attribute</title>
  <link
    href="https://alexwlchan.net/notes/2026/fileexistserror-filename-attribute/"
    rel="alternate"
    type="text/html"
    title="The `FileExistsError` exception exposes a `filename` attribute"
  />
  <published>2026-04-07T21:50:30+01:00</published>
  <updated>2026-04-07T21:50:30+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/fileexistserror-filename-attribute/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/fileexistserror-filename-attribute/">
    <![CDATA[<p><strong>The <code>filename</code> attribute returns the name of the already-existent file as a string.</strong></p><p>Here’s a simple snippet that shows it returning a string attribute:</p>
<pre class="lng-python"><code><span class="k">try</span><span class="p">:</span>
    <span class="k">with</span> open<span class="p">(</span><span class="s2">"greeting.txt"</span><span class="p">,</span> <span class="s2">"x"</span><span class="p">)</span> <span class="k">as</span> <span class="n">out_file</span><span class="p">:</span>
        out_file<span class="o">.</span>write<span class="p">(</span><span class="s2">"hello world!"</span><span class="p">)</span>
<span class="k">except</span> FileExistsError <span class="k">as</span> <span class="n">err</span><span class="p">:</span>
    print<span class="p">(</span>repr<span class="p">(</span>err<span class="o">.</span>filename<span class="p">))</span>  <span class="c1"># 'greeting.txt'</span></code></pre>
<p>It’s not especially useful in this example because you already know the name of the file you’re writing – but I’ve found it useful with the <code>download_image()</code> function I wrote for <a href="https://github.com/alexwlchan/chives/blob/main/src/chives/fetch.py"><code>chives.fetch</code></a>.</p>
<p>The <code>download_image()</code> function takes a URL and an out prefix, looks at the <code>Content-Type</code> header of the response to decide the file extension, and returns the downloaded path.
I don’t know what path it will use until I’ve got the server response.</p>
<p>The function won’t overwrite existing images, so it throws a <code>FileExistsError</code> exception if the image already exists.
If I want to carry on anyway with the already-downloaded image, I can get the filename from the exception rather than re-calculating the filename or changing the API.</p>
<p>Here’s another example:</p>
<pre class="lng-python"><code><span class="kn">from</span> <span class="n">chives</span><span class="p">.</span><span class="n">fetch</span> <span class="kn">import</span> <span class="n">download_image</span>
<span class="kn">from</span> <span class="n">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="k">try</span><span class="p">:</span>
    <span class="n">out_path</span> <span class="o">=</span> download_image<span class="p">(</span>
        url<span class="o">=</span><span class="s2">"https://alexwlchan.net/images/2026/470906.png"</span><span class="p">,</span>
        out_prefix<span class="o">=</span>Path<span class="p">(</span><span class="s2">"example"</span><span class="p">)</span>
    <span class="p">)</span>
    print<span class="p">(</span>out_path<span class="p">)</span>  <span class="c1"># Path("example.png")</span>
<span class="k">except</span> FileExistsError <span class="k">as</span> <span class="n">err</span><span class="p">:</span>
    print<span class="p">(</span>repr<span class="p">(</span>err<span class="o">.</span>filename<span class="p">))</span>  <span class="c1"># 'example.png'</span></code></pre>
<p>The <code>filename</code> attribute comes from <a href="https://docs.python.org/3/library/exceptions.html#OSError.filename"><code>OSError</code></a>, of which <code>FileExistsError</code> is a subclass.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/fileexistserror-filename-attribute/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Python" />

    <summary type="html">The `filename` attribute returns the name of the already-existent file as a string.</summary>
</entry><entry>
  <title type="html">The red-lined bubble snail</title>
  <link
    href="https://alexwlchan.net/notes/2026/red-lined-bubble-snail/"
    rel="alternate"
    type="text/html"
    title="The red-lined bubble snail"
  />
  <published>2026-03-28T10:59:16+00:00</published>
  <updated>2026-03-28T10:59:16+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/red-lined-bubble-snail/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/red-lined-bubble-snail/">
    <![CDATA[<p><strong>It’s a species of sea snail found in the Indo-Pacific with a funky red, blue and white colouring.</strong></p><p>I saw some pictures of the <a href="https://en.wikipedia.org/wiki/Bullina_lineata">red-lined bubble snail</a> on Tumblr and I thought it might have been AI – but no, it’s a real thing!
Here’s a photo from the Wikipedia article:</p>
<figure>
<picture>
<source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/Bullina_Lineata_1x.avif 750w, https://alexwlchan.net/images/2026/Bullina_Lineata_2x.avif 1500w, https://alexwlchan.net/images/2026/Bullina_Lineata_3x.avif 2250w" type="image/avif"/><source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/Bullina_Lineata_1x.webp 750w, https://alexwlchan.net/images/2026/Bullina_Lineata_2x.webp 1500w, https://alexwlchan.net/images/2026/Bullina_Lineata_3x.webp 2250w" type="image/webp"/><source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/Bullina_Lineata_1x.jpg 750w, https://alexwlchan.net/images/2026/Bullina_Lineata_2x.jpg 1500w, https://alexwlchan.net/images/2026/Bullina_Lineata_3x.jpg 2250w" type="image/jpeg"/><img alt="A sea snail with a white shell and red spiral lines, and a body which is an iridescent and almost transparent blue." src="https://alexwlchan.net/images/2026/Bullina_Lineata_1x.jpg" width="750"/>
</picture>
<figcaption>
    Photo from <a href="https://en.wikipedia.org/wiki/File:Bullina_Lineata.jpg">Wikimedia Commons</a>, by user Sylke Rohrlach.
    Used under CC BY‑SA 3.0.
  </figcaption>
</figure>
<p>I’m fascinated by the iridescent blue, which looks like something out of sci-fi, and lets at least some light through.
It’s blurry, but you can definitely see the shape and colour of the rocks beneath it.</p>
<p>For references, the Wikipedia article includes a link to an entry in the <a href="https://www.marinespecies.org/aphia.php?p=taxdetails&amp;id=212841">World Register of Marine Species (WoRMS)</a>, which includes the history of the taxonomic description, a map showing where it’s found, and a couple of photos of the shells.</p>
<p>The WoRMS entry also links to a digitised copy of an 1825 article in <em>Annals of Philosophy</em>.
Here’s the relevant passage, a Latin description of the snail:</p>
<blockquote>
<p><em>Bulla lineata.</em> Testa ovato-oblonga, pellucida, densè spiraliter striata, alba; fasciis duabus spiralibus, et lineolis coccineis concentricis ornata; spira conic; apertura elongata, integer. β spira depressa, long, 2-3 unc.</p>
<p><cite><em>A list and description of some species of Shells not taken notice of by Lamarck</em>, Page 408, by John Edward Gray. Digitised by the <a href="https://www.biodiversitylibrary.org/page/15880862#page/422/mode/1up">Biodiversity Heritage Library</a>.</cite></p>
</blockquote>
<p>The Google Translate translation is a pretty good match for the snail in the original picture:</p>
<blockquote>
<p><em>Bulla lineata.</em> Shell ovate-oblong, pellucid, densely spirally striated, white; adorned with two spiral bands and concentric scarlet lines; spiral conical; aperture elongate, entire. β spiral depressed, long, 2-3 in.</p>
</blockquote>
<p>The word <em>“pellucid”</em> is new to me, the dictionary tells me it means <em>“transparent”</em> or <em>“clear”</em>.
The etymology makes sense too; it comes from <em>“per”</em> (through) and <em>“lucid”</em> (light).</p>
<p>Given this was being described in 1825, these snails can’t live that deep in the ocean.
I’ve heard of creatures with transparent skin which live in total darkness, where skin pigmentation is unnecessary because there’s no light to see by – this is the first I’ve heard of transparent skin on a creature which might experience sunlight.</p>
<p>Once again, nature is weirder than anything humans can imagine.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/red-lined-bubble-snail/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="The world around us" />

    <summary type="html">It's a species of sea snail found in the Indo-Pacific with a funky red, blue and white colouring.</summary>
</entry><entry>
  <title type="html">Why can’t Python connect to example.com?</title>
  <link
    href="https://alexwlchan.net/notes/2026/example-certificates/"
    rel="alternate"
    type="text/html"
    title="Why can't Python connect to example.com?"
  />
  <published>2026-03-28T10:23:58+00:00</published>
  <updated>2026-03-29T08:49:56+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/example-certificates/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/example-certificates/">
    <![CDATA[<p><strong>The Python SSL libraries only know about the certificates sent by the server and in my local store. They can’t retrieve missing certificates.</strong></p><p>I’ve been experimenting with Python HTTP libraries, and I ran into an unexpected error connecting to <code>example.com</code>:</p>
<pre class="lng-pycon wrap"><code><span class="gp">&gt;&gt;&gt; </span><span class="kn">import</span><span class="w"> </span><span class="n">certifi</span><span class="o">,</span><span class="w"> </span><span class="n">ssl</span><span class="o">,</span><span class="w"> </span><span class="n">urllib</span><span class="p">.</span><span class="n">request</span>
<span class="gp">&gt;&gt;&gt; </span><span class="n">ssl_context</span> <span class="o">=</span> ssl<span class="o">.</span>create_default_context<span class="p">(</span>cafile<span class="o">=</span>certifi<span class="o">.</span>where<span class="p">())</span>
<span class="gp">&gt;&gt;&gt; </span>urllib<span class="o">.</span>request<span class="o">.</span>urlopen<span class="p">(</span><span class="s2">"https://example.com"</span><span class="p">,</span> context<span class="o">=</span>ssl_context<span class="p">)</span>
<span class="gt">Traceback (most recent call last):</span>
<span class="gr">  […]</span>
<span class="gr">ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081)</span>

<span class="gt">During handling of the above exception, another exception occurred:</span>

<span class="gt">Traceback (most recent call last):</span>
<span class="gr">  […]</span>
<span class="gr">urllib.error.URLError: &lt;urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1081)&gt;</span></code></pre><p>I get similar errors if I use <code>httpx</code> or <code>requests</code>.</p>
<p>If you look up this error, the usual advice is to make sure you’re using certifi, you have the latest version installed, run <code>Install Certificates.command</code>, and so on.
Everything looks fine on my system, and I can connect to other websites just fine:</p>
<pre class="lng-pycon"><code><span class="gp">&gt;&gt;&gt; </span>certifi<span class="o">.</span>__version__
<span class="go">'2026.02.25'</span>
<span class="gp">&gt;&gt;&gt; </span>certifi<span class="o">.</span>where<span class="p">()</span>
<span class="go">'/tmp/tmp.ZzvjQtkeZT/.venv/lib/python3.14/site-packages/certifi/cacert.pem'</span>
<span class="gp">&gt;&gt;&gt; </span>urllib<span class="o">.</span>request<span class="o">.</span>urlopen<span class="p">(</span><span class="s2">"https://alexwlchan.net"</span><span class="p">,</span> context<span class="o">=</span>ssl_context<span class="p">)</span>
<span class="go">&lt;http.client.HTTPResponse object at 0x1056722f0&gt;</span></code></pre><p>I can also open <code>example.com</code> in my web browser, but not in Python – what’s up?</p>
<h2 id="a-mistrusted-certificate-and-authority-information-access">A mistrusted certificate and Authority Information Access</h2>
<p>I found <a href="https://github.com/certifi/python-certifi/issues/393">a certifi issue</a> filed by Clément Beaujoin which describes this exact issue:</p>
<blockquote>
<p>As of February 14, 2026, many automated tests and features relying on example.com began failing with ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1016).</p>
<p>This is caused by example.com (Cloudflare) transitioning to a new certificate chain that roots into AAA Certificate Services, which was officially distrusted by major certificate stores (including certifi) in early February 2026. Because Python’s requests/urllib3 does not support AIA (Authority Information Access) to fetch missing intermediates, the verification fails in environments with updated root stores, even though browsers (which support AIA) show the site as secure.</p>
</blockquote>
<p>Alex Gaynor, one of the maintainers, explained this isn’t something certifi is going to change:</p>
<blockquote>
<p>If example.com is not shipping the necessary intermediates, that’s a bug in their TLS serving configuration impacting all non-browser clients, not something we’re going to work around.</p>
</blockquote>
<p>This explanation sounds right to me, but I wanted to understand more.
Is there a way to print the certificate chain sent by the server, so I can see the missing intermediate and the AIA that tells my client where to fetch the missing intermediates?
How could I have worked this out myself?</p>
<p>I tried running various <code>openssl</code> commands and Python scripts that were supposedly printing the certificate chain, but I don’t understand TLS well enough to really know what’s going on.</p>
<p>I was able to see that certifi no longer trusts AAA Certificate Services, and when.
I tried older versions of certifi, and <code>example.com</code> loads with 2025.1.31 but not with 2025.4.26.
Then I looked at the <a href="https://github.com/certifi/python-certifi/pull/347/changes">diff for 2025.4.26</a>, and I can see a certificate with the same name being removed:</p>
<figure class="annotated_code"><pre class="lng-diff"><code><div class="ln"><span class="lineno">128</span><span class="gd">-# Issuer: CN=AAA Certificate Services O=Comodo CA Limited</span></div>
<div class="ln"><span class="lineno">129</span><span class="gd">-# Subject: CN=AAA Certificate Services O=Comodo CA Limited</span></div>
<div class="ln"><span class="lineno">130</span><span class="gd">-# Label: "Comodo AAA Services root"</span></div>
<div class="ln"><span class="lineno">131</span><span class="gd">-# Serial: 1</span></div></code></pre>
</figure><p>I also found a <a href="https://github.com/python/cpython/issues/62817">CPython issue</a> where Authority Information Access is mentioned where the topic is discussed, and Alex explained that it’s unlikely to be added to Python:</p>
<blockquote>
<p>No, and at this point [the issue] should probably be wontfix’d (IMO), as AIA chasing is relatively out of favor compared to intermediate preloading.</p>
</blockquote>
<p>I still don’t really understand HTTPS or TLS certificates and I’m not sure how to fix this if I encounter another misconfigured website – but I only use <code>example.com</code> for testing, so for now I can just pick another website to test instead.</p>
<h2 id="why-wasn-t-this-caught-by-my-tests">Why wasn’t this caught by my tests?</h2>
<p>I use <a href="https://alexwlchan.net/2025/testing-with-vcrpy/">vcrpy to test my HTTP code</a>, but it doesn’t do anything with TLS certificates, just unencrypted HTTP responses.
I didn’t catch this until I tried regenerating my recorded cassettes, and discovered that the HTTPS certificate issues meant I could no longer do so.</p>
<p>Perhaps I need a procedure for regenerating vcrpy cassettes when I upgrade my dependencies, or on a fixed schedule?</p>

<aside class="update" id="update-2026-04-16" role="note">
<p><strong>Update, <time datetime="2026-04-16">16 April 2026</time>:</strong> I tried again today, and Python can once again connect to <code>example.com</code>.</p>
<p>If I need to test how my software behaves with bad TLS certificate chains, I can use <code>badssl.com</code> for something more reliably broken.</p>
</aside>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/example-certificates/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Python" />

    <summary type="html">The Python SSL libraries only know about the certificates sent by the server and in my local store. They can't retrieve missing certificates.</summary>
</entry><entry>
  <title type="html">Useful type hints for Python</title>
  <link
    href="https://alexwlchan.net/notes/2026/useful-type-hints/"
    rel="alternate"
    type="text/html"
    title="Useful type hints for Python"
  />
  <published>2026-03-27T14:59:26+00:00</published>
  <updated>2026-03-27T14:59:26+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/useful-type-hints/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/useful-type-hints/">
    <![CDATA[<p><strong>A collection of non-obvious type hints that I couldn’t easily find in documentation or Google searches.</strong></p><p>I type check most of my Python code with <code>mypy --strict</code>.
This note describes some of the non-obvious type hints that I couldn’t easily find in documentation or Google searches.</p>
<h2 id="pytest-parameterised-tests">pytest parameterised tests</h2>
<pre class="lng-python"><code><span class="kn">import</span> <span class="n">pytest</span>
<span class="kn">from</span> <span class="n">_pytest</span><span class="p">.</span><span class="n">mark</span><span class="p">.</span><span class="n">structures</span> <span class="kn">import</span> <span class="n">ParameterSet</span>

<span class="n">params</span><span class="p">:</span> list<span class="p">[</span>ParameterSet<span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
    pytest<span class="o">.</span>param<span class="p">(</span><span class="s2">"1"</span><span class="p">,</span> id<span class="o">=</span><span class="s2">"one"</span><span class="p">),</span>
    pytest<span class="o">.</span>param<span class="p">(</span><span class="s2">"2"</span><span class="p">,</span> id<span class="o">=</span><span class="s2">"two"</span><span class="p">),</span>
    pytest<span class="o">.</span>param<span class="p">(</span><span class="s2">"3"</span><span class="p">,</span> id<span class="o">=</span><span class="s2">"three"</span><span class="p">),</span>
<span class="p">]</span></code></pre>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/useful-type-hints/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Python" />

    <summary type="html">A collection of non-obvious type hints that I couldn't easily find in documentation or Google searches.</summary>
</entry><entry>
  <title type="html">How to truncate the middle of long command output</title>
  <link
    href="https://alexwlchan.net/notes/2026/truncate-middle-output/"
    rel="alternate"
    type="text/html"
    title="How to truncate the middle of long command output"
  />
  <published>2026-03-23T21:02:20+00:00</published>
  <updated>2026-03-23T21:02:20+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/truncate-middle-output/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/truncate-middle-output/">
    <![CDATA[<p><strong>Use a command group <code>{ head -n 3; echo '[…]'; tail -n 5; }</code> to snip print the first few and last few lines.</strong></p><p>If I’m running a command with lots of output, I can use <a href="https://alexwlchan.net/man/man1/head.html"><code>head(1)</code></a> to get the first few lines, or <a href="https://alexwlchan.net/man/man1/tail.html"><code>tail(1)</code></a> to get the last few lines.
What if I want to get some lines from the beginning and from the end, but truncate the middle?</p>
<p>In bash, I can use <a href="https://www.gnu.org/software/bash/manual/html_node/Command-Grouping.html">command grouping</a>, which runs all the commands inside curly braces as a single unit.
Here’s an example:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>tailscale<span class="w"> </span>exit-node<span class="w"> </span>list<span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="o">{</span><span class="w"> </span>head<span class="w"> </span>-n<span class="w"> </span><span class="m">3</span><span class="p">;</span><span class="w"> </span>echo<span class="w"> </span><span class="s1">' […]'</span><span class="p">;</span><span class="w"> </span>tail<span class="w"> </span>-n<span class="w"> </span><span class="m">5</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>

<span class="go"> IP                  HOSTNAME                         COUNTRY            CITY                   STATUS</span>
<span class="go"> 100.111.189.27      al-tia-wg-003.mullvad.ts.net     Albania            Tirana                 -</span>
<span class="go"> […]</span>
<span class="go"> 100.93.242.75       ua-iev-wg-001.mullvad.ts.net     Ukraine            Kyiv                   -</span>

<span class="go"># To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.</span>
<span class="go"># To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.</span>
<span class="go"># To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.</span></code></pre>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/truncate-middle-output/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Shell scripting" />

    <summary type="html">Use a command group `{ head -n 3; echo '[…]'; tail -n 5; }` to snip print the first few and last few lines.</summary>
</entry><entry>
  <title type="html">AirPlay Receiver can interfere with Flask apps</title>
  <link
    href="https://alexwlchan.net/notes/2026/flask-and-port-5000/"
    rel="alternate"
    type="text/html"
    title="AirPlay Receiver can interfere with Flask apps"
  />
  <published>2026-03-17T21:55:45+00:00</published>
  <updated>2026-03-18T19:51:19+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/flask-and-port-5000/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/flask-and-port-5000/">
    <![CDATA[<p><strong>It listens on port 5000, which is the default port used for running Flask apps in debug mode, then Safari sends blank pages for my Flask app.</strong></p><p>One persistent issue I have when developing apps with Flask is that I’ll go through several rounds of iteration, run the app with the debug server, and at some point Safari will stop loading the app.
If I try to load the app in Safari, I just get blank pages.
Other browsers are fine, but Safari is broken until I restart it.</p>
<p>I hadn’t spotted the pattern, but today <a href="https://github.com/napcs">Brian</a> explained what’s going on: the default port for the Flask dev server is port 5000, and my Mac is listening on port 5000 <a href="https://support.apple.com/en-us/103229#:~:text=5000">for AirPlay</a>.
If I run the dev server with the default port, at some point Safari connects to the AirPlay Server instead of my app, and then it starts receiving blank pages.</p>
<p>There are three workarounds:</p>
<ol>
<li><p>Use a different port to run my Flask apps.</p>
</li>
<li><p>Set the <code>FLASK_RUN_PORT</code> in my shell config to override the default Flask port (h/t <a href="https://github.com/raggi">James</a>).</p>
</li>
<li><p>Disable AirPlay Receiver in Settings: <strong>General</strong> &gt; <strong>AirDrop &amp; Handoff</strong> &gt; <strong>AirPlay Receiver</strong>.</p>
</li>
</ol>
<p>I’ve done (3), because I never need to AirPlay to my Macs.</p>
<p>This isn’t the first time I’ve had to <a href="https://alexwlchan.net/notes/2025/disable-handoff-icons-in-dock/">disable a setting</a> in the “AirDrop &amp; Handoff” screen.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/flask-and-port-5000/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Python" />
    <category term="macOS" />

    <summary type="html">It listens on port 5000, which is the default port used for running Flask apps in debug mode, then Safari sends blank pages for my Flask app.</summary>
</entry><entry>
  <title type="html">What’s the main prefix in SQLite queries?</title>
  <link
    href="https://alexwlchan.net/notes/2026/sqlite-temp-database/"
    rel="alternate"
    type="text/html"
    title="What's the `main` prefix in SQLite queries?"
  />
  <published>2026-03-17T21:36:32+00:00</published>
  <updated>2026-03-17T21:36:32+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/sqlite-temp-database/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/sqlite-temp-database/">
    <![CDATA[<p><strong>SQLite uses schema prefixes like <code>main</code> and <code>temp</code> to disambiguate between attached databases and connection-specific temporary tables.</strong></p><p>I was reading the SQLite database queries in the Tailscale source code today, and tables are referred to inconsistently: the schema creates tables like <code>CREATE TABLE main.TKAChonk</code>, but then queries may use <code>TKAChonk</code> or <code>main.TKAChonk</code>.
What’s the difference?</p>
<p>I asked about it in Slack, and <a href="https://github.com/creachadair">Michael</a> and <a href="https://github.com/bradfitz">Brad</a> explained what’s going on: it’s possible to attach multiple databases in SQLite, and the <code>main</code> prefix tells SQLite to look in the main database.
We only attach one database so the two references are equivalent, but in the past there used to be separate databases and it was useful to disambiguate.</p>
<p>Here’s the relevant part of the <a href="https://sqlite.org/lang_naming.html">SQLite docs</a>:</p>
<blockquote>
<p>In SQLite, a database object (a table, index, trigger or view) is identified by the name of the object and the name of the database that it resides in. […]</p>
<p>If no database is specified as part of the object reference, then SQLite searches the main, temp and all attached databases for an object with a matching name. The temp database is searched first, followed by the main database, followed by all attached databases in the order that they were attached. […]</p>
<p>If a schema name is specified as part of an object reference, it must be either “main”, or “temp” or the schema-name of an attached database.</p>
</blockquote>
<p>The temp database referred to here is <a href="https://sqlite.org/tempfiles.html#temp_databases">a set of tables</a> created using <code>CREATE TEMP TABLE</code> which are only visible to the database connection that created them.</p>
<h2 id="example">Example</h2>
<ol>
<li><p>Create two databases which both have an <code>IntID</code> table, and store a different value in each:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>sqlite3 db1.sqlite <span class="s">'CREATE TABLE IntID (id INTEGER);
                      INSERT INTO IntID (id) VALUES (100);'</span>
<span></span>
<span class="gp">$</span><span class="w"> </span>sqlite3 db2.sqlite <span class="s">'CREATE TABLE IntID (id INTEGER);
                      INSERT INTO IntID (id) VALUES (200);'</span></code></pre>
</li>
<li><p>Open one of the databases, attach the other, and then look up the identically-named tables:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span>ATTACH<span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="s1">'db2.sqlite'</span><span class="w"> </span><span class="k">as</span><span class="w"> </span>db2<span class="p">;</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>IntID<span class="p">;</span>
<span class="go">100</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>main<span class="p">.</span>IntID<span class="p">;</span>
<span class="go">100</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>db2<span class="p">.</span>IntID<span class="p">;</span>
<span class="go">200</span></code></pre>
<p>Observe that when I query a bare <code>IntID</code>, SQLite chooses the table from the main database.</p>
</li>
<li><p>Create a temporary table, insert a value, and query <code>IntID</code> again:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">CREATE</span><span class="w"> </span>TEMP<span class="w"> </span><span class="k">TABLE</span><span class="w"> </span>IntID<span class="w"> </span><span class="p">(</span>id<span class="w"> </span>INTEGER<span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>temp<span class="p">.</span>IntID<span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">300</span><span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>IntID<span class="p">;</span>
<span class="go">300</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>main<span class="p">.</span>IntID<span class="p">;</span>
<span class="go">100</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>temp<span class="p">.</span>IntID<span class="p">;</span>
<span class="go">300</span></code></pre>
</li>
<li><p>Try to read the temporary table from a different database connection, and observe that it fails:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>sqlite3<span class="w"> </span>db1.sqlite<span class="w"> </span><span class="s1">'SELECT id FROM temp.IntID;'</span>
<span class="go">Error: in prepare, no such table: temp.IntID</span></code></pre>
</li>
<li><p>Create a table in the attached database, and observe it can be queried by the bare name, but doesn’t exist in the main database:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span>db2<span class="p">.</span>NewID<span class="w"> </span><span class="p">(</span>id<span class="w"> </span>INTEGER<span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>db2<span class="p">.</span>NewID<span class="w"> </span><span class="p">(</span>id<span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>NewID<span class="p">;</span>
<span class="go">500</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>main<span class="p">.</span>NewID<span class="p">;</span>
<span class="go">Parse error: no such table: main.NewID</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span>id<span class="w"> </span><span class="k">FROM</span><span class="w"> </span>db2<span class="p">.</span>NewID<span class="p">;</span>
<span class="go">500</span></code></pre>
</li>
</ol>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/sqlite-temp-database/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="SQLite" />

    <summary type="html">SQLite uses schema prefixes like `main` and `temp` to disambiguate between attached databases and connection-specific temporary tables.</summary>
</entry><entry>
  <title type="html">The file(1) command can read SQLite databases</title>
  <link
    href="https://alexwlchan.net/notes/2026/file-knows-sqlite/"
    rel="alternate"
    type="text/html"
    title="The file(1) command can read SQLite databases"
  />
  <published>2026-03-13T23:29:39+00:00</published>
  <updated>2026-03-13T23:29:39+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/file-knows-sqlite/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/file-knows-sqlite/">
    <![CDATA[<p><strong>It can identify a SQLite database and give you basic information about the version, page count, encoding, and more.</strong></p><p>Here’s a neat trick that <a href="https://github.com/oxtoacart">Percy</a> showed me today: the <a href="https://alexwlchan.net/man/man1/file.html">file(1) command</a> is able to recognise SQLite databases, and print some information about the structure of the database.</p>
<p>Here’s an example from my Mac:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database, last written using SQLite version 3050004, file counter 89, database pages 4, cookie 0x2, schema 4, UTF-8, version-valid-for 89</span></code></pre>
<p>These values are read from the <a href="https://sqlite.org/fileformat.html#the_database_header">database header</a>, the first 100 bytes of the file which include information about the database.
A couple of fields stood out to me:</p>
<ul>
<li><p><strong>The SQLite version.</strong>
This is an integer, specifically <a href="https://sqlite.org/c3ref/c_scm_branch.html"><code>SQLITE_VERSION_NUMBER</code></a>, which is a representation of the version number.
In this example, <code>3050004</code> means the database was last written by SQLite 3.50.4.</p>
</li>
<li><p><strong>The <a href="https://sqlite.org/fileformat.html#file_change_counter">file (change) counter</a> counts changes to the database.</strong>
If multiple processes are working in the same database, they can detect when another process made a change, and invalidate their page cache.</p>
<p>I was surprised that this value was so low for our production databases at work (just 1196), but the SQLite docs explain that if you run your database in WAL mode – which we do – changes are tracked through the wal-index, and so the change counter might not be incremented.
That must be happening in our databases.</p>
</li>
<li><p><strong>The <a href="https://sqlite.org/fileformat.html#schema_cookie">(schema) cookie</a> gets incremented whenever the database schema changes.</strong>
I ran a quick example and I can see it increasing from <code>0x2</code> to <code>0x3</code> in my example database:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database, last written using SQLite version 3050004, file counter 90, database pages 4, cookie 0x2, schema 4, UTF-8, version-valid-for 90</span>

<span class="gp">$</span><span class="w"> </span>sqlite3<span class="w"> </span>example.db<span class="w"> </span><span class="s1">'CREATE TABLE NewTable(x);'</span>

<span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database, last written using SQLite version 3050004, file counter 91, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 91</span></code></pre>
<p>The field that file(1) labels as “schema” is the <a href="https://sqlite.org/fileformat.html#schema_format_number">schema format number</a>, and tracks the version of SQL formatting used in the database.
As of 13 March 2026, schema format 4 is the highest possible value, and it has been since January 2006.</p>
</li>
<li><p><strong>The <a href="https://sqlite.org/pragma.html#pragma_user_version">user version field</a> is an integer for “applications to use however they want” which SQLite doesn’t read.</strong>
I imagine this might be quite useful for storing, say, a schema version in an easily accessible location.</p>
<p>You can set it using the <code>user_version</code> pragma, and then it appears in the file(1) output:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>sqlite3<span class="w"> </span>example.db<span class="w"> </span><span class="s1">'PRAGMA user_version = 42;'</span>

<span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database, user version 42, last written using SQLite version 3050001, file counter 92, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 92</span></code></pre>
</li>
<li><p><strong>The <a href="https://sqlite.org/fileformat.html#application_id">application ID</a> is a field to identify the application that “owns” the database.</strong>
Lots of applications use SQLite as their storage format, and they can record their ID in this field.</p>
<p>There are well-known values in <a href="https://sqlite.org/src/artifact?ci=trunk&amp;filename=magic.txt"><code>magic.txt</code></a> in the SQLite repository.
If you use one of these, file(1) adds the human-readable name to its output:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>sqlite3<span class="w"> </span>example.db<span class="w"> </span><span class="s1">'PRAGMA application_id = 0x0f055112;'</span>

<span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database (Fossil checkout), last written using SQLite version 3050001, file counter 94, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 94</span></code></pre>
<p>If you use a different value, file(1) prints the literal value:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>sqlite3<span class="w"> </span>example.db<span class="w"> </span><span class="s1">'PRAGMA application_id = 0x42424242;'</span>

<span class="gp">$</span><span class="w"> </span>file<span class="w"> </span>example.db
<span class="go">example.db: SQLite 3.x database, application id 1111638594, last written using SQLite version 3050001, file counter 95, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 95</span></code></pre>
<p>This lookup comes from a <code>sql</code> “magic” file <a href="https://github.com/file/file/blob/fac0603d48af08d53547b795385abef4337d6d5f/magic/Magdir/sql#L207-L214">which ships with file(1)</a>, embedding all the values from SQLite’s <code>magic.txt</code> and many more besides.</p>
</li>
</ul>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/file-knows-sqlite/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="SQLite" />

    <summary type="html">It can identify a SQLite database and give you basic information about the version, page count, encoding, and more.</summary>
</entry><entry>
  <title type="html">My randline project is tested by Crater</title>
  <link
    href="https://alexwlchan.net/notes/2026/randline-in-a-crater/"
    rel="alternate"
    type="text/html"
    title="My randline project is tested by Crater"
  />
  <published>2026-03-13T21:53:42+00:00</published>
  <updated>2026-03-13T21:53:42+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/randline-in-a-crater/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/randline-in-a-crater/">
    <![CDATA[<p><strong>I got a GitHub issue warning me that my project will break with a future version of Cargo.</strong></p><p>One of the tools used by the Rust development team is <a href="https://doc.crates.io/contrib/tests/crater.html">Crater</a>, a tool which can compile and test Rust crates en masse.
It’s used to detect breaking changes in the compiler, by testing unreleased versions of the compiler with large amounts of Rust code written by lots of different people.
That includes every crate published on crates.io, and at least some Rust projects on GitHub.</p>
<p>The existence of Crater speaks to the level of standardisation in Rust build tooling – most projects will use <code>cargo build</code> and <code>cargo test</code>.
It would be impossible to do this for a language like Python, where there are lots of popular approaches and every project runs tests in a different way.</p>
<p>Earlier this week, Ed Page (who’s on the Cargo team) <a href="https://github.com/alexwlchan/randline/issues/15">opened an issue</a> in my <code>randline</code> project:</p>
<blockquote>
<p>Test binary is looked up in unit tests which relies on internals of Cargo and will break with existing / upcoming features […]</p>
<p>This problem was identified by the following crater run: <a href="https://github.com/rust-lang/rust/pull/149852">rust-lang/rust#149852</a></p>
</blockquote>
<p>This issue almost certainly comes from my version of the <code>assert_cmd</code> crate – I’m several versions behind the latest.</p>
<p>I’m not rushing to fix this because I’m not working on randline right now, but I’m quite excited to realise some of my Rust is prominent enough to be featured in Crater runs.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/randline-in-a-crater/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Rust" />

    <summary type="html">I got a GitHub issue warning me that my project will break with a future version of Cargo.</summary>
</entry><entry>
  <title type="html">Drawing an image with Liquid Glass using SwiftUI Previews</title>
  <link
    href="https://alexwlchan.net/notes/2026/larking-with-liquid-glass/"
    rel="alternate"
    type="text/html"
    title="Drawing an image with Liquid Glass using SwiftUI Previews"
  />
  <published>2026-03-06T22:21:41+00:00</published>
  <updated>2026-03-06T22:21:41+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/larking-with-liquid-glass/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/larking-with-liquid-glass/">
    <![CDATA[<p><strong>I used Xcode to create an image with a Liquid Glass effect, then I used the Preview to export it as a standalone file.</strong></p><p>Earlier this week Apple announced a <a href="https://sixcolors.com/post/2026/03/apple-announces-a-pair-of-new-studio-displays/">“new” Studio Display</a> which is almost the same as the previous Studio Display, only gaining a better camera and Thunderbolt 5.</p>
<p>Jack Wellborn posted <a href="https://mastodon.social/@jackwellborn/116166020002234878">an image on Mastodon</a> joking about the lack of new features – one of Apple’s marketing images overlaid with a blue “new” banner in the corner, which is how Apple’s online store used to highlight new products.
The Accidental Tech Podcast used a similar image as their artwork and favicon for years, joking about the lack of updates to the Mac Pro.</p>
<p>I wanted to create a similar image with a “new” banner in the style of Liquid Glass, to make fun of the design problems in macOS Tahoe.
Initially I tried creating it in <a href="https://flyingmeat.com/acorn/">Acorn</a>, but I lack the graphical design skills to replicate the effect – I realised it would be easier to create the effect in code.</p>
<p>I opened Xcode on a Mac running macOS Tahoe and created a new Mac app.
I added one of Apple’s marketing images to the Asset catalogue, then I modified the default SwiftUI view <code>ContentView</code>:</p>
<pre class="lng-swift"><code><span class="kd">import</span> <span class="n">SwiftUI</span>

<span class="kd">struct</span> <span class="n">ContentView</span><span class="p">:</span> View <span class="p">{</span>
    <span class="kd">var</span> <span class="n">body</span><span class="p">:</span> some View <span class="p">{</span>
        VStack <span class="p">{</span>
            Image<span class="p">(</span><span class="s">"center_stage_hw_studio_display__f0h1yn012iie_large_2x"</span><span class="p">)</span>
                <span class="p">.</span>imageScale<span class="p">(.</span>small<span class="p">)</span>
                <span class="p">.</span>foregroundStyle<span class="p">(.</span>tint<span class="p">)</span>
                <span class="p">.</span>overlay<span class="p">(</span>alignment<span class="p">:</span> <span class="p">.</span>topTrailing<span class="p">)</span> <span class="p">{</span>
                    Text<span class="p">(</span><span class="s">"NEW"</span><span class="p">)</span>
                        <span class="p">.</span>font<span class="p">(.</span>title<span class="p">)</span>
                        <span class="p">.</span>foregroundColor<span class="p">(.</span>white<span class="p">)</span>
                        <span class="p">.</span>padding<span class="p">()</span>
                        <span class="p">.</span>frame<span class="p">(</span>width<span class="p">:</span><span class="mi">400</span><span class="p">,</span> height<span class="p">:</span><span class="mi">5</span><span class="p">)</span>
                        <span class="p">.</span>glassEffect<span class="p">(.</span>clear<span class="p">,</span> <span class="k">in</span><span class="p">:</span> Rectangle<span class="p">())</span>
                        <span class="p">.</span>offset<span class="p">(</span>x<span class="p">:</span><span class="mi">150</span><span class="p">,</span>y<span class="p">:</span><span class="o">-</span><span class="mi">150</span><span class="p">)</span>
                        <span class="p">.</span>rotationEffect<span class="p">(.</span>degrees<span class="p">(</span><span class="mi">45</span><span class="p">))</span>
                        <span class="p">.</span>overlay<span class="p">(</span>alignment<span class="p">:</span> <span class="p">.</span>bottom<span class="p">)</span> <span class="p">{</span>
                            Text<span class="p">(</span><span class="s">"NEW"</span><span class="p">)</span>
                                <span class="p">.</span>font<span class="p">(.</span>system<span class="p">(</span>size<span class="p">:</span> <span class="mi">120</span><span class="p">))</span>
                                <span class="p">.</span>foregroundColor<span class="p">(.</span>white<span class="p">)</span>
                                <span class="p">.</span>rotationEffect<span class="p">(.</span>degrees<span class="p">(</span><span class="mi">45</span><span class="p">))</span>
                                <span class="p">.</span>offset<span class="p">(</span>x<span class="p">:</span><span class="o">-</span><span class="mi">25</span><span class="p">,</span> y<span class="p">:</span><span class="mi">300</span><span class="p">)</span>
                                <span class="p">.</span>blur<span class="p">(</span>radius<span class="p">:</span> <span class="mf">1.5</span><span class="p">)</span>
                                <span class="p">.</span>opacity<span class="p">(</span><span class="mf">0.7</span><span class="p">)</span>
                        <span class="p">}</span>
                <span class="p">}</span>
                <span class="p">.</span>clipped<span class="p">()</span>
        <span class="p">}</span>
        <span class="p">.</span>padding<span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="p">#</span>Preview <span class="p">{</span>
    ContentView<span class="p">()</span>
<span class="p">}</span></code></pre>
<p>I ran Xcode with two panes: my source code on the left, my preview on the right.
That worked well for iterating the design, because I could tweak the source code and immediately see the effect.</p>
<p>Even with my limited SwiftUI experience, I know this isn’t very good – for example, I have two <code>Text</code> views, because I couldn’t make the first one work.
(Specifically, I couldn’t make it work with <code>glassEffect</code> and <code>rotationEffect</code> – the text was rotated within a horizontal glass container.)</p>
<p>I also couldn’t get the banner to be a thin rectangle; I ended up drawing a large rectangle that clips out of the view (<code>clipped()</code>) and overlaps the entire corner.
Let’s retcon that as an homage to the way Tahoe’s UI supposedly “gets out of your way”, but actually takes over even more of your screen.</p>
<p>Rubbish as this code is, it does the trick!
And I discovered that Xcode has a menu item to export the SwiftUI Preview as an image: <strong>Editor</strong> &gt; <strong>Canvas</strong> &gt; <strong>Export Preview Screenshot</strong>.
I was using Xcode 26; I imagine that menu item might move around in different versions.</p>
<p>Here’s what the output looks like:</p>
<picture>
<source media="(prefers-color-scheme: dark)" sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/liquid-glass.dark_1x.jpg 750w, https://alexwlchan.net/images/2026/liquid-glass.dark_2x.jpg 1500w, https://alexwlchan.net/images/2026/liquid-glass.dark_3x.jpg 2250w" type="image/jpeg"/><source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/liquid-glass_1x.jpg 750w, https://alexwlchan.net/images/2026/liquid-glass_2x.jpg 1500w, https://alexwlchan.net/images/2026/liquid-glass_3x.jpg 2250w" type="image/jpeg"/><img alt="A photo of a large display with a glassy triangle in the top right-hand corner which has the word 'New' shown in blurry, barely-visible text." class="screenshot dark_aware" src="https://alexwlchan.net/images/2026/liquid-glass_1x.jpg" width="750"/>
</picture>
<p>I don’t expect to create more images like this, but it’s cool to know I <em>could</em> use Xcode to mock up UI or Liquid Glass quickly, and export my work as images.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/larking-with-liquid-glass/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Drawing things" />
    <category term="Swift" />
    <category term="Fun stuff" />

    <summary type="html">I used Xcode to create an image with a Liquid Glass effect, then I used the Preview to export it as a standalone file.</summary>
</entry><entry>
  <title type="html">Road signs in the Soviet union don’t have circular heads</title>
  <link
    href="https://alexwlchan.net/notes/2026/soviet-road-signs/"
    rel="alternate"
    type="text/html"
    title="Road signs in the Soviet union don't have circular heads"
  />
  <published>2026-02-27T19:54:09+00:00</published>
  <updated>2026-02-27T19:54:09+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/soviet-road-signs/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/soviet-road-signs/">
    <![CDATA[<p><strong>Their heads are more a sort of rounded triangle shape, a bit like an oval or an egg.</strong></p><p>Earlier this week, I saw a photo of a road sign that caught my eye.
The <a href="https://www.alamy.com/road-sign-are-going-construction-work-close-up-figurine-of-a-man-with-a-shovel-on-a-yellow-background-high-quality-photo-image433881547.html">original image</a> is a stock photo that I can’t reproduce without a license, but here’s a <a href="https://www.pexels.com/photo/traffic-signs-27134572/">freely available alternative</a> that shows the same sign:</p>
<figure>
<picture>
<source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/pexels-sejio402-27134572_1x.avif 750w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_2x.avif 1500w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_3x.avif 2250w" type="image/avif"/><source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/pexels-sejio402-27134572_1x.webp 750w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_2x.webp 1500w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_3x.webp 2250w" type="image/webp"/><source sizes="(max-width:750px)100vw,750px" srcset="https://alexwlchan.net/images/2026/pexels-sejio402-27134572_1x.jpg 750w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_2x.jpg 1500w, https://alexwlchan.net/images/2026/pexels-sejio402-27134572_3x.jpg 2250w" type="image/jpeg"/><img alt="A construction site with a warning sign in the foreground. The sign is a red triangle with a yellow background, and a black stick figure shovelling something. The stick figure’s head is separated from their body, and a sort of oval shape." src="https://alexwlchan.net/images/2026/pexels-sejio402-27134572_1x.jpg" width="750"/>
</picture>
<figcaption>
<a href="https://www.pexels.com/photo/traffic-signs-27134572/">Traffic signs</a> by Sergei Starostin on Pexels.
    Used under the <a href="https://www.pexels.com/license/">Pexels License</a>.
  </figcaption>
</figure>
<p>I understood the sign immediately.
The details differ from the signs I’m used to, but it’s recognisably a “men at work” sign meant to warn you about ongoing roadworks.</p>
<p>The man’s head is an oval or egg shape, which is a style I’d not seen before.
I’m used to signs where the head is a perfect circle, or signs where the head is very distinct and stylised, like the <a href="https://en.wikipedia.org/wiki/Ampelm%C3%A4nnchen">Ampelmännchen</a> seen at pedestrian crossings in Germany.</p>
<p>Writing this note, I realise there are road signs I’m familiar with that don’t have rounded heads, but I’d never noticed.
For example, the UK’s “children crossing” sign is meant to depict a girl with a non-circular haircut, but I’d never noticed it before.
(Although Margaret Calvert, the original designer, <a href="https://www.transportxtra.com/publications/parking-review/news/48901/children-crossing-sign-refreshed-and-restored/">refreshed the design</a> in 2016 to make the haircut more prominent.
Perhaps that’s why I never noticed it.)</p>

<figure>
<div class="three_up">
<picture>
<source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_1x.avif 250w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_2x.avif 500w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_3x.avif 750w" type="image/avif"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_1x.webp 250w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_2x.webp 500w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_3x.webp 750w" type="image/webp"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_1x.jpg 250w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_2x.jpg 500w, https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_3x.jpg 750w" type="image/jpeg"/><img alt="A green sign with white lettering that says ‘Escaleras de emergencia’ with a stick figure of a running person. The stick figure’s head is detached from their body and a perfect circle." src="https://alexwlchan.net/images/2026/pexels-cesar-diaz-356944981-14290014_1x.jpg" width="250"/>
</picture>
<picture>
<source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_1x.avif 250w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_2x.avif 500w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_3x.avif 750w" type="image/avif"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_1x.webp 250w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_2x.webp 500w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_3x.webp 750w" type="image/webp"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_1x.jpg 250w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_2x.jpg 500w, https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_3x.jpg 750w" type="image/jpeg"/><img alt="Red and green pedestrian crossing lights. The red light is a small figure with their arms oustretched and a wide brimmed hat, while the green light is the same figure walking, their head tipped back slightly." src="https://alexwlchan.net/images/2026/pexels-jos-van-ouwerkerk-377363-1616781_1x.jpg" width="250"/>
</picture>
<picture>
<source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_1x.avif 250w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_2x.avif 500w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_3x.avif 750w" type="image/avif"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_1x.webp 250w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_2x.webp 500w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_3x.webp 750w" type="image/webp"/><source sizes="(max-width:250px)100vw,250px" srcset="https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_1x.jpg 250w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_2x.jpg 500w, https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_3x.jpg 750w" type="image/jpeg"/><img alt="A warning sign labelled ‘School’. The sign is a red triangle with a white background, and two stick figure children are walking from left to right. The right-hand child is slightly older and walking the younger child by the hand. Their heads are attached to their bodies, and the older girl’s head is non-circular, but in a subtle way." src="https://alexwlchan.net/images/2026/Downpatrick_signs_(10)_August_2009_1x.jpg" width="250"/>
</picture>
</div>
<figcaption>
    Left to right:
    (1) a circular head on an emergency exit sign, photographed by <a href="https://www.pexels.com/photo/sign-on-white-wall-14290014/">Cesar Diaz</a>, used under the Pexels License;
    (2) the stylised head of the Ampelmännchen, photographed by <a href="https://www.pexels.com/photo/selective-focus-photography-of-traffic-light-1616781/">Jos van Ouwerkerk</a>, used under the Pexels License;
    (3) a school sign in Northern Ireland, photographed by <a href="https://commons.wikimedia.org/wiki/File:Downpatrick_signs_(10), _August_2009.JPG">Wikimedia user Ardfern</a>, used under CC BY‑SA 3.0.
  </figcaption>
</figure>
<p>It took me a bit of searching, but I eventually learnt that the egg-shaped head in the original image is a design choice that goes back at least as far as the Soviet Union.
Soviet road signs were specified by the GOST 10807-78 standard, released in 1980, and copies are <a href="https://files.stroyinf.ru/Data/444/44457.pdf">available online</a>.</p>
<p>This sign is entry 1.23 Дорожные работы (“men at work”).
The same head shape appears in signs like 1.20 Пешеходный переход (“pedestrian crossing”) and 1.21 Дети (“children”).</p>
<figure>
<div class="three_up">
<svg class="dark_aware" role="img" version="1.1" viewbox="0 0 837 737" xmlns="http://www.w3.org/2000/svg">
<title>1.25 Дорожные работы</title>
<path d="m 0,689.423 c 0,24.853 20.147,45 45,45 l 744.116,0 c 24.853,0 45,-20.147 45,-45 c 0,-7.899 -2.079,-15.659 -6.029,-22.5 l -372.058,-644.423 c -8.038,-13.923 -22.894,-22.5 -38.971,-22.5 c -16.077,0 -30.933,8.577 -38.971,22.5 l -372.058,644.423 c -3.950,6.841 -6.029,14.601 -6.029,22.5 z"></path>
<path d="m 1,689.423 c 0,24.301 19.699,44 44,44 l 744.116,0 c 24.301,0 44,-19.699 44,-44 c 0,-7.724 -2.033,-15.311 -5.895,-22.0 l -372.058,-644.423 c -7.860,-13.614 -22.385,-22.0 -38.105,-22.0 c -15.720,0 -30.245,8.386 -38.105,22.0 l -372.058,644.423 c -3.862,6.689 -5.895,14.276 -5.895,22.0 z"></path>
<path d="m 10,689.423 c 0,19.330 15.670,35 35,35 l 744.116,0 c 19.330,0 35,-15.670 35,-35 c 0,-6.144 -1.617,-12.179 -4.689,-17.5 l -372.058,-644.423 c -6.252,-10.829 -17.807,-17.5 -30.311,-17.5 c -12.504,0 -24.059,6.671 -30.311,17.5 l -372.058,644.423 c -3.072,5.321 -4.689,11.357 -4.689,17.5 z m 83.634,-33.852 c 0,-1.755 0.462,-3.480 1.340,-5.000 l 313.424,-542.866 c 1.786,-3.094 5.087,-5 8.660,-5.000 c 3.573,0 6.873,1.906 8.660,5.000 l 313.424,542.866 c 0.878,1.520 1.340,3.245 1.340,5.000 c 0,5.523 -4.477,10 -10,10 l -626.848,0 c -5.523,0 -10,-4.477 -10,-10 z"></path>
<path d="m 452.632,261.064 c -16.811,0 -26.967,12.680 -26.967,29.377 c 0,25.762 22.951,61.852 40.279,61.852 c 17.328,0 36.606,-17.500 36.606,-41.197 c 0,-19.680 -19.967,-50.033 -49.918,-50.033 z"></path>
<path d="m 430.829,621.965 c 0,11.408 9.248,20.656 20.656,20.656 c 11.408,0 20.656,-9.248 20.656,-20.656 l 0,-91.839 c 0.382,0.024 0.765,0.036 1.148,0.036 c 6.203,0 11.987,-3.132 15.376,-8.327 l 86.447,49.910 c -34.017,22.821 -52.631,10.633 -97.226,70.875 l 230.166,0 l -80.427,-139.304 c -17.123,19.357 -20.333,36.478 -41.128,59.101 l -95.376,-55.065 l -37.731,-140.816 c -0.495,-1.847 -1.444,-3.542 -2.760,-4.929 l -36.951,-39.016 c -2.167,-2.283 -5.176,-3.576 -8.324,-3.576 l -72.754,0 c -4.100,0 -7.888,2.187 -9.938,5.738 l -48.422,83.885 c -1.611,2.791 -2.460,5.957 -2.460,9.180 c 0,10.140 8.220,18.361 18.361,18.361 c 6.560,0 12.621,-3.500 15.901,-9.180 l 4.590,-7.950 l 26.037,15.033 l -8.033,13.913 c -1.007,1.744 -1.537,3.723 -1.537,5.738 c 0,4.100 2.187,7.888 5.738,9.938 l 97.992,56.576 z m -113.312,-214.842 l 29.668,-51.386 l 34.717,0 l -38.347,66.419 z m 81.967,44.408 l 26.404,-45.733 l 19.329,72.138 z"></path>
<path d="m 284.416,611.637 c -1.813,3.140 -2.767,6.702 -2.767,10.328 c 0,11.408 9.248,20.656 20.656,20.656 c 7.380,0 14.199,-3.937 17.888,-10.328 l 57.461,-99.838 c 1.007,-1.744 1.537,-3.723 1.537,-5.738 c 0,-0.468 -0.029,-0.935 -0.085,-1.399 l -3.216,-26.192 l -46.388,-26.782 l 6.197,50.468 z"></path>
</svg><svg class="dark_aware" role="img" version="1.1" viewbox="0 0 837 737" xmlns="http://www.w3.org/2000/svg">
<title>1.22 Пешеходный переход</title>
<path d="m 0,689.423 c 0,24.853 20.147,45 45,45 l 744.116,0 c 24.853,0 45,-20.147 45,-45 c 0,-7.899 -2.079,-15.659 -6.029,-22.5 l -372.058,-644.423 c -8.038,-13.923 -22.894,-22.5 -38.971,-22.5 c -16.077,0 -30.933,8.577 -38.971,22.5 l -372.058,644.423 c -3.950,6.841 -6.029,14.601 -6.029,22.5 z"></path>
<path d="m 1,689.423 c 0,24.301 19.699,44 44,44 l 744.116,0 c 24.301,0 44,-19.699 44,-44 c 0,-7.724 -2.033,-15.311 -5.895,-22.0 l -372.058,-644.423 c -7.860,-13.614 -22.385,-22.0 -38.105,-22.0 c -15.720,0 -30.245,8.386 -38.105,22.0 l -372.058,644.423 c -3.862,6.689 -5.895,14.276 -5.895,22.0 z"></path>
<path d="m 10,689.423 c 0,19.330 15.670,35 35,35 l 744.116,0 c 19.330,0 35,-15.670 35,-35 c 0,-6.144 -1.617,-12.179 -4.689,-17.5 l -372.058,-644.423 c -6.252,-10.829 -17.807,-17.5 -30.311,-17.5 c -12.504,0 -24.059,6.671 -30.311,17.5 l -372.058,644.423 c -3.072,5.321 -4.689,11.357 -4.689,17.5 z m 83.634,-33.852 c 0,-1.755 0.462,-3.480 1.340,-5.000 l 313.424,-542.866 c 1.786,-3.094 5.087,-5 8.660,-5.000 c 3.573,0 6.873,1.906 8.660,5.000 l 313.424,542.866 c 0.878,1.520 1.340,3.245 1.340,5.000 c 0,5.523 -4.477,10 -10,10 l -626.848,0 c -5.523,0 -10,-4.477 -10,-10 z"></path>
<path d="m 233.452,573.768 l -45.901,68.852 l 64.262,0 l 27.541,-68.852 z"></path>
<path d="m 396.402,573.768 l -9.180,68.852 l 59.672,0 l -9.180,-68.852 z"></path>
<path d="m 554.762,573.768 l 27.541,68.852 l 64.262,0 l -45.901,-68.852 z"></path>
<path d="m 347.232,601.805 c -2.281,9.149 -10.499,15.570 -19.928,15.570 c -11.343,0 -20.538,-9.195 -20.538,-20.538 c 0,-1.675 0.205,-3.344 0.610,-4.969 l 30.078,-120.638 c 0.380,-1.523 1.166,-2.913 2.276,-4.023 l 54.378,-54.378 l 0,-114.907 c 0,-3.862 2.060,-7.430 5.405,-9.362 l 27.905,-16.111 c 6.689,-3.862 14.930,-3.862 21.619,0 l 68.841,39.746 c 6.689,3.862 10.810,10.999 10.810,18.723 l 0,75.668 c 0,8.955 -7.259,16.215 -16.215,16.215 c -8.955,0 -16.215,-7.259 -16.215,-16.215 l 0,-64.426 c 0,-3.090 -1.648,-5.944 -4.324,-7.489 l -29.186,-16.851 l 0,90.711 c 0,2.867 -1.139,5.616 -3.166,7.644 l -86.316,86.316 c -1.110,1.110 -1.896,2.500 -2.276,4.023 z"></path>
<path d="m 456.733,434.312 l -30.070,30.070 l 82.401,142.723 c 3.669,6.355 10.449,10.269 17.787,10.269 c 11.343,0 20.538,-9.195 20.538,-20.538 c 0,-3.605 -0.949,-7.147 -2.752,-10.269 z"></path>
<path d="m 383.298,389.520 l 0,-45.862 l -61.731,61.731 c -3.041,3.041 -4.749,7.165 -4.749,11.465 c 0,8.955 7.259,16.215 16.215,16.215 c 4.300,0 8.425,-1.708 11.465,-4.749 z"></path>
<path d="m 430.320,188.770 c 11.891,0 31.672,6.918 31.672,29.186 c 0,25.511 -41.401,45.941 -54.373,45.941 c -18.268,0 -26.700,-21.079 -26.700,-34.051 c 0,-31.997 39.347,-41.077 49.400,-41.077 z"></path>
</svg><svg class="dark_aware" role="img" version="1.1" viewbox="0 0 837 737" xmlns="http://www.w3.org/2000/svg">
<title>1.23 Дети</title>
<path d="m 0,689.423 c 0,24.853 20.147,45 45,45 l 744.116,0 c 24.853,0 45,-20.147 45,-45 c 0,-7.899 -2.079,-15.659 -6.029,-22.5 l -372.058,-644.423 c -8.038,-13.923 -22.894,-22.5 -38.971,-22.5 c -16.077,0 -30.933,8.577 -38.971,22.5 l -372.058,644.423 c -3.950,6.841 -6.029,14.601 -6.029,22.5 z"></path>
<path d="m 1,689.423 c 0,24.301 19.699,44 44,44 l 744.116,0 c 24.301,0 44,-19.699 44,-44 c 0,-7.724 -2.033,-15.311 -5.895,-22.0 l -372.058,-644.423 c -7.860,-13.614 -22.385,-22.0 -38.105,-22.0 c -15.720,0 -30.245,8.386 -38.105,22.0 l -372.058,644.423 c -3.862,6.689 -5.895,14.276 -5.895,22.0 z"></path>
<path d="m 10,689.423 c 0,19.330 15.670,35 35,35 l 744.116,0 c 19.330,0 35,-15.670 35,-35 c 0,-6.144 -1.617,-12.179 -4.689,-17.5 l -372.058,-644.423 c -6.252,-10.829 -17.807,-17.5 -30.311,-17.5 c -12.504,0 -24.059,6.671 -30.311,17.5 l -372.058,644.423 c -3.072,5.321 -4.689,11.357 -4.689,17.5 z m 83.634,-33.852 c 0,-1.755 0.462,-3.480 1.340,-5.000 l 313.424,-542.866 c 1.786,-3.094 5.087,-5 8.660,-5.000 c 3.573,0 6.873,1.906 8.660,5.000 l 313.424,542.866 c 0.878,1.520 1.340,3.245 1.340,5.000 c 0,5.523 -4.477,10 -10,10 l -626.848,0 c -5.523,0 -10,-4.477 -10,-10 z"></path>
<path d="m 251.135,593.850 c -1.511,2.617 -2.306,5.585 -2.306,8.607 c 0,9.507 7.707,17.213 17.213,17.213 c 6.150,0 11.832,-3.281 14.907,-8.607 l 47.413,-82.121 l 49.584,-29.793 c 4.144,-2.490 6.678,-6.970 6.678,-11.804 c 0,-2.417 -0.636,-4.792 -1.845,-6.885 l -31.705,-54.915 c -1.209,-2.093 -1.845,-4.468 -1.845,-6.885 c 0,-2.417 0.636,-4.792 1.845,-6.885 l 44.478,-77.038 c 1.309,-2.268 1.999,-4.840 1.999,-7.459 c 0,-8.239 -6.679,-14.918 -14.918,-14.918 c -5.330,0 -10.254,2.843 -12.919,7.459 l -65.016,112.612 c -1.209,2.093 -1.845,4.468 -1.845,6.885 c 0,2.417 0.636,4.792 1.845,6.885 l 22.997,39.831 l -21.518,15.067 c -1.647,1.153 -3.022,2.653 -4.027,4.395 z"></path>
<path d="m 351.625,527.013 l 29.313,50.772 c 2.460,4.261 7.006,6.885 11.926,6.885 l 31.080,0 l 0,-34.426 l -19.154,0 l -23.651,-40.964 z"></path>
<path d="m 295.830,449.204 l -24.766,14.299 l -17.233,-29.849 c -2.665,-4.616 -7.590,-7.459 -12.919,-7.459 c -8.239,0 -14.918,6.679 -14.918,14.918 c 0,2.619 0.689,5.191 1.999,7.459 l 25.266,43.762 c 2.460,4.261 7.006,6.885 11.926,6.885 c 2.417,0 4.792,-0.636 6.885,-1.845 l 38.679,-22.331 z"></path>
<path d="m 266.042,380.638 c 0,17.901 14.918,31.672 29.262,31.672 c 22.033,0 30.869,-38.902 30.869,-46.475 c 0,-14.459 -7.229,-26.164 -22.492,-26.164 c -15.606,0 -37.639,20.770 -37.639,40.967 z"></path>
<path d="m 433.124,601.309 c 0,10.140 8.220,18.361 18.361,18.361 c 10.140,0 18.361,-8.220 18.361,-18.361 l 0,-97.122 l 80.673,-46.577 c 7.101,-4.100 11.475,-11.676 11.475,-19.876 c 0,-4.029 -1.060,-7.986 -3.075,-11.475 l -39.391,-68.228 l 40.779,0 l -18.541,-32.131 l -53.705,0 c -4.493,0 -8.703,2.192 -11.280,5.872 l -14.708,21.006 c -1.621,2.315 -2.490,5.072 -2.490,7.898 c 0,2.417 0.636,4.792 1.845,6.885 l 42.904,74.312 l -64.322,37.137 c -4.261,2.460 -6.885,7.006 -6.885,11.926 z"></path>
<path d="m 528.184,483.755 l 36.442,63.119 c 2.050,3.550 5.838,5.738 9.938,5.738 c 2.014,0 3.993,-0.530 5.738,-1.537 l 68.615,-39.566 l -18.361,-31.801 l -46.730,26.979 l -23.840,-41.292 z"></path>
<path d="m 453.184,373.939 l -24.905,43.137 l -38.647,-22.313 c -2.617,-1.511 -5.585,-2.306 -8.607,-2.306 c -9.507,0 -17.213,7.707 -17.213,17.213 c 0,6.150 3.281,11.832 8.607,14.907 l 56.535,32.641 c 1.744,1.007 3.723,1.537 5.738,1.537 c 4.100,0 7.888,-2.187 9.938,-5.738 l 27.105,-46.947 z"></path>
<path d="m 416.599,305.015 c 0,23.639 21.918,36.262 32.820,36.262 c 16.754,0 35.458,-35.114 35.458,-54.622 c -0.459,-16.181 -9.754,-26.967 -23.983,-26.967 c -19.393,0 -44.295,21.458 -44.295,45.327 z"></path>
</svg> </div>
<figcaption>
    Soviet Union road sign SVGs by Wikimedia user Юкатан, used under CC BY‑SA 3.0 (<a href="https://en.wikipedia.org/wiki/File:SU_road_sign_1.23.svg">1.23</a>,
     <a href="https://en.wikipedia.org/wiki/File:RU_road_sign_1.22.svg">1.20</a>, <a href="https://en.wikipedia.org/wiki/File:RU_road_sign_1.23.svg">1.21</a>)
  </figcaption>
</figure>
<p>I looked through the rest of the standard, and there are some other signs I enjoyed and would like to see when I’m driving: the adorable cow in 1.24 Перегон скота (“cattle drive”), the bugle in 3.26 Подача звукового сигнала запрещена (“sounding the horn is prohibited”), and the gently swaying tree in 6.11 Место отдыха (“resting place”).</p>
<figure>
<div class="three_up">
<svg class="dark_aware" role="img" version="1.1" viewbox="0 0 837 737" xmlns="http://www.w3.org/2000/svg">
<title>1.26 Перегон скота</title>
<path d="m 789.12 734.42 a 45 45 0 0 0 45 -45 a 45 45 0 0 0 -6.03 -22.50 l -372.06 -644.42 a 45 45 0 0 0 -38.97 -22.50 a 45 45 0 0 0 -38.97 22.50 l -372.06 644.42 a 45 45 0 0 0 -6.03 22.50 a 45 45 0 0 0 45 45 z"></path>
<path d="m 789.12 733.42 a 44 44 0 0 0 44 -44 a 44 44 0 0 0 -5.89 -22 l -372.06 -644.42 a 44 44 0 0 0 -38.11 -22 a 44 44 0 0 0 -38.11 22 l -372.06 644.42 a 44 44 0 0 0 -5.89 22 a 44 44 0 0 0 44 44 z"></path>
<path d="m 789.12 724.42 a 35 35 0 0 0 35 -35 a 35 35 0 0 0 -4.69 -17.50 l -372.06 -644.42 a 35 35 0 0 0 -30.31 -17.50 a 35 35 0 0 0 -30.31 17.50 l -372.06 644.42 a 35 35 0 0 0 -4.69 17.50 a 35 35 0 0 0 35 35 z m -685.48 -58.85 a 10 10 0 0 1 -10 -10 a 10 10 0 0 1 1.34 -5 l 313.42 -542.87 a 10 10 0 0 1 8.66 -5 a 10 10 0 0 1 8.66 5 l 313.42 542.87 a 10 10 0 0 1 1.34 5 a 10 10 0 0 1 -10 10 z"></path>
<path d="m 360 370 l -8.78 49.78 a 16 16 0 0 1 -15.76 13.22 h -40.46 l 15.18 52.89 a 25 25 0 0 0 24.03 18.11 h 28.29 a 16 16 0 0 1 16 16 v 46.50 a 9.50 9.50 0 0 0 9.50 9.50 a 9.50 9.50 0 0 0 9.50 -9.50 v -60.50 a 8 8 0 0 1 5 -7.42 l 56.29 -22.52 c 18.78 -7.51 17.63 17.88 26 22.71 c 8.02 4.63 38.14 5.35 40.87 21.67 l 7.98 47.71 a 9.50 9.50 0 0 0 9.36 7.85 a 9.50 9.50 0 0 0 9.50 -9.50 a 9.50 9.50 0 0 0 -0.02 -0.66 l -5.54 -79.23 a 20 20 0 0 0 -4.33 -11.10 l -19.32 -24.14 a 15 15 0 0 1 -3.29 -9.37 v -52 a 20 20 0 0 0 -20 -20 z" transform="scale(1.14753125) translate(-16.55891724 -20)"></path>
<path d="m 335.04 420.50 a 4 4 0 0 0 3.94 -3.31 l 10.70 -60.69 h 5.82 a 7.50 7.50 0 0 0 7.50 -7.50 a 7.50 7.50 0 0 0 -6.20 -7.39 l -1.15 -0.20 a 12 12 0 0 1 -9.51 -8.71 l -4.21 -15.72 a 2 2 0 0 0 -1.93 -1.48 a 2 2 0 0 0 -1.93 1.48 l -6.17 23.02 h -43.80 l -6.17 -23.02 a 2 2 0 0 0 -1.93 -1.48 a 2 2 0 0 0 -1.93 1.48 l -4.21 15.72 a 12 12 0 0 1 -9.51 8.71 l -1.15 0.20 a 7.50 7.50 0 0 0 -6.20 7.39 a 7.50 7.50 0 0 0 7.50 7.50 h 5.82 l 10.70 60.69 a 4 4 0 0 0 3.94 3.31 z" transform="scale(1.14753125) translate(-16.55891724 -20)"></path>
<path d="m 336.21 516 l -18.08 47.10 a 9.50 9.50 0 0 0 -0.63 3.40 a 9.50 9.50 0 0 0 9.50 9.50 a 9.50 9.50 0 0 0 8.87 -6.10 l 20.69 -53.90 z" transform="scale(1.14753125) translate(-16.55891724 -20)"></path>
<path d="m 474.93 500.79 l -25.71 62.07 a 9.50 9.50 0 0 0 -0.72 3.64 a 9.50 9.50 0 0 0 9.50 9.50 a 9.50 9.50 0 0 0 8.78 -5.86 l 22.70 -54.80 z" transform="scale(1.14753125) translate(-16.55891724 -20)"></path>
</svg><svg class="dark_aware" id="svg2" role="img" version="1.1" viewbox="0 0 600 602" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<title id="title3002">3.26 Подача звукового сигнала запрещена</title>
<g id="layer1" transform="translate(0,-452.36218)">
<path d="m -65.613615,432.25998 c 0,16.27327 -9.89766,29.46537 -22.107042,29.46537 -12.209383,0 -22.107043,-13.1921 -22.107043,-29.46537 0,-16.27328 9.89766,-29.46537 22.107043,-29.46537 12.209382,0 22.107042,13.19209 22.107042,29.46537 z" id="path5334" transform="matrix(13.570336,0,0,10.181443,1490.3988,-3648.6681)"></path>
<path d="m -65.613615,432.25998 c 0,16.27327 -9.89766,29.46537 -22.107042,29.46537 -12.209383,0 -22.107043,-13.1921 -22.107043,-29.46537 0,-16.27328 9.89766,-29.46537 22.107043,-29.46537 12.209382,0 22.107042,13.19209 22.107042,29.46537 z" id="path3003" transform="matrix(13.525102,0,0,10.147505,1486.4308,-3633.998)"></path>
<path d="m 116.76419,616.87826 c -12.73602,22.93116 -19.989922,49.32916 -19.989922,77.41935 0,28.09019 7.253902,54.4882 19.989922,77.41936 7.65019,-38.62321 41.72157,-67.74194 82.59072,-67.74194 l 12.5504,0 -19.65725,-19.65725 C 154.58181,681.17337 123.96684,653.242 116.76419,616.87826 z" id="path7161"></path>
<path d="m 271.9658,684.62019 19.35484,19.35484 37.71169,0 c 25.27855,0 46.76453,16.1619 54.7379,38.70968 l -53.73992,0 19.35484,19.35484 37.71169,0 c 0,10.81126 -2.95885,20.93929 -8.10484,29.60685 l 14.00202,14.00202 c 8.48791,-12.42217 13.45766,-27.42829 13.45766,-43.60887 l 30.24194,0 c 4.29604,16.69741 19.46166,29.03226 37.5,29.03226 21.37876,0 38.70967,-17.33092 38.70967,-38.70968 0,-21.37877 -17.33091,-38.70968 -38.70967,-38.70968 -18.03834,0 -33.20396,12.33485 -37.5,29.03226 l -32.69154,0 c -8.5921,-33.39481 -38.89309,-58.06452 -74.96975,-58.06452 l -57.06653,0 z" id="path7159"></path>
<path d="m 212.9033,742.68471 c -26.72346,0 -48.3871,21.66364 -48.3871,48.3871 0,26.72345 21.66364,48.38709 48.3871,48.38709 l 116.12903,0 c 5.68836,0 11.23335,-0.61987 16.57258,-1.78427 l -17.57057,-17.57057 -115.13104,0 c -16.03407,0 -29.03226,-12.99818 -29.03226,-29.03225 0,-16.03408 12.99819,-29.03226 29.03226,-29.03226 l 57.06653,0 -19.35484,-19.35484 -37.71169,0 z" id="path7133"></path>
<path d="M 300 10 C 139.83742 10 10 139.83742 10 300 C 10 460.16258 139.83742 590 300 590 C 460.16258 590 590 460.16258 590 300 C 590 139.83742 460.16258 10 300 10 z M 300 59.03125 C 433.08281 59.03125 540.96875 166.9172 540.96875 300 C 540.96875 359.57582 519.35369 414.09739 483.53125 456.15625 L 143.84375 116.46875 C 185.90261 80.646313 240.42418 59.03125 300 59.03125 z M 116.46875 143.84375 L 456.15625 483.53125 C 414.09739 519.35369 359.57582 540.96875 300 540.96875 C 166.91719 540.96875 59.03125 433.0828 59.03125 300 C 59.03125 240.42418 80.646314 185.90261 116.46875 143.84375 z " id="path7567" transform="translate(0,452.36218)"></path>
</g>
</svg><svg class="dark_aware" role="img" version="1.1" viewbox="0 0 705 1050" xmlns="http://www.w3.org/2000/svg">
<title>7.11 Место отдыха</title>
<rect height="1050" rx="45" ry="45" width="700" x="0" y="0"></rect>
<rect height="1048" rx="44" ry="44" width="698" x="1" y="1"></rect>
<path d="m 655 1030 a 25 25 0 0 0 25 -25 l 0 -960 a 25 25 0 0 0 -25 -25 l -610 0 a 25 25 0 0 0 -25 25 l 0 960 a 25 25 0 0 0 25 25 z m -544.38 -286.25 a 45 45 0 0 1 -45 -45 l 0 -478.75 a 45 45 0 0 1 45 -45 l 478.75 0 a 45 45 0 0 1 45 45 l 0 478.75 a 45 45 0 0 1 -45 45 z"></path>
<path d="m 420 620 v -52 a 190 190 0 0 0 120 -38 a 230 230 0 0 1 -130 -80 a 100 100 0 0 0 70 -20 a 210 210 0 0 1 -104 -100 a 220 220 0 0 0 34 -14 a 440 440 0 0 1 -99 -84 a 190 190 0 0 1 -19 121 a 180 180 0 0 0 34 -13 a 150 150 0 0 1 -55 140 a 210 210 0 0 0 69 -20 a 194 194 0 0 1 -80 120 a 120 120 0 0 0 100 -10 v 50 z" transform="scale(1.09375)"></path>
<path d="m 130 540 a 10 10 0 0 0 -10 10 a 10 10 0 0 0 10 10 h 6.08 l -16.08 60 h 16 l 16.08 -60 h 35.84 l 16.08 60 h 16 l -16.08 -60 h 6.08 a 10 10 0 0 0 10 -10 a 10 10 0 0 0 -10 -10 z" transform="scale(1.09375)"></path>
</svg> </div>
<figcaption>
    Soviet Union road sign SVGs by Wikimedia user Юкатан, used under CC BY‑SA 3.0 (<a href="https://en.wikipedia.org/wiki/File:RU_road_sign_1.26.svg">1.24</a>,
     <a href="https://en.wikipedia.org/wiki/File:RU_road_sign_3.26.svg">3.26</a>, <a href="https://en.wikipedia.org/wiki/File:RU_road_sign_7.11.svg">6.11</a>)
  </figcaption>
</figure>
<p>These signs all look familiar even to non-Soviet drivers because of the <a href="https://en.wikipedia.org/wiki/Vienna_Convention_on_Road_Signs_and_Signals">Vienna Convention on Road Signs and Signals</a>, which established international standards for road signs and images.
The full text is <a href="https://unece.org/DAM/trans/conventn/Conv_road_signs_2006v_EN.pdf">available online</a>, and Annex 3 includes symbols to be used on road signs.
The detail was left to individual countries, but the broad shapes are meant to be consistent.</p>
<p>After the dissolution of the Soviet Union, road signs gradually diverged in the new countries.
I found a cool website by Bartolomeo Mecánico, which collects photos of different road signs around the world, and there’s a page with examples of the men at work sign in <a href="https://www.elve.net/rmenru.htm">former Soviet countries</a>.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/soviet-road-signs/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="The world around us" />

    <summary type="html">Their heads are more a sort of rounded triangle shape, a bit like an oval or an egg.</summary>
</entry><entry>
  <title type="html">Setting up golink in my personal tailnet</title>
  <link
    href="https://alexwlchan.net/notes/2026/golinks/"
    rel="alternate"
    type="text/html"
    title="Setting up golink in my personal tailnet"
  />
  <published>2026-02-22T23:22:19+00:00</published>
  <updated>2026-02-27T23:03:45+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/golinks/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/golinks/">
    <![CDATA[<p><strong>I created a macOS LaunchAgent to start golink automatically whenever my desktop Mac restarts.</strong></p><p>We use <a href="https://tailscale.com/blog/golink">golink</a> a lot at work, and I wanted to add it to my personal tailnet.</p>
<p>I decided to run it from my home Mac mini, because it’s always running and golink is a very lightweight service.
Writing this, it occurs to me I could have also run it on my Linux web server, which doesn’t have to restart for macOS updates, but it’s set up now.</p>
<p>These are some notes on how I set it up:</p>
<ol>
<li><p><strong>Clone the [golink repo] to my Mac.</strong></p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>git<span class="w"> </span>clone<span class="w"> </span>git@github.com:tailscale/golink.git<span class="w"> </span>~/repos/golink</code></pre>
</li>
<li><p><strong>Authenticate golink with my tailnet.</strong>
I created an <a href="https://tailscale.com/docs/features/access-control/auth-keys">auth key</a> for my tailnet which is tagged with <code>tag:golink</code>, then passed it as the <code>TS_AUTH_KEY</code> environment variable to start golink:</p>
<pre class="lng-console wrap"><code><span class="gp">$</span><span class="w"> </span>TS_AUTHKEY<span class="o">=</span><span class="s2">"tskey-auth-&lt;key&gt;"</span><span class="w"> </span>go<span class="w"> </span>run<span class="w"> </span>./cmd/golink<span class="w"> </span>-sqlitedb<span class="w"> </span><span class="s2">"/Volumes/Media (Speedwell)/golink.db"</span></code></pre>
<p>That starts an instance of golink that I could see at <a href="http://go/">http://go/</a>, but it would only last as long as my terminal session – I want it to be an always-running service.</p>
</li>
<li><p><strong>Configure golink to start automatically.</strong>
I created a macOS LaunchAgent by creating a file at <code>~/Library/LaunchAgents/net.alexwlchan.golinks.plist</code> with the following contents:</p>
<pre class="lng-xml"><code><span class="cp">&lt;?xml version="1.0" encoding="UTF-8"?&gt;</span>
<span class="cp">&lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&gt;</span>
<span class="nt">&lt;plist</span> <span class="na">version=</span><span class="s">"1.0"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;dict&gt;</span>
        <span class="nt">&lt;key&gt;</span>Label<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;string&gt;</span>net.alexwlchan.golinks<span class="nt">&lt;/string&gt;</span>
        <span class="nt">&lt;key&gt;</span>ProgramArguments<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;array&gt;</span>
            <span class="nt">&lt;string&gt;</span>/usr/local/go/bin/go<span class="nt">&lt;/string&gt;</span>
            <span class="nt">&lt;string&gt;</span>run<span class="nt">&lt;/string&gt;</span>
            <span class="nt">&lt;string&gt;</span>./cmd/golink<span class="nt">&lt;/string&gt;</span>
            <span class="nt">&lt;string&gt;</span>-sqlitedb<span class="nt">&lt;/string&gt;</span>
            <span class="nt">&lt;string&gt;</span>/Volumes/Media (Speedwell)/golink.db<span class="nt">&lt;/string&gt;</span>
        <span class="nt">&lt;/array&gt;</span>
        <span class="nt">&lt;key&gt;</span>WorkingDirectory<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;string&gt;</span>/Users/alexwlchan/repos/golink<span class="nt">&lt;/string&gt;</span>
        <span class="nt">&lt;key&gt;</span>RunAtLoad<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;true/&gt;</span>
        <span class="nt">&lt;key&gt;</span>StandardOutPath<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;string&gt;</span>/Users/alexwlchan/Library/Logs/golinks.log<span class="nt">&lt;/string&gt;</span>
        <span class="nt">&lt;key&gt;</span>StandardErrorPath<span class="nt">&lt;/key&gt;</span>
        <span class="nt">&lt;string&gt;</span>/Users/alexwlchan/Library/Logs/golinks.log<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;/dict&gt;</span>
<span class="nt">&lt;/plist&gt;</span></code></pre>
<p>I start the service by running:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>launchctl<span class="w"> </span>load<span class="w"> </span>~/Library/LaunchAgents/net.alexwlchan.golinks.plist</code></pre>
<p>Now my golink service is running, and will be automatically started whenever I restart or log into my Mac.</p>
<p>If I need to stop it, I run:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>launchctl<span class="w"> </span>unload<span class="w"> </span>~/Library/LaunchAgents/net.alexwlchan.golinks.plist</code></pre>
<p>I can restart the service by running <code>unload</code>/<code>load</code>, for example if I’ve made changes to the LaunchAgent plist.</p>
</li>
<li><p><strong>Allow my devices to see golink.</strong>
I added a grant to my <a href="https://tailscale.com/docs/reference/syntax/policy-file">policy file</a> which allows every device in my tailnet to look up http://go/ URLs:</p>
<pre class="lng-json"><code>"grants"<span class="p">:</span> <span class="p">[</span>
  <span class="err">…</span>
  <span class="p">{</span>
    "src"<span class="p">:</span> <span class="p">[</span><span class="s2">"autogroup:member"</span><span class="p">],</span>
    "dst"<span class="p">:</span> <span class="p">[</span><span class="s2">"tag:golink"</span><span class="p">],</span>
    "ip"<span class="p">:</span>  <span class="p">[</span><span class="s2">"*"</span><span class="p">],</span>
  <span class="p">},</span>
<span class="p">]</span></code></pre>
<p>It’s also possible for me to control <a href="https://github.com/tailscale/golink?tab=readme-ov-file#permissions">who’s allowed to edit links</a>, but I’m the only user in my personal tailnet, so that’s a non-issue.</p>
</li>
<li><p><strong>Add a <a href="https://tailscale.com/docs/reference/syntax/policy-file#tests">policy file test</a> to ensure I can reach golinks from my devices.</strong></p>
<pre class="lng-json"><code>"hosts"<span class="p">:</span> <span class="p">{</span>
  "phaenna-mac-mini"<span class="p">:</span>     <span class="s2">"100.76.19.1"</span><span class="p">,</span>
  "go"<span class="p">:</span>                   <span class="s2">"100.107.83.99"</span><span class="p">,</span>
  <span class="err">…</span>
<span class="p">},</span>

"tests"<span class="p">:</span> <span class="p">[</span>
  <span class="p">{</span>
    "src"<span class="p">:</span>    <span class="s2">"phaenna-mac-mini"</span><span class="p">,</span>
    "accept"<span class="p">:</span> <span class="p">[</span><span class="s2">"go:80"</span><span class="p">],</span>
  <span class="p">},</span>
  <span class="err">…</span>
<span class="p">],</span></code></pre>
<p>I populated the <code>hosts</code> field using <a href="https://alexwlchan.net/notes/2026/map-of-tailscale-ips/">the output of <code>tailscale status</code></a>.</p>
</li>
</ol>
<p>(Disclaimer: At time of writing, I’m employed by Tailscale.)</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/golinks/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Tailscale" />

    <summary type="html">I created a macOS LaunchAgent to start golink automatically whenever my desktop Mac restarts.</summary>
</entry><entry>
  <title type="html">Create a file atomically in Go</title>
  <link
    href="https://alexwlchan.net/notes/2026/go-atomicfile/"
    rel="alternate"
    type="text/html"
    title="Create a file atomically in Go"
  />
  <published>2026-02-22T10:36:29+00:00</published>
  <updated>2026-02-22T10:36:29+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/go-atomicfile/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/go-atomicfile/">
    <![CDATA[<p><strong>Use <code>os.CreateTemp</code> to create a temporary file in the target directory, then do an atomic rename once you’ve finished writing.</strong></p><p>Here’s an interesting function from the Tailscale repos that <a href="https://github.com/knyar">Anton</a> told me about in a code review last week: a function to write to a file atomically.
This ensures you don’t get partially written data in the final file.</p>
<figure class="annotated_code"><pre class="lng-go"><code><div class="ln"><span class="lineno">10</span><span class="kn">import</span> <span class="p">(</span></div>
<div class="ln"><span class="lineno">11</span>	<span class="s">"fmt"</span></div>
<div class="ln"><span class="lineno">12</span>	<span class="s">"os"</span></div>
<div class="ln"><span class="lineno">13</span>	<span class="s">"path/filepath"</span></div>
<div class="ln"><span class="lineno">14</span>	<span class="s">"runtime"</span></div>
<div class="ln"><span class="lineno">15</span><span class="p">)</span></div>
<div class="ln"><span class="lineno">16</span></div>
<div class="ln"><span class="lineno">17</span><span class="c1">// WriteFile writes data to filename+some suffix, then renames it into filename.</span></div>
<div class="ln"><span class="lineno">18</span><span class="c1">// The perm argument is ignored on Windows, but if the target filename already</span></div>
<div class="ln"><span class="lineno">19</span><span class="c1">// exists then the target file's attributes and ACLs are preserved. If the target</span></div>
<div class="ln"><span class="lineno">20</span><span class="c1">// filename already exists but is not a regular file, WriteFile returns an error.</span></div>
<div class="ln"><span class="lineno">21</span><span class="kd">func</span> <span class="n">WriteFile</span><span class="p">(</span><span class="n">filename</span> <span class="kt">string</span><span class="p">,</span> <span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="n">perm</span> os<span class="p">.</span>FileMode<span class="p">)</span> <span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">22</span>	<span class="n">fi</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> os<span class="p">.</span>Stat<span class="p">(</span>filename<span class="p">)</span></div>
<div class="ln"><span class="lineno">23</span>	<span class="k">if</span> err <span class="o">==</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="p">!</span>fi<span class="p">.</span>Mode<span class="p">().</span>IsRegular<span class="p">()</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">24</span>		<span class="k">return</span> fmt<span class="p">.</span>Errorf<span class="p">(</span><span class="s">"%s already exists and is not a regular file"</span><span class="p">,</span> filename<span class="p">)</span></div>
<div class="ln"><span class="lineno">25</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">26</span>	<span class="n">f</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> os<span class="p">.</span>CreateTemp<span class="p">(</span>filepath<span class="p">.</span>Dir<span class="p">(</span>filename<span class="p">),</span> filepath<span class="p">.</span>Base<span class="p">(</span>filename<span class="p">)</span><span class="o">+</span><span class="s">".tmp"</span><span class="p">)</span></div>
<div class="ln"><span class="lineno">27</span>	<span class="k">if</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">28</span>		<span class="k">return</span> err</div>
<div class="ln"><span class="lineno">29</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">30</span>	<span class="n">tmpName</span> <span class="o">:=</span> f<span class="p">.</span>Name<span class="p">()</span></div>
<div class="ln"><span class="lineno">31</span>	<span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">32</span>		<span class="k">if</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">33</span>			f<span class="p">.</span>Close<span class="p">()</span></div>
<div class="ln"><span class="lineno">34</span>			os<span class="p">.</span>Remove<span class="p">(</span>tmpName<span class="p">)</span></div>
<div class="ln"><span class="lineno">35</span>		<span class="p">}</span></div>
<div class="ln"><span class="lineno">36</span>	<span class="p">}()</span></div>
<div class="ln"><span class="lineno">37</span>	<span class="k">if</span> _<span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> f<span class="p">.</span>Write<span class="p">(</span>data<span class="p">);</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">38</span>		<span class="k">return</span> err</div>
<div class="ln"><span class="lineno">39</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">40</span>	<span class="k">if</span> runtime<span class="p">.</span>GOOS <span class="o">!=</span> <span class="s">"windows"</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">41</span>		<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> f<span class="p">.</span>Chmod<span class="p">(</span>perm<span class="p">);</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">42</span>			<span class="k">return</span> err</div>
<div class="ln"><span class="lineno">43</span>		<span class="p">}</span></div>
<div class="ln"><span class="lineno">44</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">45</span>	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> f<span class="p">.</span>Sync<span class="p">();</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">46</span>		<span class="k">return</span> err</div>
<div class="ln"><span class="lineno">47</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">48</span>	<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> f<span class="p">.</span>Close<span class="p">();</span> err <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span></div>
<div class="ln"><span class="lineno">49</span>		<span class="k">return</span> err</div>
<div class="ln"><span class="lineno">50</span>	<span class="p">}</span></div>
<div class="ln"><span class="lineno">51</span>	<span class="k">return</span> Rename<span class="p">(</span>tmpName<span class="p">,</span> filename<span class="p">)</span></div>
<div class="ln"><span class="lineno">52</span><span class="p">}</span></div></code></pre>
<figcaption>Lines 10–52 of <a href="https://github.com/tailscale/tailscale/blob/8890c3c413d6422c7810719efe4ff3e8c994afa9/atomicfile/atomicfile.go#L10C1-L52"><code>atomicfile/atomicfile.go</code></a> in the <a href="https://github.com/tailscale/tailscale/">tailscale/tailscale</a> repo. Copyright Tailscale Inc &amp; contributors, used under the BSD-3-Clause license.</figcaption></figure><p>This is similar to code I’ve produced in other projects to do atomic file writes – write to a temporary file first, then do an atomic rename to the final destination.</p>
<p>The temporary file is created in the same directory as the target, to give the best chance of being able to do an atomic rename.
You can’t do an atomic rename across filesystem boundaries; using the same directory ensures both files are on the same filesystem.</p>
<p>To handle concurrent writes, I normally insert a random UUID into the temporary filename, so different processes write to different tempfiles.
This is handled automatically by Go’s <a href="https://pkg.go.dev/os#CreateTemp"><code>os.CreateTemp</code> function</a>, which adds a random string to the end of the filename.</p>
<p>The <code>Rename()</code> function has different logic for Windows and non-Windows systems:</p>
<ul>
<li>On non-Windows, it uses <a href="https://pkg.go.dev/os#Rename"><code>os.Rename()</code></a>.
The Go documentation notes that <em>“even within the same directory, on non-Unix platforms Rename is not an atomic operation”</em>.</li>
<li>On Windows, it makes a syscall to the <a href="https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew"><code>ReplaceFileW</code> function</a>.
A cursory Internet search is conflicted on whether this is a truly atomic rename, although concurs that it’s the best option on Windows.</li>
</ul>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/go-atomicfile/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Go" />

    <summary type="html">Use `os.CreateTemp` to create a temporary file in the target directory, then do an atomic rename once you've finished writing.</summary>
</entry><entry>
  <title type="html">Get a map of IP addresses for devices in my tailnet</title>
  <link
    href="https://alexwlchan.net/notes/2026/map-of-tailscale-ips/"
    rel="alternate"
    type="text/html"
    title="Get a map of IP addresses for devices in my tailnet"
  />
  <published>2026-02-22T08:10:53+00:00</published>
  <updated>2026-05-10T04:26:06+01:00</updated>

  <id>https://alexwlchan.net/notes/2026/map-of-tailscale-ips/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/map-of-tailscale-ips/">
    <![CDATA[<p><strong>Use <code>tailscale status --json</code> and filter the output using <code>jq</code>.</strong></p><p>Here’s a jq snippet that prints the hostname and IP addresses of every device in my tailnet (or at least, every device my current machine can see):</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>tailscale status --json <span class="p">\</span>
<span class="w">    </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s">'[.Self] + [.Peer[]]</span>
<span class="s">          | sort_by(.DNSName)</span>
<span class="s">          | map({(.DNSName): (.TailscaleIPs)}) </span>
<span class="s">          | add'</span>
<span class="go">{</span>
<span class="go">  "phaenna-mac-mini.tailfa84dd.ts.net.": [</span>
<span class="go">    "100.76.19.1",</span>
<span class="go">    "fd7a:115c:a1e0::fb01:1301"</span>
<span class="go">  ],</span>
<span class="go">  …</span>
<span class="go">}</span></code></pre>
<p>How it works:</p>
<ul>
<li><code>[.Self] + [.Peer[]]</code> combines the <code>.Self</code> object and <code>.Peer</code> array into a single array.</li>
<li><code>sort_by(.DNSName)</code> sorts the array based on the <code>DNSName</code> field.</li>
<li><code>map({(.DNSName): (.TailscaleIPs)})</code> converts each entry in that array into a map where the <code>DNSName</code> is the key, and the <code>TailscaleIPs</code> array is the value.
Now the output is an array of objects, each with a single key-value pair.</li>
<li><code>add</code> combines all those objects into a single object.</li>
</ul>
<p>Here’s a variant that keys the map by MagicDNS name:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>tailscale status --json <span class="p">\</span>
<span class="w">    </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s">'[.Self] + [.Peer[]]</span>
<span class="s">          | sort_by(.DNSName)</span>
<span class="s">          | map({(.DNSName | split(".")[0]): (.TailscaleIPs)}) </span>
<span class="s">          | add'</span>
<span class="go">{</span>
<span class="go">  "phaenna-mac-mini": [</span>
<span class="go">    "100.76.19.1",</span>
<span class="go">    "fd7a:115c:a1e0::fb01:1301"</span>
<span class="go">  ],</span>
<span class="go">  …</span>
<span class="go">}</span></code></pre>
<p>Another variant that just extracts the IPv4 address:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>tailscale status --json <span class="p">\</span>
<span class="w">    </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s">'[.Self] + [.Peer[]]</span>
<span class="s">          | sort_by(.DNSName)</span>
<span class="s">          | map({(.DNSName | split(".")[0]): (.TailscaleIPs[0])}) </span>
<span class="s">          | add'</span>
<span class="go">{</span>
<span class="go">  "linode-vps": "100.98.193.6",</span>
<span class="go">  "palaemon-macbook-air": "100.120.194.127",</span>
<span class="go">  "phaenna-mac-mini": "100.76.19.1",</span>
<span class="go">  …</span>
<span class="go">}</span></code></pre>
<p>I paste this directly into the <a href="https://tailscale.com/docs/reference/syntax/policy-file#hosts"><code>hosts</code> section</a> of my policy file.</p>
<p>If you have Mullvad nodes in your Tailnet, you can filter them out of this map with:</p>
<pre class="lng-console"><code><span class="gp">$</span><span class="w"> </span>tailscale status --json <span class="p">\</span>
<span class="w">    </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s">'[.Self] + [.Peer[]]</span>
<span class="s">          | map(select(.Tags == null or (.Tags | contains(["tag:mullvad-exit-node"]) | not)))</span>
<span class="s">          | sort_by(.DNSName)</span>
<span class="s">          | map({(.DNSName | split(".")[0]): (.TailscaleIPs[0])}) </span>
<span class="s">          | add'</span>
<span class="go">{</span>
<span class="go">  "linode-vps": "100.98.193.6",</span>
<span class="go">  "palaemon-macbook-air": "100.120.194.127",</span>
<span class="go">  "phaenna-mac-mini": "100.76.19.1",</span>
<span class="go">  …</span>
<span class="go">}</span></code></pre>
<p>Tested with Tailscale 1.95.104.
Disclaimer: At time of writing, I’m employed by Tailscale.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/map-of-tailscale-ips/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="Tailscale" />
    <category term="jq" />

    <summary type="html">Use `tailscale status --json` and filter the output using `jq`.
</summary>
</entry><entry>
  <title type="html">The SQLite command line shell will count your unclosed parentheses</title>
  <link
    href="https://alexwlchan.net/notes/2026/sqlite-nested-parens/"
    rel="alternate"
    type="text/html"
    title="The SQLite command line shell will count your unclosed parentheses"
  />
  <published>2026-02-19T21:05:41+00:00</published>
  <updated>2026-02-19T21:05:41+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/sqlite-nested-parens/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/sqlite-nested-parens/">
    <![CDATA[<p><strong>If the prompt starts with <code>(x1</code> or <code>(x2</code>, it means you’ve opened some parentheses and not closed them yet.</strong></p><p>While writing <a href="https://alexwlchan.net/notes/2026/sqlite-triggers-to-catch-errors/">my previous note</a>, I noticed an unexpected prefix in the SQLite shell:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span>CREATE TABLE KeyValuePairs <span class="p">(</span>
<span class="gp">(x1...&gt;</span><span class="w"> </span>    Key   TEXT NOT NULL PRIMARY KEY,
<span class="gp">(x1...&gt;</span><span class="w"> </span>    Value TEXT NOT NULL
<span class="gp">(x1...&gt;</span><span class="w"> </span><span class="p">);</span></code></pre>
<p>What does that <code>(x1</code> prefix mean?
I couldn’t find any reference to it online, so I had to read the <a href="https://sqlite.org/src/dir?ci=trunk">SQLite source code</a>.</p>
<p>The relevant code is in <code>shell.c.in</code>.
First I found the default prompts, and two variables where the main and continuation prompts are stored:</p>
<figure class="annotated_code"><pre class="lng-c"><code><div class="ln"><span class="lineno">406</span><span class="cm">/*</span></div>
<div class="ln"><span class="lineno">407</span><span class="cm">** Prompt strings. Initialized in main. Settable with</span></div>
<div class="ln"><span class="lineno">408</span><span class="cm">**   .prompt main continue</span></div>
<div class="ln"><span class="lineno">409</span><span class="cm">*/</span></div>
<div class="ln"><span class="lineno">410</span>#define <span class="n">PROMPT_LEN_MAX</span> <span class="mi">128</span></div>
<div class="ln"><span class="lineno">411</span><span class="cm">/* First line prompt.   default: "sqlite&gt; " */</span></div>
<div class="ln"><span class="lineno">412</span><span class="k">static</span> <span class="kt">char</span> <span class="n">mainPrompt</span><span class="p">[</span>PROMPT_LEN_MAX<span class="p">];</span></div>
<div class="ln"><span class="lineno">413</span><span class="cm">/* Continuation prompt. default: "   ...&gt; " */</span></div>
<div class="ln"><span class="lineno">414</span><span class="k">static</span> <span class="kt">char</span> <span class="n">continuePrompt</span><span class="p">[</span>PROMPT_LEN_MAX<span class="p">];</span></div></code></pre>
<figcaption>Lines 406–414 of <a href="https://sqlite.org/src/info?name=15285c21cc3f1da9289b0b6c5fd0b2ca8ab2e664b4b300c404afe7634ce9876f&amp;ln=406-414"><code>src/shell.c.in</code></a> in the <a href="https://sqlite.org/src/dir?ci=trunk">SQLite source repository</a>. All SQLite code is <a href="https://sqlite.org/copyright.html">in the public domain</a>.</figcaption></figure><p>Looking at where those variables get used leads to another interesting snippet, which names this feature as “dynamic continuation prompt”:</p>
<figure class="annotated_code"><pre class="lng-c"><code><div class="ln"><span class="lineno">444</span><span class="cm">/*</span></div>
<div class="ln"><span class="lineno">445</span><span class="cm">** Optionally disable dynamic continuation prompt.</span></div>
<div class="ln"><span class="lineno">446</span><span class="cm">** Unless disabled, the continuation prompt shows open SQL lexemes if any,</span></div>
<div class="ln"><span class="lineno">447</span><span class="cm">** or open parentheses level if non-zero, or continuation prompt as set.</span></div>
<div class="ln"><span class="lineno">448</span><span class="cm">** This facility interacts with the scanner and process_input() where the</span></div>
<div class="ln"><span class="lineno">449</span><span class="cm">** below 5 macros are used.</span></div>
<div class="ln"><span class="lineno">450</span><span class="cm">*/</span></div>
<div class="ln"><span class="lineno">451</span>#ifdef SQLITE_OMIT_DYNAPROMPT</div>
<div class="ln"><span class="lineno">452</span># define <span class="n">CONTINUATION_PROMPT</span> continuePrompt</div>
<div class="ln"><span class="lineno empty">⋮</span><span class="p"><span class="err">…</span></span></div>
<div class="ln"><span class="lineno">460</span>#else</div>
<div class="ln"><span class="lineno">461</span># define <span class="n">CONTINUATION_PROMPT</span> dynamicContinuePrompt()</div></code></pre>
<figcaption>Lines 444–461 of <a href="https://sqlite.org/src/info?name=15285c21cc3f1da9289b0b6c5fd0b2ca8ab2e664b4b300c404afe7634ce9876f&amp;ln=444-461"><code>src/shell.c.in</code></a> in the <a href="https://sqlite.org/src/dir?ci=trunk">SQLite source repository</a>.</figcaption></figure><p>And looking for the definition of that <code>dynamicContinuePrompt</code> function, I can see it updating a <code>dynPrompt.dynamicPrompt</code> variable with expressions like the <code>(x1</code> I saw in my SQLite shell:</p>
<figure class="annotated_code"><pre class="lng-c"><code><div class="ln"><span class="lineno">499</span><span class="cm">/* Upon demand, derive the continuation prompt to display. */</span></div>
<div class="ln"><span class="lineno">500</span><span class="k">static</span> <span class="kt">char</span> <span class="o">*</span><span class="n">dynamicContinuePrompt</span><span class="p">(</span><span class="kt">void</span><span class="p">){</span></div>
<div class="ln"><span class="lineno">501</span>  <span class="k">if</span><span class="p">(</span> continuePrompt<span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">==</span><span class="mi">0</span></div>
<div class="ln"><span class="lineno">502</span>      <span class="o">||</span> <span class="p">(</span>dynPrompt<span class="p">.</span>zScannerAwaits<span class="o">==</span><span class="mi">0</span> <span class="o">&amp;&amp;</span> dynPrompt<span class="p">.</span>inParenLevel <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">){</span></div>
<div class="ln"><span class="lineno">503</span>    <span class="k">return</span> continuePrompt<span class="p">;</span></div>
<div class="ln"><span class="lineno">504</span>  <span class="p">}</span><span class="k">else</span><span class="p">{</span></div>
<div class="ln"><span class="lineno">505</span>    <span class="k">if</span><span class="p">(</span> dynPrompt<span class="p">.</span>zScannerAwaits <span class="p">){</span></div>
<div class="ln"><span class="lineno">506</span>      <span class="kt">size_t</span> <span class="n">ncp</span> <span class="o">=</span> strlen<span class="p">(</span>continuePrompt<span class="p">);</span></div>
<div class="ln"><span class="lineno">507</span>      <span class="kt">size_t</span> <span class="n">ndp</span> <span class="o">=</span> strlen<span class="p">(</span>dynPrompt<span class="p">.</span>zScannerAwaits<span class="p">);</span></div>
<div class="ln"><span class="lineno">508</span>      <span class="k">if</span><span class="p">(</span> ndp <span class="o">&gt;</span> ncp<span class="mi">-3</span> <span class="p">)</span> <span class="k">return</span> continuePrompt<span class="p">;</span></div>
<div class="ln"><span class="lineno">509</span>      shell_strcpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">,</span> dynPrompt<span class="p">.</span>zScannerAwaits<span class="p">);</span></div>
<div class="ln"><span class="lineno">510</span>      <span class="k">while</span><span class="p">(</span> ndp<span class="o">&lt;</span><span class="mi">3</span> <span class="p">)</span> dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">[</span>ndp<span class="o">++</span><span class="p">]</span> <span class="o">=</span> <span class="sc">' '</span><span class="p">;</span></div>
<div class="ln"><span class="lineno">511</span>      shell_strncpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="o">+</span><span class="mi">3</span><span class="p">,</span> continuePrompt<span class="o">+</span><span class="mi">3</span><span class="p">,</span></div>
<div class="ln"><span class="lineno">512</span>              PROMPT_LEN_MAX<span class="mi">-4</span><span class="p">);</span></div>
<div class="ln"><span class="lineno">513</span>    <span class="p">}</span><span class="k">else</span><span class="p">{</span></div>
<div class="ln"><span class="lineno">514</span>      <span class="k">if</span><span class="p">(</span> dynPrompt<span class="p">.</span>inParenLevel<span class="o">&gt;</span><span class="mi">9</span> <span class="p">){</span></div>
<div class="ln"><span class="lineno">515</span>        shell_strncpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">,</span> <span class="s">"(.."</span><span class="p">,</span> <span class="mi">4</span><span class="p">);</span></div>
<div class="ln"><span class="lineno">516</span>      <span class="p">}</span><span class="k">else</span> <span class="k">if</span><span class="p">(</span> dynPrompt<span class="p">.</span>inParenLevel<span class="o">&lt;</span><span class="mi">0</span> <span class="p">){</span></div>
<div class="ln"><span class="lineno">517</span>        shell_strncpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">,</span> <span class="s">")x!"</span><span class="p">,</span> <span class="mi">4</span><span class="p">);</span></div>
<div class="ln"><span class="lineno">518</span>      <span class="p">}</span><span class="k">else</span><span class="p">{</span></div>
<div class="ln"><span class="lineno">519</span>        shell_strncpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">,</span> <span class="s">"(x."</span><span class="p">,</span> <span class="mi">4</span><span class="p">);</span></div>
<div class="ln"><span class="lineno">520</span>        dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="kt">char</span><span class="p">)(</span><span class="sc">'0'</span><span class="o">+</span>dynPrompt<span class="p">.</span>inParenLevel<span class="p">);</span></div>
<div class="ln"><span class="lineno">521</span>      <span class="p">}</span></div>
<div class="ln"><span class="lineno">522</span>      shell_strncpy<span class="p">(</span>dynPrompt<span class="p">.</span>dynamicPrompt<span class="o">+</span><span class="mi">3</span><span class="p">,</span> continuePrompt<span class="o">+</span><span class="mi">3</span><span class="p">,</span></div>
<div class="ln"><span class="lineno">523</span>                    PROMPT_LEN_MAX<span class="mi">-4</span><span class="p">);</span></div>
<div class="ln"><span class="lineno">524</span>    <span class="p">}</span></div>
<div class="ln"><span class="lineno">525</span>  <span class="p">}</span></div>
<div class="ln"><span class="lineno">526</span>  <span class="k">return</span> dynPrompt<span class="p">.</span>dynamicPrompt<span class="p">;</span></div>
<div class="ln"><span class="lineno">527</span><span class="p">}</span></div>
<div class="ln"><span class="lineno">528</span>#endif <span class="cm">/* !defined(SQLITE_OMIT_DYNAPROMPT) */</span></div></code></pre>
<figcaption>Lines 499–528 of <a href="https://sqlite.org/src/info?name=15285c21cc3f1da9289b0b6c5fd0b2ca8ab2e664b4b300c404afe7634ce9876f&amp;ln=499-528"><code>src/shell.c.in</code></a> in the <a href="https://sqlite.org/src/dir?ci=trunk">SQLite source repository</a>.</figcaption></figure><p>I don’t completely understand this function, but I think I get the general gist.
The first branch is looking for “open SQL lexemes”, or unterminated strings, while the second branch is counting open parentheses.
I can compare this to what I see in the SQLite shell:</p>
<ul>
<li>If you have between 1 to 9 unclosed parentheses, the prompt starts with <code>(x</code> and the number of unclosed parens:
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="p">(</span>
<span class="gp">(x1...&gt;</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="p">(((((((((</span>
<span class="gp">(x9...&gt;</span></code></pre>
</li>
<li>If you have 10 or more unclosed parentheses, the prompt starts with prints <code>(..</code>:
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="p">((((((((((</span>
<span class="gp">(x.....&gt;</span></code></pre>
</li>
<li>
<p>If you have more closed parentheses than you've opened, the prompt starts with prints <code>)x!</code>:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="p">)))</span>
<span class="gp">)x!...&gt;</span></code></pre>
</li>
<p>If you’re in this state, I’m not sure if it's ever possible to get back to a valid SQL expression?</p>
<li><p>If you have an unfinished string, square bracket, or <a href="https://sqlite.org/lang_comment.html">multi-line comment</a>, the prompt starts with the quote character you need to close the string:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span>SELECT '
<span class="gp">'  ...&gt;</span><span class="w"> </span>hello world
<span class="gp">'  ...&gt;</span><span class="w"> </span>'
<span class="go"></span>
<span class="go">hello world</span>
<span class="go"></span>
<span class="gp">sqlite&gt;</span><span class="w"> </span>SELECT "
<span class="gp">"  ...&gt;</span><span class="w"> </span>hello world
<span class="gp">"  ...&gt;</span><span class="w"> </span>"
<span class="go"></span>
<span class="go">hello world</span>
<span class="go"></span>
<span class="gp">sqlite&gt;</span><span class="w"> </span>[
<span class="gp">[  ...&gt;</span><span class="w"> </span>

<span class="gp">sqlite&gt;</span><span class="w"> </span>/*
<span class="gp">/* ...&gt;</span><span class="w"> </span>
</code></pre>
</li>
</ul>
<p>I wonder where this behaviour came from?
It feels like the sort of thing that might have come from Lisp, which is famous for having lots of brackets and exactly where this sort of indicator might be useful, whereas I imagine writing a heavily nested expression in the SQLite shell interface is comparatively rare.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/sqlite-nested-parens/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="SQLite" />

    <summary type="html">If the prompt starts with `(x1` or `(x2`, it means you've opened some parentheses and not closed them yet.</summary>
</entry><entry>
  <title type="html">Use SQL triggers to prevent overwriting a value</title>
  <link
    href="https://alexwlchan.net/notes/2026/sqlite-triggers-to-catch-errors/"
    rel="alternate"
    type="text/html"
    title="Use SQL triggers to prevent overwriting a value"
  />
  <published>2026-02-19T19:50:44+00:00</published>
  <updated>2026-02-19T19:50:44+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/sqlite-triggers-to-catch-errors/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/sqlite-triggers-to-catch-errors/">
    <![CDATA[<p><strong>A trigger lets you run an action when you <code>INSERT</code>, <code>UPDATE</code> or <code>DELETE</code> a value.</strong></p><p>Today I wanted to write a value to a SQLite database, and error if the database already had a conflicting value.</p>
<p>There are a variety of ways you could do this – I decided to read the current stored value and check it in Go – but I also discovered there’s a way you could do it in SQL alone using <a href="https://sqlite.org/lang_createtrigger.html"><code>CREATE TRIGGER</code></a>.
I did this with SQLite, but it looks like this is supported by other SQL dialects, including PostgreSQL and MySQL.</p>
<h2 id="setup">Setup</h2>
<p>Let’s create a table which we’ll use to store write-once values:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span>
<span class="gp">   ...&gt;</span><span class="w">     </span><span class="k">Key</span><span class="w">   </span>TEXT<span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span>
<span class="gp">   ...&gt;</span><span class="w">     </span>Value<span class="w"> </span>TEXT<span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="p">);</span></code></pre>
<p>If I try to <code>INSERT</code> a duplicate key into this table, it fails:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span><span class="k">Key</span><span class="p">,</span><span class="w"> </span>Value<span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">'Colour'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Red'</span><span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span><span class="k">Key</span><span class="p">,</span><span class="w"> </span>Value<span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">'Colour'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Green'</span><span class="p">);</span>
<span class="go">Runtime error: UNIQUE constraint failed: WriteOnce.Key (19)</span></code></pre>
<p>But I can overwrite an existing key with an <code>INSERT OR REPLACE</code> or <code>UPDATE</code>:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span><span class="k">Key</span><span class="p">,</span><span class="w"> </span>Value<span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">'Colour'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Green'</span><span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>WriteOnce<span class="p">;</span>
<span class="go">Parse error: no such table: WriteOnce</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="p">;</span>
<span class="go">Colour|Green</span>

<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span>Value<span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Blue'</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span><span class="p">;</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="p">;</span>
<span class="go">Colour|Blue</span></code></pre>
<h2 id="adding-triggers">Adding triggers</h2>
<p>Let’s suppose I want to prevent somebody from overwriting the <code>Colour</code> key with a different value.</p>
<p>I can use <a href="https://sqlite.org/lang_createtrigger.html"><code>CREATE TRIGGER</code></a> to create a trigger on my table – that is, an action that runs whenever I perform an <code>INSERT</code>, <code>UPDATE</code> or <code>DELETE</code>.</p>
<p>For the <code>INSERT</code> case, I look for an existing key-value pair, and check if the existing value matches the inserted value.
If not, I call a special <a href="https://sqlite.org/lang_createtrigger.html#the_raise_function"><code>RAISE()</code> function</a> which aborts the transaction, and nothing is written:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">CREATE</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span>prevent_insert_overwrite_colour
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">BEFORE</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">EACH</span><span class="w"> </span><span class="k">ROW</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">NEW</span><span class="p">.</span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span><span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">BEGIN</span>
<span class="gp">   ...&gt;</span><span class="w">     </span><span class="k">SELECT</span><span class="w"> </span><span class="k">CASE</span>
<span class="gp">   ...&gt;</span><span class="w">         </span><span class="k">WHEN</span><span class="w"> </span><span class="p">(</span>
<span class="gp">   ...&gt;</span><span class="w">             </span><span class="k">SELECT</span><span class="w"> </span>Value
<span class="gp">   ...&gt;</span><span class="w">             </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w">             </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span>
<span class="gp">   ...&gt;</span><span class="w">         </span><span class="p">)</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="k">New</span><span class="p">.</span>Value
<span class="gp">   ...&gt;</span><span class="w">         </span><span class="k">THEN</span><span class="w"> </span>RAISE<span class="p">(</span><span class="k">ABORT</span><span class="p">,</span><span class="w"> </span><span class="s1">'Error: Colour already exists with a different value.'</span><span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w">     </span><span class="k">END</span><span class="p">;</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">END</span><span class="p">;</span></code></pre>
<p>For the <code>UPDATE</code> case, I can use the <code>OLD</code> reference to inspect the existing value in the table:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">CREATE</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span>prevent_update_overwrite_colour
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">BEFORE</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">EACH</span><span class="w"> </span><span class="k">ROW</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="k">NEW</span><span class="p">.</span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span><span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">BEGIN</span>
<span class="gp">   ...&gt;</span><span class="w">     </span><span class="k">SELECT</span><span class="w"> </span><span class="k">CASE</span>
<span class="gp">   ...&gt;</span><span class="w">         </span><span class="k">WHEN</span><span class="w"> </span><span class="k">OLD</span><span class="p">.</span>Value<span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="k">New</span><span class="p">.</span>Value
<span class="gp">   ...&gt;</span><span class="w">         </span><span class="k">THEN</span><span class="w"> </span>RAISE<span class="p">(</span><span class="k">ABORT</span><span class="p">,</span><span class="w"> </span><span class="s1">'Error: Colour already exists with a different value.'</span><span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w">     </span><span class="k">END</span><span class="p">;</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">END</span><span class="p">;</span></code></pre>
<p>With these two triggers in place, running an <code>INSERT</code> or <code>UPDATE</code> that matches the existing value is a no-op:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span><span class="k">Key</span><span class="p">,</span><span class="w"> </span>Value<span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">'Colour'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Blue'</span><span class="p">);</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span>Value<span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Blue'</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span><span class="p">;</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="p">;</span></code></pre>
<p>But trying to <code>INSERT</code> or <code>UPDATE</code> a conflicting value throws my custom error, and leaves the value as-is:</p>
<pre class="lng-sqlite3"><code><span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span>KeyValuePairs<span class="w"> </span><span class="p">(</span><span class="k">Key</span><span class="p">,</span><span class="w"> </span>Value<span class="p">)</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">'Colour'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Orange'</span><span class="p">);</span>
<span class="go">Runtime error: Error: Colour already exists with a different value. (19)</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span>KeyValuePairs
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span>Value<span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Purple'</span>
<span class="gp">   ...&gt;</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="k">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'Colour'</span><span class="p">;</span>
<span class="go">Runtime error: Error: Colour already exists with a different value. (19)</span>
<span class="gp">sqlite&gt;</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span>KeyValuePairs<span class="p">;</span>
<span class="go">Colour|Blue</span></code></pre>
<p>The projects I work on usually put this sort of logic in the application code, but it’s neat to see how this could be implemented in the database layer.</p>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/sqlite-triggers-to-catch-errors/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="SQLite" />

    <summary type="html">A trigger lets you run an action when you `INSERT`, `UPDATE` or `DELETE` a value.</summary>
</entry><entry>
  <title type="html">Testing date formatting with date-fns-tz and different timezones</title>
  <link
    href="https://alexwlchan.net/notes/2026/test-date-fns-tz-by-timezone/"
    rel="alternate"
    type="text/html"
    title="Testing date formatting with date-fns-tz and different timezones"
  />
  <published>2026-02-19T08:47:15+00:00</published>
  <updated>2026-02-19T08:47:15+00:00</updated>

  <id>https://alexwlchan.net/notes/2026/test-date-fns-tz-by-timezone/</id>

  <content type="html" xml:base="https://alexwlchan.net/notes/2026/test-date-fns-tz-by-timezone/">
    <![CDATA[<p><strong>Override the <code>TZ</code> environment variable in your tests.</strong></p><p>I was reading some code which formatted dates using the <a href="https://github.com/marnusw/date-fns-tz?tab=readme-ov-file#format"><code>format</code> function</a> from the <a href="https://github.com/marnusw/date-fns-tz"><code>date-fns-tz</code> library</a>.
Here’s an example:</p>
<pre class="lng-javascript"><code><span class="k">import</span> <span class="p">{</span> <span class="n">format</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">"date-fns-tz"</span>

<span class="cm">/* formatDate returns the given date as a date string, with a 12-hour</span>
<span class="cm"> * timestamp and the timezone. Example: Jan 2, 2006 - 10:04 PM GMT. */</span>
<span class="k">export</span> <span class="kd">function</span> <span class="n">formatDate</span><span class="p">(</span><span class="n">date</span><span class="o">:</span> Date<span class="p">)</span><span class="o">:</span> string <span class="p">{</span>
  <span class="k">return</span> format<span class="p">(</span>date<span class="p">,</span> <span class="s2">"MMM d, y - p z"</span><span class="p">)</span>
<span class="p">}</span></code></pre>
<p>If I tested the code in Chrome by <a href="https://stackoverflow.com/a/60008052">changing the browser timezone</a>, I could see it behaving correctly – the displayed time would change to match my current timezone.</p>
<p>I wanted to write an automated test to check this behaviour.</p>
<h2 id="option-1-pass-the-timezone-to-code-format-code-in-code-optionswithtz-code">Option 1: Pass the timezone to <code>format()</code> in <code>OptionsWithTZ</code></h2>
<p>The <code>format()</code> function accepts an optional third argument <code>options: OptionsWithTZ</code>, which can include a timezone.
If I allowed passing a timezone to <code>formatDate()</code>, I could pass it to <code>format()</code>.</p>
<p>However, that would mean changing the function.
This code didn’t have any existing tests, and I don’t like changing code at the same time I add tests – it’s too easy to introduce a change unexpectedly, and codify the wrong behaviour in your new tests.</p>
<p>I prefer to add tests that check the existing behaviour, merge them to main, and only then start changing the implementation.</p>
<h2 id="option-2-mock-the-code-tz-code-environment-variable">Option 2: Mock the <code>TZ</code> environment variable</h2>
<p>If you don’t give <code>format()</code> an explicit timezone, it guesses one based on your environment.
In my Node tests, it was enough to mock the value of the <code>TZ</code> environment variable with different timezones, and watch the value change.</p>
<p>Here’s an example using vitest:</p>
<pre class="lng-javascript"><code><span class="k">import</span> <span class="p">{</span> <span class="n">afterEach</span><span class="p">,</span> <span class="n">describe</span><span class="p">,</span> <span class="n">expect</span><span class="p">,</span> <span class="n">it</span><span class="p">,</span> <span class="n">vi</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">"vitest"</span>
<span class="k">import</span> <span class="p">{</span> <span class="n">formatDate</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">"./dates"</span>

describe<span class="p">(</span><span class="s2">"formatDate"</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="n">d</span> <span class="o">=</span> <span class="ow">new</span> Date<span class="p">(</span><span class="s2">"2006-01-02T15:04:05-0700"</span><span class="p">)</span>
   
  <span class="kd">const</span> <span class="n">testCases</span><span class="o">:</span> <span class="p">{</span> <span class="n">tz</span><span class="o">:</span> string<span class="p">,</span> <span class="n">expected</span><span class="o">:</span> string <span class="o">|</span> Regexp <span class="p">}[]</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">{</span> tz<span class="o">:</span> <span class="s2">"America/Los_Angeles"</span><span class="p">,</span> expected<span class="o">:</span> <span class="sr">/Jan 2, 2006 - 2:04 PM (GMT-8|PST)/</span> <span class="p">},</span>
    <span class="p">{</span> tz<span class="o">:</span> <span class="s2">"Europe/London"</span><span class="p">,</span>       expected<span class="o">:</span> <span class="s2">"Jan 2, 2006 - 10:04 PM GMT"</span>        <span class="p">},</span>
    <span class="p">{</span> tz<span class="o">:</span> <span class="s2">"Asia/Kolkata"</span><span class="p">,</span>        expected<span class="o">:</span> <span class="s2">"Jan 3, 2006 - 3:34 AM GMT+5:30"</span>    <span class="p">},</span>
    <span class="p">{</span> tz<span class="o">:</span> <span class="s2">"Pacific/Auckland"</span><span class="p">,</span>    expected<span class="o">:</span> <span class="s2">"Jan 3, 2006 - 11:04 AM GMT+13"</span>     <span class="p">},</span>
  <span class="p">]</span>

  afterEach<span class="p">(()</span> <span class="p">=&gt;</span> vi<span class="p">.</span>unstubAllEnvs<span class="p">())</span>

  it<span class="p">.</span>each<span class="p">(</span>testCases<span class="p">)(</span><span class="s2">"formats date for $tz as $expected"</span><span class="p">,</span> <span class="p">({</span> <span class="n">tz</span><span class="p">,</span> <span class="n">expected</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
    vi<span class="p">.</span>stubEnv<span class="p">(</span><span class="s2">"TZ"</span><span class="p">,</span> tz<span class="p">)</span>
    expect<span class="p">(</span>formatDate<span class="p">(</span>d<span class="p">)).</span>toMatch<span class="p">(</span>expected<span class="p">)</span>
  <span class="p">})</span>
<span class="p">})</span></code></pre>
<p>Notes:</p>
<ul>
<li>My example date is the reference time used for <a href="https://pkg.go.dev/time#pkg-constants">formatting dates in Go</a>.</li>
<li>The way timezones are displayed can vary across platforms, hence the <code>Regexp</code>.
On my Mac, Los Angeles time is <code>GMT-8</code>, but in the Linux CI worker, it’s <code>PST</code>.</li>
<li>I wonder if this test will break when the clocks change in one of the locations, but I think it will be fine.
The UTC offset in those locations on that day isn’t going to change.
I’ll update this note if the code breaks.</li>
</ul>

    <p>[If the formatting of this post looks odd in your feed reader, <a href="https://alexwlchan.net/notes/2026/test-date-fns-tz-by-timezone/">visit the original article</a>]</p>
    ]]>
  </content>

    <category term="JavaScript" />
    <category term="Datetime shenanigans" />

    <summary type="html">Override the `TZ` environment variable in your tests.</summary>
</entry></feed>