<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hanminsoo.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hanminsoo.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-19T01:08:25+00:00</updated><id>https://hanminsoo.github.io/feed.xml</id><title type="html">Small Machine Notes</title><subtitle>Fixes and field notes from running automation, AI coding agents and small self-hosted services on one Mac. Every post is a problem I hit and verified on real hardware.</subtitle><entry><title type="html">ffmpeg drawtext breaks on quotes, colons, commas and %: “No option name near”, “Stray %”, blank or □□□ text — use textfile= with expansion=none</title><link href="https://hanminsoo.github.io/2026/10/ffmpeg-drawtext-quotes-colon-percent-korean-textfile/" rel="alternate" type="text/html" title="ffmpeg drawtext breaks on quotes, colons, commas and %: “No option name near”, “Stray %”, blank or □□□ text — use textfile= with expansion=none" /><published>2026-10-19T00:00:00+00:00</published><updated>2026-10-19T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/ffmpeg-drawtext-quotes-colon-percent-korean-textfile</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/ffmpeg-drawtext-quotes-colon-percent-korean-textfile/"><![CDATA[<p>You add a caption to a video with ffmpeg’s <code class="language-plaintext highlighter-rouge">drawtext</code> filter, and it works —
until the caption is a real sentence. An apostrophe, a time like <code class="language-plaintext highlighter-rouge">3:30</code>, or a
comma gives <strong><code class="language-plaintext highlighter-rouge">No option name near '30:fontsize=48:...'</code></strong> and exit code 234.
A <strong><code class="language-plaintext highlighter-rouge">%</code> is worse: ffmpeg prints <code class="language-plaintext highlighter-rouge">Stray % near ''</code>, exits 0, and the video has
no caption at all.</strong> And Korean (or any non-Latin text) may come out as a row
of empty boxes.</p>

<p>Below is each failure reproduced on my Mac, a working way to escape the text
if you must use <code class="language-plaintext highlighter-rouge">text=</code>, and the fix I ended up with: put the caption in a file
and use <code class="language-plaintext highlighter-rouge">textfile=</code> <strong>with <code class="language-plaintext highlighter-rouge">expansion=none</code></strong> — <code class="language-plaintext highlighter-rouge">textfile=</code> alone still fails on <code class="language-plaintext highlighter-rouge">%</code>.</p>

<p>Every command here renders one frame from a generated black background
(<code class="language-plaintext highlighter-rouge">-f lavfi -i color=...</code>) to a PNG, so you can try them without a video. <code class="language-plaintext highlighter-rouge">-y</code>
overwrites the output file without asking — run them in an empty folder.</p>

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

<p><strong>No font that has Korean glyphs: boxes.</strong> Without <code class="language-plaintext highlighter-rouge">fontfile=</code>, the static
ffmpeg build I used couldn’t load a fontconfig configuration, fell back to a
font without Hangul, and drew one box per syllable. Exit code 0:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=text='안녕하세요':fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 1-tofu.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">Fontconfig error: Cannot load default config file: No such file: (null)
Fontconfig error: Cannot load default config file: No such file: (null)
exit 0
</span></code></pre></div></div>

<p>The PNG shows <code class="language-plaintext highlighter-rouge">□□□□□</code>. Pointing <code class="language-plaintext highlighter-rouge">fontfile=</code> at a font with Korean glyphs
fixes it; macOS ships one at <code class="language-plaintext highlighter-rouge">/System/Library/Fonts/AppleSDGothicNeo.ttc</code>. (A
Homebrew ffmpeg with a working fontconfig setup may pick a different default
font; I only tested this build.)</p>

<p><strong>An apostrophe inside <code class="language-plaintext highlighter-rouge">text='...'</code>:</strong></p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text='it's 오후 3:30':fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 2-quote.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[AVFilterGraph @ 0x6000039ec000] No option name near '30:fontsize=48:fontcolor=white:x=20:y=50'
[AVFilterGraph @ 0x6000039ec000] Error parsing a filter description around: 
[AVFilterGraph @ 0x6000039ec000] Error parsing filterchain 'drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text='it's 오후 3:30':fontsize=48:fontcolor=white:x=20:y=50' around: 
Error opening output file 2-quote.png.
Error opening output files: Invalid argument
exit 234
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">'</code> in <code class="language-plaintext highlighter-rouge">it's</code> closes the quote, so the <code class="language-plaintext highlighter-rouge">:</code> in <code class="language-plaintext highlighter-rouge">3:30</code> is no longer quoted and
ends the <code class="language-plaintext highlighter-rouge">text</code> option. ffmpeg then reads <code class="language-plaintext highlighter-rouge">30</code> as the next option, and it has
no <code class="language-plaintext highlighter-rouge">name=</code>. Without quotes, a plain <code class="language-plaintext highlighter-rouge">text=오후 3:30</code> fails with the same message.</p>

<p><strong>A comma:</strong></p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text=네, 맞아요:fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 3-comma.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[AVFilterGraph @ 0x600001d8a4c0] No option name near '48:fontcolor=white:x=20:y=50'
[AVFilterGraph @ 0x600001d8a4c0] Error parsing a filter description around: 
[AVFilterGraph @ 0x600001d8a4c0] Error parsing filterchain 'drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text=네, 맞아요:fontsize=48:fontcolor=white:x=20:y=50' around: 
Error opening output file 3-comma.png.
Error opening output files: Invalid argument
exit 234
</span></code></pre></div></div>

<p>In a filtergraph a comma separates filters, so everything after <code class="language-plaintext highlighter-rouge">네</code> became a
second “filter”.</p>

<p><strong>A percent sign — the silent one:</strong></p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text='할인 50%':fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 4-percent.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[Parsed_drawtext_0 @ 0x6000006802c0] Stray % near ''
exit 0
</span></code></pre></div></div>

<p>Exit 0, the file is written, and the frame is <strong>plain black: the whole caption
is dropped, not just the <code class="language-plaintext highlighter-rouge">%</code>.</strong> drawtext treats <code class="language-plaintext highlighter-rouge">%{...}</code> as an expansion (for
things like <code class="language-plaintext highlighter-rouge">%{pts}</code>), and a <code class="language-plaintext highlighter-rouge">%</code> that doesn’t start one makes it give up on the
text. If your script only checks the exit code, it won’t notice.</p>

<h2 id="why-three-parsers">Why: three parsers</h2>

<p>The caption is parsed three times before it’s drawn, and each parser has its
own special characters:</p>

<ol>
  <li><strong>The filtergraph</strong> (<code class="language-plaintext highlighter-rouge">-vf</code>): <code class="language-plaintext highlighter-rouge">,</code> <code class="language-plaintext highlighter-rouge">;</code> <code class="language-plaintext highlighter-rouge">[</code> <code class="language-plaintext highlighter-rouge">]</code> separate filters and links;
<code class="language-plaintext highlighter-rouge">'</code> quotes; <code class="language-plaintext highlighter-rouge">\</code> escapes.</li>
  <li><strong>The filter’s options</strong>: <code class="language-plaintext highlighter-rouge">:</code> separates options; <code class="language-plaintext highlighter-rouge">'</code> and <code class="language-plaintext highlighter-rouge">\</code> again.</li>
  <li><strong>drawtext’s text expansion</strong>: <code class="language-plaintext highlighter-rouge">%</code> starts <code class="language-plaintext highlighter-rouge">%{...}</code>; <code class="language-plaintext highlighter-rouge">\</code> escapes.</li>
</ol>

<p>(The shell is a fourth if you type the command, which is why the escaped
<code class="language-plaintext highlighter-rouge">text=</code> versions you find online are hard to read.)</p>

<h2 id="if-you-must-use-text-escape-for-each-parser">If you must use <code class="language-plaintext highlighter-rouge">text=</code>: escape for each parser</h2>

<p>Escape in the reverse order the parsers read the text: drawtext first, then
the option value, then the filtergraph. This Python version passes the filter
as a list item to <code class="language-plaintext highlighter-rouge">subprocess.run</code>, so there’s no shell layer:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># escaped.py -- the text= route: escape for three parsers, in this order
</span><span class="kn">import</span> <span class="nn">subprocess</span>

<span class="n">FONT</span> <span class="o">=</span> <span class="s">"/System/Library/Fonts/AppleSDGothicNeo.ttc"</span>

<span class="k">def</span> <span class="nf">drawtext_escape</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
    <span class="n">s</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\\</span><span class="s">"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\\\</span><span class="s">"</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"%"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\</span><span class="s">%"</span><span class="p">)</span>              <span class="c1"># 1. drawtext's % expansion
</span>    <span class="n">s</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\\</span><span class="s">"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\\\</span><span class="s">"</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">":"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\</span><span class="s">:"</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"'"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\</span><span class="s">'"</span><span class="p">)</span>  <span class="c1"># 2. option value
</span>    <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="s">"</span><span class="se">\\</span><span class="s">'[],;"</span><span class="p">:</span>                                          <span class="c1"># 3. filtergraph
</span>        <span class="n">s</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="s">"</span><span class="se">\\</span><span class="s">"</span> <span class="o">+</span> <span class="n">c</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">s</span>

<span class="n">caption</span> <span class="o">=</span> <span class="s">"it's 오후 3:30, 할인 50% [끝]"</span>
<span class="n">vf</span> <span class="o">=</span> <span class="p">(</span><span class="sa">f</span><span class="s">"drawtext=fontfile=</span><span class="si">{</span><span class="n">FONT</span><span class="si">}</span><span class="s">:text=</span><span class="si">{</span><span class="n">drawtext_escape</span><span class="p">(</span><span class="n">caption</span><span class="p">)</span><span class="si">}</span><span class="s">"</span>
      <span class="s">":fontsize=48:fontcolor=white:x=20:y=50"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">vf</span><span class="p">)</span>
<span class="n">subprocess</span><span class="p">.</span><span class="n">run</span><span class="p">([</span><span class="s">"ffmpeg"</span><span class="p">,</span> <span class="s">"-hide_banner"</span><span class="p">,</span> <span class="s">"-loglevel"</span><span class="p">,</span> <span class="s">"error"</span><span class="p">,</span>
                <span class="s">"-f"</span><span class="p">,</span> <span class="s">"lavfi"</span><span class="p">,</span> <span class="s">"-i"</span><span class="p">,</span> <span class="s">"color=c=black:s=800x160:d=1"</span><span class="p">,</span>
                <span class="s">"-vf"</span><span class="p">,</span> <span class="n">vf</span><span class="p">,</span> <span class="s">"-frames:v"</span><span class="p">,</span> <span class="s">"1"</span><span class="p">,</span> <span class="s">"-y"</span><span class="p">,</span> <span class="s">"escaped.png"</span><span class="p">],</span> <span class="n">check</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 escaped.py
<span class="go">drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:text=it\\\'s 오후 3\\:30\, 할인 50\\\\% \[끝\]:fontsize=48:fontcolor=white:x=20:y=50
</span></code></pre></div></div>

<p>That rendered <code class="language-plaintext highlighter-rouge">it's 오후 3:30, 할인 50% [끝]</code> correctly, and so did
<code class="language-plaintext highlighter-rouge">"따옴표" C:\temp; a=b</code> (quotes, a backslash, a semicolon). It works, but look
at <code class="language-plaintext highlighter-rouge">50\\\\%</code>: four backslashes for one percent sign. It’s easy to get one layer
wrong, and the <code class="language-plaintext highlighter-rouge">%</code> mistake doesn’t raise an error.</p>

<h2 id="the-fix-textfile-plus-expansionnone">The fix: <code class="language-plaintext highlighter-rouge">textfile=</code> plus <code class="language-plaintext highlighter-rouge">expansion=none</code></h2>

<p>Write the caption to a file and give drawtext the file’s path. The filtergraph
and option parsers only see the path; the caption itself is never parsed by them.</p>

<p>But <code class="language-plaintext highlighter-rouge">textfile=</code> alone <strong>still fails on <code class="language-plaintext highlighter-rouge">%</code></strong>, because drawtext’s expansion also
runs on text read from the file:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"it's 오후 3:30, 할인 50% [끝]"</span> <span class="o">&gt;</span> caption.txt
<span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:textfile=caption.txt:fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 7-textfile.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[Parsed_drawtext_0 @ 0x600002720fd0] Stray % near ' [끝]'
exit 0
</span></code></pre></div></div>

<p>Black frame again. Add <code class="language-plaintext highlighter-rouge">expansion=none</code> and the text is drawn exactly as it is
in the file:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:textfile=caption.txt:expansion=none:fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 8-textfile-none.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">exit 0
</span></code></pre></div></div>

<p>The frame shows <code class="language-plaintext highlighter-rouge">it's 오후 3:30, 할인 50% [끝]</code>. (<code class="language-plaintext highlighter-rouge">expansion=none</code> also turns
off <code class="language-plaintext highlighter-rouge">%{pts}</code> and friends; if you need a timestamp in the same caption, that
needs <code class="language-plaintext highlighter-rouge">text=</code> and escaping.)</p>

<p>In a script, a temporary file per caption:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># textfile.py -- the textfile= route: no escaping of the caption at all
</span><span class="kn">import</span> <span class="nn">os</span><span class="p">,</span> <span class="n">subprocess</span><span class="p">,</span> <span class="n">tempfile</span>

<span class="n">FONT</span> <span class="o">=</span> <span class="s">"/System/Library/Fonts/AppleSDGothicNeo.ttc"</span>

<span class="k">def</span> <span class="nf">render</span><span class="p">(</span><span class="n">caption</span><span class="p">,</span> <span class="n">out</span><span class="p">):</span>
    <span class="n">fd</span><span class="p">,</span> <span class="n">path</span> <span class="o">=</span> <span class="n">tempfile</span><span class="p">.</span><span class="n">mkstemp</span><span class="p">(</span><span class="n">suffix</span><span class="o">=</span><span class="s">".txt"</span><span class="p">)</span>   <span class="c1"># e.g. /var/folders/.../T/tmpab12.txt
</span>    <span class="k">try</span><span class="p">:</span>
        <span class="k">with</span> <span class="n">os</span><span class="p">.</span><span class="n">fdopen</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">f</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">caption</span><span class="p">)</span>
        <span class="n">vf</span> <span class="o">=</span> <span class="p">(</span><span class="sa">f</span><span class="s">"drawtext=fontfile=</span><span class="si">{</span><span class="n">FONT</span><span class="si">}</span><span class="s">:textfile=</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s">:expansion=none"</span>
              <span class="s">":fontsize=48:fontcolor=white:x=20:y=50"</span><span class="p">)</span>
        <span class="n">subprocess</span><span class="p">.</span><span class="n">run</span><span class="p">([</span><span class="s">"ffmpeg"</span><span class="p">,</span> <span class="s">"-hide_banner"</span><span class="p">,</span> <span class="s">"-loglevel"</span><span class="p">,</span> <span class="s">"error"</span><span class="p">,</span>
                        <span class="s">"-f"</span><span class="p">,</span> <span class="s">"lavfi"</span><span class="p">,</span> <span class="s">"-i"</span><span class="p">,</span> <span class="s">"color=c=black:s=800x160:d=1"</span><span class="p">,</span>
                        <span class="s">"-vf"</span><span class="p">,</span> <span class="n">vf</span><span class="p">,</span> <span class="s">"-frames:v"</span><span class="p">,</span> <span class="s">"1"</span><span class="p">,</span> <span class="s">"-y"</span><span class="p">,</span> <span class="n">out</span><span class="p">],</span> <span class="n">check</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="n">os</span><span class="p">.</span><span class="n">remove</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>

<span class="n">render</span><span class="p">(</span><span class="s">"it's 오후 3:30, 할인 50% [끝]"</span><span class="p">,</span> <span class="s">"textfile.png"</span><span class="p">)</span>
<span class="n">render</span><span class="p">(</span><span class="s">'"따옴표" C:</span><span class="se">\\</span><span class="s">temp; a=b'</span><span class="p">,</span> <span class="s">"textfile2.png"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"ok"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 textfile.py
<span class="go">ok
</span></code></pre></div></div>

<p>Both frames showed the captions exactly, with no escaping code. Write the file
as UTF-8 (<code class="language-plaintext highlighter-rouge">encoding="utf-8"</code>). A trailing newline (<code class="language-plaintext highlighter-rouge">echo</code> instead of
<code class="language-plaintext highlighter-rouge">printf '%s'</code>) made no visible difference with this ffmpeg.</p>

<p><strong>The path is still parsed.</strong> The file name goes through the same filtergraph
and option parsers, so a <code class="language-plaintext highlighter-rouge">:</code> in it breaks the command the same way:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"안녕"</span> <span class="o">&gt;</span> <span class="s2">"a:b.txt"</span>
<span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>640x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=/System/Library/Fonts/AppleSDGothicNeo.ttc:textfile=a:b.txt:expansion=none:fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> colon.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[AVFilterGraph @ 0x6000005a0230] No option name near 'b.txt:expansion=none:fontsize=48:fontcolor=white:x=20:y=50'
</span><span class="c">...
</span><span class="go">exit 234
</span></code></pre></div></div>

<p>Use paths you control, like the ones <code class="language-plaintext highlighter-rouge">tempfile.mkstemp()</code> returns, rather than
a name built from the caption.</p>

<h2 id="zsh-trap-fonttext-is-not-what-you-typed">zsh trap: <code class="language-plaintext highlighter-rouge">$FONT:text</code> is not what you typed</h2>

<p>On macOS the default shell is zsh. Putting the font path in a variable and
writing <code class="language-plaintext highlighter-rouge">fontfile=$FONT:text=...</code> fails with a different error:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nv">FONT</span><span class="o">=</span>/System/Library/Fonts/AppleSDGothicNeo.ttc
<span class="gp">$</span><span class="w"> </span>ffmpeg <span class="nt">-hide_banner</span> <span class="nt">-loglevel</span> error <span class="nt">-f</span> lavfi <span class="nt">-i</span> <span class="nv">color</span><span class="o">=</span><span class="nv">c</span><span class="o">=</span>black:s<span class="o">=</span>800x160:d<span class="o">=</span>1 <span class="nt">-vf</span> <span class="s2">"drawtext=fontfile=</span><span class="nv">$FONT</span><span class="s2">:text='안녕하세요':fontsize=48:fontcolor=white:x=20:y=50"</span> <span class="nt">-frames</span>:v 1 <span class="nt">-y</span> 5-zsh.png<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">[Parsed_drawtext_0 @ 0x600003a84420] Either text, a valid file, a timecode or text source must be provided
[AVFilterGraph @ 0x600002684380] Error initializing filters
Error opening output file 5-zsh.png.
Error opening output files: Invalid argument
exit 234
</span></code></pre></div></div>

<p>zsh read <code class="language-plaintext highlighter-rouge">$FONT:t</code> as “the last path component of <code class="language-plaintext highlighter-rouge">$FONT</code>” (the <code class="language-plaintext highlighter-rouge">:t</code> modifier),
so ffmpeg received <code class="language-plaintext highlighter-rouge">fontfile=AppleSDGothicNeo.ttcext='안녕하세요'</code> — no font
path and no <code class="language-plaintext highlighter-rouge">text</code> option. Write <code class="language-plaintext highlighter-rouge">${FONT}:text=...</code> and it works (<code class="language-plaintext highlighter-rouge">exit 0</code>, text
drawn). bash doesn’t have this problem, but braces work in both.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>Korean shows as boxes: give drawtext <code class="language-plaintext highlighter-rouge">fontfile=</code> a font that has the glyphs
(<code class="language-plaintext highlighter-rouge">/System/Library/Fonts/AppleSDGothicNeo.ttc</code> on macOS).</li>
  <li><code class="language-plaintext highlighter-rouge">No option name near '...'</code> with a caption: a <code class="language-plaintext highlighter-rouge">:</code>, <code class="language-plaintext highlighter-rouge">'</code> or <code class="language-plaintext highlighter-rouge">,</code> in the text
ended the option or the filter early.</li>
  <li><code class="language-plaintext highlighter-rouge">Stray % near ...</code> with exit 0: the whole caption was dropped. Don’t rely on
the exit code; look at a frame.</li>
  <li>Fix: write the caption to a file and use <code class="language-plaintext highlighter-rouge">textfile=&lt;path&gt;:expansion=none</code>.
<code class="language-plaintext highlighter-rouge">textfile=</code> without <code class="language-plaintext highlighter-rouge">expansion=none</code> still fails on <code class="language-plaintext highlighter-rouge">%</code>.</li>
  <li>Keep the text file’s path free of <code class="language-plaintext highlighter-rouge">:</code>, <code class="language-plaintext highlighter-rouge">'</code>, <code class="language-plaintext highlighter-rouge">,</code> (a temp file is fine).</li>
  <li>If you need <code class="language-plaintext highlighter-rouge">text=</code>, escape for drawtext, then the option, then the
filtergraph, and pass the filter as one argv item (no shell).</li>
  <li>In zsh, write <code class="language-plaintext highlighter-rouge">${VAR}:</code> before a colon, not <code class="language-plaintext highlighter-rouge">$VAR:</code>.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with ffmpeg 7.1, the static arm64
build shipped in the imageio-ffmpeg 0.6.0 Python package (built with
libfreetype, libharfbuzz and fontconfig), from zsh; Python scripts run with
Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>). The font-fallback result is specific to
that build; filter addresses like <code class="language-plaintext highlighter-rouge">0x6000039ec000</code> differ between runs.</em></p>]]></content><author><name></name></author><category term="ffmpeg" /><category term="macos" /><category term="video" /><category term="python" /><summary type="html"><![CDATA[Burning a caption with ffmpeg's drawtext failed as soon as the text had an apostrophe, a colon or a comma ("No option name near '30:fontsize=48...'"), and a % made ffmpeg exit 0 with no text at all ("Stray % near ''"). Korean came out as boxes until fontfile= pointed at a Korean font. The text goes through three parsers; textfile= skips two of them, and expansion=none skips the third. Also: zsh eats "$FONT:text" as a :t modifier.]]></summary></entry><entry><title type="html">Python script hangs at 0% CPU on macOS: find where with `sample` (and why spindump says it must be run as root)</title><link href="https://hanminsoo.github.io/2026/10/python-hangs-0-cpu-macos-sample-subprocess-pipe/" rel="alternate" type="text/html" title="Python script hangs at 0% CPU on macOS: find where with `sample` (and why spindump says it must be run as root)" /><published>2026-10-18T00:00:00+00:00</published><updated>2026-10-18T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/python-hangs-0-cpu-macos-sample-subprocess-pipe</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/python-hangs-0-cpu-macos-sample-subprocess-pipe/"><![CDATA[<p>A Python script <strong>hangs forever at 0% CPU</strong>: no output, no traceback, nothing
in the log. <code class="language-plaintext highlighter-rouge">ps</code> shows it alive and idle. You want to know <em>where</em> it’s
waiting without adding prints and restarting it.</p>

<p>On macOS you don’t need to install anything for the first look: <code class="language-plaintext highlighter-rouge">/usr/bin/sample</code>
attaches to a running process for a few seconds and prints where every thread
is. Below I use it on a hang I reproduced on my Mac — a subprocess pipe
deadlock — and show what it can and can’t tell you, plus what happens when you
reach for <code class="language-plaintext highlighter-rouge">spindump</code> instead.</p>

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

<p>The script starts a child process, captures its stdout through a pipe, and
waits for it to finish before reading:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># stuck.py -- waits for a child that is blocked writing to a full pipe
</span><span class="kn">import</span> <span class="nn">subprocess</span><span class="p">,</span> <span class="n">sys</span>
<span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">Popen</span><span class="p">(</span>
    <span class="p">[</span><span class="n">sys</span><span class="p">.</span><span class="n">executable</span><span class="p">,</span> <span class="s">"-c"</span><span class="p">,</span> <span class="s">"print('x' * 200_000)"</span><span class="p">],</span>
    <span class="n">stdout</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">p</span><span class="p">.</span><span class="n">wait</span><span class="p">()</span>          <span class="c1"># never returns: nobody reads the pipe
</span><span class="k">print</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">read</span><span class="p">()))</span>
</code></pre></div></div>

<p>Run it in the background so you get its PID, then look at it:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 stuck.py &amp;
<span class="gp">$</span><span class="w"> </span>ps <span class="nt">-o</span> pid,ppid,%cpu,time <span class="nt">-p</span> 52520
<span class="go">  PID  PPID  %CPU      TIME
52520 52518   0.0   0:00.03
</span><span class="gp">$</span><span class="w"> </span>pgrep <span class="nt">-P</span> 52520
<span class="go">52522
</span></code></pre></div></div>

<p>Alive, 0% CPU, 30 ms of CPU time in total, and it has one child (<code class="language-plaintext highlighter-rouge">52522</code>).
Nothing is spinning; something is waiting.</p>

<h2 id="step-1-sample-the-stuck-process">Step 1: <code class="language-plaintext highlighter-rouge">sample</code> the stuck process</h2>

<p><code class="language-plaintext highlighter-rouge">sample &lt;pid&gt; &lt;seconds&gt;</code> takes a stack snapshot of every thread about once per
millisecond. Use <code class="language-plaintext highlighter-rouge">-file</code> so the report lands where you want it (without
<code class="language-plaintext highlighter-rouge">-file</code>, it printed the report to the terminal <em>and</em> saved a copy as
<code class="language-plaintext highlighter-rouge">/tmp/Python_&lt;date&gt;_&lt;id&gt;.sample.txt</code> on my Mac):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>sample 52520 1 <span class="nt">-file</span> parent.txt
<span class="go">Sampling process 52520 for 1 second with 1 millisecond of run time between samples
Sampling completed, processing symbols...
Sample analysis of process 52520 written to file parent.txt
</span><span class="gp">$</span><span class="w"> </span><span class="nb">grep</span> <span class="nt">-A1</span> <span class="s2">"Sort by top of stack"</span> parent.txt
<span class="gp">Sort by top of stack, same collapsed (when &gt;</span><span class="o">=</span> 5<span class="o">)</span>:
<span class="go">        __wait4  (in libsystem_kernel.dylib)        775
</span></code></pre></div></div>

<p>The “Sort by top of stack” section at the bottom of the report is the fastest
thing to read: it counts which function was on top of the stack in each
sample. Here <strong>all 775 samples</strong> were in <code class="language-plaintext highlighter-rouge">__wait4</code> — the process is waiting
for a child to exit. The call graph above it shows how it got there (address
columns trimmed):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">                                                    775 os_waitpid  (in Python3) + 92
                                                      775 __wait4  (in libsystem_kernel.dylib) + 8
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">os_waitpid</code> is the C function behind <code class="language-plaintext highlighter-rouge">os.waitpid()</code>, which <code class="language-plaintext highlighter-rouge">Popen.wait()</code>
calls. So: the parent is in <code class="language-plaintext highlighter-rouge">wait()</code>, waiting for the child. Now sample the
child:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>sample 52522 1 <span class="nt">-file</span> child.txt
<span class="go">Sampling process 52522 for 1 second with 1 millisecond of run time between samples
Sampling completed, processing symbols...
Sample analysis of process 52522 written to file child.txt
</span><span class="gp">$</span><span class="w"> </span><span class="nb">grep</span> <span class="nt">-A1</span> <span class="s2">"Sort by top of stack"</span> child.txt
<span class="gp">Sort by top of stack, same collapsed (when &gt;</span><span class="o">=</span> 5<span class="o">)</span>:
<span class="go">        write  (in libsystem_kernel.dylib)        778
</span></code></pre></div></div>

<p>The child is stuck in <code class="language-plaintext highlighter-rouge">write</code> (its call graph runs from <code class="language-plaintext highlighter-rouge">builtin_print</code> down
through <code class="language-plaintext highlighter-rouge">_io_FileIO_write</code> to <code class="language-plaintext highlighter-rouge">write</code>). It is trying to print, and the write
doesn’t complete.</p>

<p>That’s the whole deadlock: <strong>the child waits for someone to read the pipe, and
the parent waits for the child to exit.</strong> A pipe has a fixed buffer; once the
child has filled it, its next <code class="language-plaintext highlighter-rouge">write()</code> blocks until the other end reads.
<code class="language-plaintext highlighter-rouge">stuck.py</code> doesn’t read until after <code class="language-plaintext highlighter-rouge">wait()</code> returns, which it never does.</p>

<h2 id="how-big-is-the-pipe">How big is the pipe?</h2>

<p>On my Mac the child finished as long as it wrote at most 65,536 bytes, and hung
at 65,537. This script checks a few sizes with a timeout so it can’t hang
itself:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># pipesize.py -- does the child finish if nobody reads its stdout?
</span><span class="kn">import</span> <span class="nn">subprocess</span><span class="p">,</span> <span class="n">sys</span>
<span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="p">(</span><span class="mi">16_384</span><span class="p">,</span> <span class="mi">65_535</span><span class="p">,</span> <span class="mi">65_536</span><span class="p">,</span> <span class="mi">65_537</span><span class="p">,</span> <span class="mi">200_000</span><span class="p">):</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">Popen</span><span class="p">(</span>
        <span class="p">[</span><span class="n">sys</span><span class="p">.</span><span class="n">executable</span><span class="p">,</span> <span class="s">"-c"</span><span class="p">,</span> <span class="sa">f</span><span class="s">"import sys; sys.stdout.write('x' * </span><span class="si">{</span><span class="n">n</span><span class="si">}</span><span class="s">)"</span><span class="p">],</span>
        <span class="n">stdout</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">p</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="mi">3</span><span class="p">)</span>
        <span class="k">print</span><span class="p">(</span><span class="n">n</span><span class="p">,</span> <span class="s">"finished"</span><span class="p">)</span>
    <span class="k">except</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">TimeoutExpired</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="n">n</span><span class="p">,</span> <span class="s">"stuck"</span><span class="p">)</span>
        <span class="n">p</span><span class="p">.</span><span class="n">kill</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">communicate</span><span class="p">()</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 pipesize.py
<span class="go">16384 finished
65535 finished
65536 finished
65537 stuck
200000 stuck
</span></code></pre></div></div>

<p>Same result with Python 3.12. This is why the bug hides in testing: with small
output, <code class="language-plaintext highlighter-rouge">wait()</code> then <code class="language-plaintext highlighter-rouge">read()</code> works fine; it only hangs when the child’s output
grows past 64 KiB.</p>

<h2 id="why-not-spindump">Why not spindump?</h2>

<p><code class="language-plaintext highlighter-rouge">spindump</code> is the other built-in tool people suggest for hangs (it samples user
<em>and</em> kernel stacks). Run as a normal user, it stopped immediately:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>/usr/sbin/spindump 52520 1 <span class="nt">-file</span> spin.txt
<span class="go">spindump must be run as root when sampling the live system
</span><span class="gp">$</span><span class="w"> </span><span class="nb">echo</span> <span class="nv">$?</span>
<span class="go">77
</span></code></pre></div></div>

<p>(It lives in <code class="language-plaintext highlighter-rouge">/usr/sbin</code>; a normal login shell has that on <code class="language-plaintext highlighter-rouge">PATH</code>, so plain
<code class="language-plaintext highlighter-rouge">spindump</code> gives the same message.) I didn’t run it with <code class="language-plaintext highlighter-rouge">sudo</code>, so I
can’t show its output here. For a process you own, <code class="language-plaintext highlighter-rouge">sample</code> needs no root and
was enough to find this hang.</p>

<h2 id="what-sample-cant-tell-you-the-python-line">What <code class="language-plaintext highlighter-rouge">sample</code> can’t tell you: the Python line</h2>

<p><code class="language-plaintext highlighter-rouge">sample</code> shows C frames: <code class="language-plaintext highlighter-rouge">_PyEval_EvalFrameDefault</code>, <code class="language-plaintext highlighter-rouge">call_function</code>,
<code class="language-plaintext highlighter-rouge">os_waitpid</code>. It doesn’t say which line of <em>your</em> script is waiting. For that,
let Python print its own stack on a signal with <code class="language-plaintext highlighter-rouge">faulthandler</code> — two lines at the
top of the script:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># stuck_fh.py -- same hang, but `kill -USR1 &lt;pid&gt;` prints the Python stack
</span><span class="kn">import</span> <span class="nn">faulthandler</span><span class="p">,</span> <span class="n">signal</span><span class="p">,</span> <span class="n">subprocess</span><span class="p">,</span> <span class="n">sys</span>
<span class="n">faulthandler</span><span class="p">.</span><span class="n">register</span><span class="p">(</span><span class="n">signal</span><span class="p">.</span><span class="n">SIGUSR1</span><span class="p">)</span>

<span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">Popen</span><span class="p">(</span>
    <span class="p">[</span><span class="n">sys</span><span class="p">.</span><span class="n">executable</span><span class="p">,</span> <span class="s">"-c"</span><span class="p">,</span> <span class="s">"print('x' * 200_000)"</span><span class="p">],</span>
    <span class="n">stdout</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">p</span><span class="p">.</span><span class="n">wait</span><span class="p">()</span>          <span class="c1"># never returns: nobody reads the pipe
</span><span class="k">print</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">read</span><span class="p">()))</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 stuck_fh.py 2&gt;fh.err &amp;
<span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nt">-USR1</span> 52343
<span class="gp">$</span><span class="w"> </span><span class="nb">cat </span>fh.err
<span class="go">Current thread 0x00000001f399dd80 (most recent call first):
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 1762 in _try_wait
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 1804 in _wait
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 1079 in wait
</span><span class="gp">  File "stuck_fh.py", line 9 in &lt;module&gt;</span><span class="w">
</span></code></pre></div></div>

<p>Line 9 is <code class="language-plaintext highlighter-rouge">p.wait()</code>. The process keeps running after the dump (SIGUSR1 doesn’t
stop it), so you can do this to a long-running service. It only works if the
handler was registered <em>before</em> the hang, so it’s worth adding to scripts that
run unattended.</p>

<h2 id="stopping-it">Stopping it</h2>

<p>Kill the parent by the PID you got when you started it:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill </span>52520
<span class="go">Traceback (most recent call last):
</span><span class="gp">  File "&lt;string&gt;</span><span class="s2">", line 1, in &lt;module&gt;
</span><span class="go">BrokenPipeError: [Errno 32] Broken pipe
</span></code></pre></div></div>

<p>The traceback is from the child: when the parent died, the read end of the pipe
closed, the child’s blocked <code class="language-plaintext highlighter-rouge">write</code> failed, and the child exited too (<code class="language-plaintext highlighter-rouge">ps -p</code>
found neither PID afterwards). Kill by PID rather than by process name: a
name pattern also matches every other Python process on the machine, including
services you didn’t mean to stop.</p>

<h2 id="the-fix-read-while-you-wait">The fix: read while you wait</h2>

<p>Use <code class="language-plaintext highlighter-rouge">communicate()</code>, which reads stdout (and stderr) while waiting for the
child to exit:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># fixed.py -- communicate() reads the pipe while waiting
</span><span class="kn">import</span> <span class="nn">subprocess</span><span class="p">,</span> <span class="n">sys</span>
<span class="n">p</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">Popen</span><span class="p">(</span>
    <span class="p">[</span><span class="n">sys</span><span class="p">.</span><span class="n">executable</span><span class="p">,</span> <span class="s">"-c"</span><span class="p">,</span> <span class="s">"print('x' * 200_000)"</span><span class="p">],</span>
    <span class="n">stdout</span><span class="o">=</span><span class="n">subprocess</span><span class="p">.</span><span class="n">PIPE</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">out</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">communicate</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">out</span><span class="p">),</span> <span class="s">"exit"</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">returncode</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 fixed.py
<span class="go">200001 exit 0
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">subprocess.run(..., capture_output=True)</code> uses <code class="language-plaintext highlighter-rouge">communicate()</code> internally and
behaves the same way:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-c</span> <span class="s2">"import subprocess,sys; r=subprocess.run([sys.executable,'-c',</span><span class="se">\"</span><span class="s2">print('x' * 200_000)</span><span class="se">\"</span><span class="s2">], capture_output=True, timeout=30); print(len(r.stdout), 'exit', r.returncode)"</span>
<span class="go">200001 exit 0
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">timeout=</code> doesn’t fix the deadlock — <code class="language-plaintext highlighter-rouge">communicate()</code> does — but it turns
any future hang (a child that really never exits) into an exception instead
of a silent 0% CPU process.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>A process hangs at 0% CPU: <code class="language-plaintext highlighter-rouge">sample &lt;pid&gt; 1 -file out.txt</code>, then read
“Sort by top of stack” at the bottom.</li>
  <li><code class="language-plaintext highlighter-rouge">__wait4</code> on top means it’s waiting for a child process. <code class="language-plaintext highlighter-rouge">pgrep -P &lt;pid&gt;</code>
and sample the child too.</li>
  <li>Child in <code class="language-plaintext highlighter-rouge">write</code> + parent in <code class="language-plaintext highlighter-rouge">wait4</code> = pipe deadlock. Replace
<code class="language-plaintext highlighter-rouge">Popen(..., stdout=PIPE)</code> + <code class="language-plaintext highlighter-rouge">wait()</code> with <code class="language-plaintext highlighter-rouge">communicate()</code> or
<code class="language-plaintext highlighter-rouge">subprocess.run(capture_output=True)</code>.</li>
  <li>It only shows up once the output passes the pipe buffer (64 KiB on my Mac),
so small test runs pass.</li>
  <li><code class="language-plaintext highlighter-rouge">spindump</code> needs root on a live system; <code class="language-plaintext highlighter-rouge">sample</code> doesn’t for your own
processes.</li>
  <li>To get the Python line, register <code class="language-plaintext highlighter-rouge">faulthandler</code> on <code class="language-plaintext highlighter-rouge">SIGUSR1</code> and
<code class="language-plaintext highlighter-rouge">kill -USR1 &lt;pid&gt;</code>.</li>
  <li>Stop the hung process by its PID, not by a name pattern.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>,
Command Line Tools); the pipe-size check and the fix were also run on Python
3.12.13 (Homebrew). <code class="language-plaintext highlighter-rouge">sample</code> and <code class="language-plaintext highlighter-rouge">spindump</code> are the ones shipped with that macOS.
spindump was not run as root. PIDs and the faulthandler output are from
separate runs, so the PIDs differ.</em></p>]]></content><author><name></name></author><category term="python" /><category term="macos" /><category term="debugging" /><category term="subprocess" /><summary type="html"><![CDATA[A Python script that runs a subprocess stopped doing anything: no CPU, no output, no error. macOS's built-in `sample` showed the parent stuck in __wait4 and the child stuck in write — the classic subprocess pipe deadlock once the child prints more than 64 KiB. spindump refused without root ("spindump must be run as root when sampling the live system"). Fixed with communicate(); faulthandler gives the Python line.]]></summary></entry><entry><title type="html">PWA shows a blank page offline after a deploy: the service worker never cached the new hashed bundle</title><link href="https://hanminsoo.github.io/2026/10/pwa-blank-offline-after-deploy-service-worker-precache/" rel="alternate" type="text/html" title="PWA shows a blank page offline after a deploy: the service worker never cached the new hashed bundle" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/pwa-blank-offline-after-deploy-service-worker-precache</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/pwa-blank-offline-after-deploy-service-worker-precache/"><![CDATA[<p>Your PWA works offline. You deploy a new version, open the app once while
online (it still shows the old version, which is normal), and later open it
offline: <strong>the page is blank</strong>. The HTML comes from the service worker, but
the request for <code class="language-plaintext highlighter-rouge">/assets/app.&lt;hash&gt;.js</code> fails with <code class="language-plaintext highlighter-rouge">net::ERR_FAILED</code>.
In my test, offline worked once the page had been reloaded online at least
once (the “day one” step below), because the reload caches the bundle.</p>

<p>I reproduced this on my Mac with headless Chromium. “Offline” in this post
means I stopped the local server, so only the service worker’s caches could
answer; I didn’t test with the network actually cut (Wi-Fi off, airplane
mode). The service worker cached <code class="language-plaintext highlighter-rouge">/</code> at install and cached everything else
“on the way through” the first time it was requested. After a deploy the new
service worker caches the <strong>new</strong> <code class="language-plaintext highlighter-rouge">index.html</code>, which points at a <strong>new</strong>
hashed bundle name. Nothing has requested that file yet, so it isn’t in any
cache, and the activate step has just deleted the old cache that held the old
bundle. The cached page refers to a script that only exists on the server.</p>

<h2 id="reproduce-it">Reproduce it</h2>

<p>Four small files in an empty folder. <code class="language-plaintext highlighter-rouge">build.py</code> plays the role of a deploy: it
replaces <code class="language-plaintext highlighter-rouge">site/</code> with an <code class="language-plaintext highlighter-rouge">index.html</code>, one content-hashed bundle and <code class="language-plaintext highlighter-rouge">sw.js</code>.
With <code class="language-plaintext highlighter-rouge">--precache</code> the bundle goes into the service worker’s precache list;
without it only <code class="language-plaintext highlighter-rouge">/</code> does.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># build.py — "deploy" a tiny app into site/: python3 build.py v1 [--precache]
</span><span class="kn">import</span> <span class="nn">hashlib</span><span class="p">,</span> <span class="n">json</span><span class="p">,</span> <span class="n">pathlib</span><span class="p">,</span> <span class="n">shutil</span><span class="p">,</span> <span class="n">sys</span>

<span class="n">version</span> <span class="o">=</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
<span class="n">precache_bundles</span> <span class="o">=</span> <span class="s">"--precache"</span> <span class="ow">in</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span>
<span class="n">site</span> <span class="o">=</span> <span class="n">pathlib</span><span class="p">.</span><span class="n">Path</span><span class="p">(</span><span class="s">"site"</span><span class="p">)</span>
<span class="n">shutil</span><span class="p">.</span><span class="n">rmtree</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">ignore_errors</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>        <span class="c1"># a deploy replaces the old files
</span><span class="p">(</span><span class="n">site</span> <span class="o">/</span> <span class="s">"assets"</span><span class="p">).</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="n">js</span> <span class="o">=</span> <span class="sa">f</span><span class="s">'document.getElementById("root").textContent = "notes app </span><span class="si">{</span><span class="n">version</span><span class="si">}</span><span class="s">";</span><span class="se">\n</span><span class="s">'</span>
<span class="n">name</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"app.</span><span class="si">{</span><span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">js</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">hexdigest</span><span class="p">()[</span><span class="si">:</span><span class="mi">8</span><span class="p">]</span><span class="si">}</span><span class="s">.js"</span>
<span class="p">(</span><span class="n">site</span> <span class="o">/</span> <span class="s">"assets"</span> <span class="o">/</span> <span class="n">name</span><span class="p">).</span><span class="n">write_text</span><span class="p">(</span><span class="n">js</span><span class="p">)</span>
<span class="n">bundles</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="s">"/assets/</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s">"</span><span class="p">]</span>

<span class="p">(</span><span class="n">site</span> <span class="o">/</span> <span class="s">"index.html"</span><span class="p">).</span><span class="n">write_text</span><span class="p">(</span><span class="sa">f</span><span class="s">"""&lt;!doctype html&gt;
&lt;title&gt;notes&lt;/title&gt;
&lt;div id="root"&gt;&lt;/div&gt;
&lt;script src="/assets/</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s">"&gt;&lt;/script&gt;
&lt;script&gt;navigator.serviceWorker.register("/sw.js");&lt;/script&gt;
"""</span><span class="p">)</span>

<span class="n">precache</span> <span class="o">=</span> <span class="p">[</span><span class="s">"/"</span><span class="p">]</span> <span class="o">+</span> <span class="p">(</span><span class="n">bundles</span> <span class="k">if</span> <span class="n">precache_bundles</span> <span class="k">else</span> <span class="p">[])</span>
<span class="n">sw</span> <span class="o">=</span> <span class="n">pathlib</span><span class="p">.</span><span class="n">Path</span><span class="p">(</span><span class="s">"sw.template.js"</span><span class="p">).</span><span class="n">read_text</span><span class="p">()</span>
<span class="n">sw</span> <span class="o">=</span> <span class="n">sw</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"__VERSION__"</span><span class="p">,</span> <span class="n">version</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"__PRECACHE__"</span><span class="p">,</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">precache</span><span class="p">))</span>
<span class="p">(</span><span class="n">site</span> <span class="o">/</span> <span class="s">"sw.js"</span><span class="p">).</span><span class="n">write_text</span><span class="p">(</span><span class="n">sw</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">version</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="s">"precache:"</span><span class="p">,</span> <span class="n">precache</span><span class="p">)</span>
</code></pre></div></div>

<p>The service worker is a common cache-first pattern: precache a list at install,
delete other versions’ caches at activate, and cache anything else the first
time it’s fetched:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// sw.template.js</span>
<span class="kd">const</span> <span class="nx">CACHE</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">app-__VERSION__</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">PRECACHE</span> <span class="o">=</span> <span class="nx">__PRECACHE__</span><span class="p">;</span>

<span class="nb">self</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">install</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">waitUntil</span><span class="p">(</span>
    <span class="nx">caches</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">CACHE</span><span class="p">)</span>
      <span class="p">.</span><span class="nx">then</span><span class="p">((</span><span class="nx">c</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">addAll</span><span class="p">(</span><span class="nx">PRECACHE</span><span class="p">))</span>
      <span class="p">.</span><span class="nx">then</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nb">self</span><span class="p">.</span><span class="nx">skipWaiting</span><span class="p">())</span>
  <span class="p">);</span>
<span class="p">});</span>

<span class="nb">self</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">activate</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">waitUntil</span><span class="p">(</span>
    <span class="nx">caches</span><span class="p">.</span><span class="nx">keys</span><span class="p">()</span>
      <span class="p">.</span><span class="nx">then</span><span class="p">((</span><span class="nx">keys</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">all</span><span class="p">(</span><span class="nx">keys</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">k</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">k</span> <span class="o">!==</span> <span class="nx">CACHE</span><span class="p">).</span><span class="nx">map</span><span class="p">((</span><span class="nx">k</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">caches</span><span class="p">.</span><span class="k">delete</span><span class="p">(</span><span class="nx">k</span><span class="p">))))</span>
      <span class="p">.</span><span class="nx">then</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nb">self</span><span class="p">.</span><span class="nx">clients</span><span class="p">.</span><span class="nx">claim</span><span class="p">())</span>
  <span class="p">);</span>
<span class="p">});</span>

<span class="c1">// cache first; anything not cached yet is fetched and cached on the way through</span>
<span class="nb">self</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">fetch</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">request</span><span class="p">.</span><span class="nx">method</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">GET</span><span class="dl">"</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">respondWith</span><span class="p">(</span>
    <span class="nx">caches</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">request</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">hit</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">hit</span> <span class="o">||</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">request</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">copy</span> <span class="o">=</span> <span class="nx">res</span><span class="p">.</span><span class="nx">clone</span><span class="p">();</span>
        <span class="nx">caches</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">CACHE</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">c</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">put</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">request</span><span class="p">,</span> <span class="nx">copy</span><span class="p">));</span>
      <span class="p">}</span>
      <span class="k">return</span> <span class="nx">res</span><span class="p">;</span>
    <span class="p">}))</span>
  <span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>A static server on <code class="language-plaintext highlighter-rouge">127.0.0.1</code> only (service workers are allowed on
<code class="language-plaintext highlighter-rouge">http://127.0.0.1</code> without HTTPS). It sends <code class="language-plaintext highlighter-rouge">Cache-Control: no-cache</code> so the
browser’s HTTP cache doesn’t hide the effect:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server.py — serve site/ on 127.0.0.1:8771, always revalidate
</span><span class="kn">import</span> <span class="nn">functools</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">ThreadingHTTPServer</span><span class="p">,</span> <span class="n">SimpleHTTPRequestHandler</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">SimpleHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">end_headers</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Cache-Control"</span><span class="p">,</span> <span class="s">"no-cache"</span><span class="p">)</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">end_headers</span><span class="p">()</span>
    <span class="k">def</span> <span class="nf">log_message</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
        <span class="k">pass</span>

<span class="n">handler</span> <span class="o">=</span> <span class="n">functools</span><span class="p">.</span><span class="n">partial</span><span class="p">(</span><span class="n">Handler</span><span class="p">,</span> <span class="n">directory</span><span class="o">=</span><span class="s">"site"</span><span class="p">)</span>
<span class="n">ThreadingHTTPServer</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8771</span><span class="p">),</span> <span class="n">handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>And a Playwright script that opens the app in a <strong>persistent</strong> headless
Chromium profile (<code class="language-plaintext highlighter-rouge">profile/</code>), so the service worker and its caches survive
from one run to the next, like a real browser. It prints what’s in <code class="language-plaintext highlighter-rouge">#root</code>
and what’s in each cache. “Offline” here means the server is stopped, so
nothing answers on <code class="language-plaintext highlighter-rouge">127.0.0.1:8771</code> and only the service worker’s caches can
serve the page (why not Playwright’s offline mode: see below).</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// visit.mjs — open the app in a persistent headless Chromium profile</span>
<span class="c1">// node visit.mjs            online: load once, wait for the newest service worker</span>
<span class="c1">// node visit.mjs --reload   same, then reload once</span>
<span class="c1">// node visit.mjs --offline  one load, no waiting (stop the server first)</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">chromium</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">playwright</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">offline</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">argv</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="dl">"</span><span class="s2">--offline</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">chromium</span><span class="p">.</span><span class="nx">launchPersistentContext</span><span class="p">(</span><span class="dl">"</span><span class="s2">profile</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">headless</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">pages</span><span class="p">()[</span><span class="mi">0</span><span class="p">]</span> <span class="o">??</span> <span class="k">await</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">newPage</span><span class="p">();</span>
<span class="nx">page</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">requestfailed</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">  failed:</span><span class="dl">"</span><span class="p">,</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">url</span><span class="p">()).</span><span class="nx">pathname</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">failure</span><span class="p">().</span><span class="nx">errorText</span><span class="p">));</span>

<span class="kd">const</span> <span class="nx">show</span> <span class="o">=</span> <span class="k">async</span> <span class="p">(</span><span class="nx">label</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">root</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">textContent</span><span class="p">(</span><span class="dl">"</span><span class="s2">#root</span><span class="dl">"</span><span class="p">).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="dl">"</span><span class="s2">(no page)</span><span class="dl">"</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">keys</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="p">[];</span>
    <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">k</span> <span class="k">of</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">keys</span><span class="p">())</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">reqs</span> <span class="o">=</span> <span class="k">await</span> <span class="p">(</span><span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">k</span><span class="p">)).</span><span class="nx">keys</span><span class="p">();</span>
      <span class="nx">out</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">k</span><span class="p">}</span><span class="s2">: </span><span class="p">${</span><span class="nx">reqs</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">url</span><span class="p">).</span><span class="nx">pathname</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2"> </span><span class="dl">"</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nx">out</span><span class="p">;</span>
  <span class="p">}).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">[]);</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">label</span><span class="p">}</span><span class="s2">: #root = </span><span class="p">${</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">root</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">k</span> <span class="k">of</span> <span class="nx">keys</span><span class="p">)</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">  cache</span><span class="dl">"</span><span class="p">,</span> <span class="nx">k</span><span class="p">);</span>
<span class="p">};</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">offline</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">http://127.0.0.1:8771/</span><span class="dl">"</span><span class="p">).</span><span class="k">catch</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">  goto:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">e</span><span class="p">.</span><span class="nx">message</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">)[</span><span class="mi">0</span><span class="p">]));</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">show</span><span class="p">(</span><span class="dl">"</span><span class="s2">offline</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="dl">"</span><span class="s2">http://127.0.0.1:8771/</span><span class="dl">"</span><span class="p">);</span>
  <span class="c1">// wait until the newest sw.js is installed and controlling the page</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">reg</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">navigator</span><span class="p">.</span><span class="nx">serviceWorker</span><span class="p">.</span><span class="nx">ready</span><span class="p">;</span>
    <span class="k">await</span> <span class="nx">reg</span><span class="p">.</span><span class="nx">update</span><span class="p">();</span>
    <span class="k">while</span> <span class="p">(</span><span class="nx">reg</span><span class="p">.</span><span class="nx">installing</span> <span class="o">||</span> <span class="nx">reg</span><span class="p">.</span><span class="nx">waiting</span> <span class="o">||</span> <span class="o">!</span><span class="nb">navigator</span><span class="p">.</span><span class="nx">serviceWorker</span><span class="p">.</span><span class="nx">controller</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">100</span><span class="p">));</span>
    <span class="p">}</span>
    <span class="c1">// give activate up to 3 s to delete the previous version's cache</span>
    <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="mi">30</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">keys</span><span class="p">()).</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">100</span><span class="p">));</span>
    <span class="p">}</span>
  <span class="p">});</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
  <span class="k">await</span> <span class="nx">show</span><span class="p">(</span><span class="dl">"</span><span class="s2">online</span><span class="dl">"</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">argv</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="dl">"</span><span class="s2">--reload</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">reload</span><span class="p">();</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
    <span class="k">await</span> <span class="nx">show</span><span class="p">(</span><span class="dl">"</span><span class="s2">online, reload</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="k">await</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
</code></pre></div></div>

<p>Install Playwright (<code class="language-plaintext highlighter-rouge">npx playwright install chromium</code> downloads the browser
if you don’t have it; I already had it), then start the server and keep its
PID — you’ll stop and start it to go “offline” and back:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>npm init <span class="nt">-y</span> <span class="o">&gt;</span>/dev/null <span class="o">&amp;&amp;</span> npm i playwright <span class="o">&amp;&amp;</span> npx playwright <span class="nb">install </span>chromium
<span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
</code></pre></div></div>

<p>Day one: deploy v1, open the app and reload once (so the bundle gets cached on
the way through), then stop the server and open it again. Everything works:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 build.py v1 <span class="o">&amp;&amp;</span> node visit.mjs <span class="nt">--reload</span>
<span class="go">v1 app.bbd8455b.js precache: ['/']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: /
</span><span class="gp">online, reload: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: / /assets/app.bbd8455b.js
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: / /assets/app.bbd8455b.js
</span></code></pre></div></div>

<p>Deploy v2, open the app <strong>once</strong> while online, then go offline:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 build.py v2 <span class="o">&amp;&amp;</span> node visit.mjs
<span class="go">v2 app.05452931.js precache: ['/']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v2: /
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="go">  failed: /assets/app.05452931.js net::ERR_FAILED
</span><span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">""</span>
<span class="go">  cache app-v2: /
</span></code></pre></div></div>

<p>That’s the bug. The online visit still showed v1 (the page was served from the
old cache before the new worker took over). The new worker cached the v2
<code class="language-plaintext highlighter-rouge">index.html</code>, and activate deleted <code class="language-plaintext highlighter-rouge">app-v1</code> with the only bundle in it. Offline,
the v2 HTML asks for <code class="language-plaintext highlighter-rouge">app.05452931.js</code>, which no cache has, and <code class="language-plaintext highlighter-rouge">#root</code> stays
empty. In a real app that’s a white screen. I ran the same deploy sequence
again from a fresh profile: blank in 5 of 5 runs.</p>

<p>It’s not only deploys. On a <strong>first visit</strong> the bundle loads before the service
worker exists, so it isn’t cached either. With a fresh profile, opening v1 once
and then going offline was blank too (one run):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> <span class="nt">-rf</span> profile
<span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 build.py v1 <span class="o">&amp;&amp;</span> node visit.mjs
<span class="go">v1 app.bbd8455b.js precache: ['/']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: /
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="go">  failed: /assets/app.bbd8455b.js net::ERR_FAILED
</span><span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">""</span>
<span class="go">  cache app-v1: /
</span></code></pre></div></div>

<h3 id="why-i-stopped-the-server-instead-of-using-setoffline">Why I stopped the server instead of using <code class="language-plaintext highlighter-rouge">setOffline</code></h3>

<p>My first version of <code class="language-plaintext highlighter-rouge">visit.mjs</code> used Playwright’s <code class="language-plaintext highlighter-rouge">context.setOffline(true)</code>.
In 1 of 6 runs of the deploy sequence, the offline load <strong>worked</strong> and the
v2 bundle showed up in the cache afterwards. I didn’t find out why; my guess
is that the emulation didn’t block the service worker’s own <code class="language-plaintext highlighter-rouge">fetch()</code> that
time, but I haven’t confirmed it. A test that passes by luck hides exactly
this bug, so I switched to stopping the server, which gave a blank page in
7 of 7 runs of the deploy sequence (the 5 above plus two full runs of this
post). If you test offline behaviour with network emulation, check that the
result is repeatable.</p>

<h2 id="fix-put-the-hashed-bundles-in-the-precache-list">Fix: put the hashed bundles in the precache list</h2>

<p>With a cache-first worker like this one, the fix is to cache, at install,
the files the cached HTML will ask for, so the two arrive together. The file
names change on each build, so the list has to come from the build: here
<code class="language-plaintext highlighter-rouge">build.py --precache</code> writes the bundle names into <code class="language-plaintext highlighter-rouge">sw.js</code>. Same
steps as above, starting from a fresh profile:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> <span class="nt">-rf</span> profile
<span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 build.py v1 <span class="nt">--precache</span> <span class="o">&amp;&amp;</span> node visit.mjs
<span class="go">v1 app.bbd8455b.js precache: ['/', '/assets/app.bbd8455b.js']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: / /assets/app.bbd8455b.js
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v1: / /assets/app.bbd8455b.js
</span><span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 build.py v2 <span class="nt">--precache</span> <span class="o">&amp;&amp;</span> node visit.mjs
<span class="go">v2 app.05452931.js precache: ['/', '/assets/app.05452931.js']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v1"</span>
<span class="go">  cache app-v2: / /assets/app.05452931.js
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">"notes app v2"</span>
<span class="go">  cache app-v2: / /assets/app.05452931.js
</span></code></pre></div></div>

<p>The first visit now works offline, and after the deploy the offline page is v2
with its bundle — the HTML and the scripts it names are installed together.</p>

<h3 id="if-a-listed-file-is-missing-the-update-doesnt-happen">If a listed file is missing, the update doesn’t happen</h3>

<p><code class="language-plaintext highlighter-rouge">cache.addAll()</code> is all-or-nothing: if any URL fails, the promise rejects and
the install fails. I checked by deploying v3 with <code class="language-plaintext highlighter-rouge">--precache</code> and then
deleting its bundle from <code class="language-plaintext highlighter-rouge">site/</code> before visiting:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 build.py v3 <span class="nt">--precache</span> <span class="o">&amp;&amp;</span> <span class="nb">rm </span>site/assets/app.<span class="k">*</span>.js <span class="o">&amp;&amp;</span> node visit.mjs
<span class="go">v3 app.7b8eea49.js precache: ['/', '/assets/app.7b8eea49.js']
</span><span class="gp">online: #</span>root <span class="o">=</span> <span class="s2">"notes app v2"</span>
<span class="go">  cache app-v2: / /assets/app.05452931.js
  cache app-v3: 
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span>
<span class="gp">$</span><span class="w"> </span>node visit.mjs <span class="nt">--offline</span>
<span class="gp">offline: #</span>root <span class="o">=</span> <span class="s2">"notes app v2"</span>
<span class="go">  cache app-v2: / /assets/app.05452931.js
  cache app-v3: 
</span></code></pre></div></div>

<p>The v3 worker never activated: the v2 worker stayed in control and the app
still worked offline as v2. The failed install did leave an empty <code class="language-plaintext highlighter-rouge">app-v3</code>
cache behind (<code class="language-plaintext highlighter-rouge">caches.open()</code> creates it before <code class="language-plaintext highlighter-rouge">addAll</code> fails). The activate
step in <code class="language-plaintext highlighter-rouge">sw.template.js</code> deletes every cache except its own, so the next
successful version should clean it up; I didn’t run that step. Keeping users
on the last working version is what you want from a broken deploy, instead of
installing an HTML page that points at nothing.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>With a cache-first worker like this one that precaches only the HTML and
leaves the bundles to runtime caching, <strong>the first offline open after a
deploy can be blank</strong> if the user opened the new version only once online
(and the same after a single first visit). Putting the scripts the HTML
references into the install-time precache fixed both in my test. A later
online reload caches the bundle too, so not every user hits it.</li>
  <li>Generate the list from the build output (the hashed names change every
build). Here a script writes it into <code class="language-plaintext highlighter-rouge">sw.js</code>. Bundler plugins for Workbox or
Vite’s <code class="language-plaintext highlighter-rouge">build.manifest</code> can produce such a list; I didn’t test those here.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">cache.addAll()</code> for the list so a missing file fails the install and
the old version stays, instead of installing half a version.</li>
  <li>Test it the way users hit it: deploy, open <strong>once</strong> online (no reload), then
go offline. Testing offline right after a reload hides the bug, because the
reload caches the bundle on the way through.</li>
  <li>If you test with network emulation (Playwright’s <code class="language-plaintext highlighter-rouge">setOffline</code>), repeat the
run: in 1 of my 6 runs the offline load worked when it shouldn’t have.
Stopping the server gave the same result in 7 of 7 runs here.</li>
  <li>When you test locally, make sure the HTTP cache isn’t involved
(<code class="language-plaintext highlighter-rouge">Cache-Control: no-cache</code> here). Otherwise <code class="language-plaintext highlighter-rouge">addAll</code> may be served a stale
<code class="language-plaintext highlighter-rouge">index.html</code> from the HTTP cache; I set the header so I didn’t have to find
out.</li>
  <li>Stop the test server with <code class="language-plaintext highlighter-rouge">kill</code> and the PID it was started with, not by
process name.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>),
Node 24.14.1 and Playwright 1.62.1 driving its bundled headless Chromium
(Chrome for Testing 151). Server bound to <code class="language-plaintext highlighter-rouge">127.0.0.1</code> only. I didn’t test
Safari/WebKit, Firefox, or an installed (home-screen) PWA; I’d expect the same
result from the same caching logic, but I haven’t run it there.</em></p>]]></content><author><name></name></author><category term="javascript" /><category term="pwa" /><category term="service-worker" /><category term="web" /><summary type="html"><![CDATA[A small PWA worked offline until a deploy. Opened once online after the deploy and then offline, it gave an empty page and "net::ERR_FAILED" for /assets/app..js. The service worker precached "/" at install but left the JavaScript to be cached on first use, and the new HTML points at a bundle name the browser has never fetched. Reproduced with headless Chromium (Playwright), simulating offline by stopping the local server, then fixed by precaching the hashed bundles at install.]]></summary></entry><entry><title type="html">Text jumps back while typing: the server’s change event echoes your own save and the refetch overwrites it (SSE)</title><link href="https://hanminsoo.github.io/2026/10/sse-change-event-refetch-overwrites-typing/" rel="alternate" type="text/html" title="Text jumps back while typing: the server’s change event echoes your own save and the refetch overwrites it (SSE)" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/sse-change-event-refetch-overwrites-typing</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/sse-change-event-refetch-overwrites-typing/"><![CDATA[<p>You type into an editor that autosaves. Every so often the text jumps back a few
characters, the next keys land in the wrong place, and what ends up saved is
garbled — <code class="language-plaintext highlighter-rouge">the quick brown fox jumps over the lazy dog</code> came out as
<code class="language-plaintext highlighter-rouge">theicox jse lg</code> in the repro below. Nothing errors, and every save reaches
the server.</p>

<p>The editor listens for “document changed” events from the server (Server-Sent
Events here) so that edits from another tab or device show up. When an event
arrives it refetches the document and puts the result in the editor. The catch:
<strong>the server sends that event for your own save too</strong>. By the time the event
arrives and the refetch comes back, you’ve typed more, so the refetch hands you
<em>older</em> text and the editor rolls back to it. Your next keystroke then saves the
rolled-back text, and the server stores it.</p>

<p>I reproduced this on my Mac with a small server and a scripted typist, then
fixed it with a per-session write sequence number.</p>

<h2 id="reproduce-it">Reproduce it</h2>

<p>A server with one shared document. <code class="language-plaintext highlighter-rouge">PUT /doc</code> saves it, <code class="language-plaintext highlighter-rouge">GET /doc</code> returns it,
and <code class="language-plaintext highlighter-rouge">GET /events</code> is an SSE stream with one event per save. The save sleeps for a
moment to stand in for a slow disk, network or sync step:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server.py — one shared document, saved with PUT, change events over SSE
</span><span class="kn">import</span> <span class="nn">json</span><span class="p">,</span> <span class="n">queue</span><span class="p">,</span> <span class="n">sys</span><span class="p">,</span> <span class="n">threading</span><span class="p">,</span> <span class="n">time</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">ThreadingHTTPServer</span><span class="p">,</span> <span class="n">BaseHTTPRequestHandler</span>

<span class="n">SAVE_DELAY</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="k">else</span> <span class="mf">0.08</span>   <span class="c1"># seconds a save takes
</span><span class="n">doc</span> <span class="o">=</span> <span class="p">{</span><span class="s">"text"</span><span class="p">:</span> <span class="s">""</span><span class="p">,</span> <span class="s">"seqs"</span><span class="p">:</span> <span class="p">{}}</span>   <span class="c1"># seqs: last write applied per session
</span><span class="n">lock</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Lock</span><span class="p">()</span>
<span class="n">listeners</span> <span class="o">=</span> <span class="p">[]</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">log_message</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
        <span class="k">pass</span>

    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">path</span> <span class="o">==</span> <span class="s">"/events"</span><span class="p">:</span>
            <span class="n">q</span> <span class="o">=</span> <span class="n">queue</span><span class="p">.</span><span class="n">Queue</span><span class="p">()</span>
            <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
                <span class="n">listeners</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">q</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">200</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"text/event-stream"</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
            <span class="k">try</span><span class="p">:</span>
                <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
                    <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="sa">b</span><span class="s">"data: "</span> <span class="o">+</span> <span class="n">q</span><span class="p">.</span><span class="n">get</span><span class="p">().</span><span class="n">encode</span><span class="p">()</span> <span class="o">+</span> <span class="sa">b</span><span class="s">"</span><span class="se">\n\n</span><span class="s">"</span><span class="p">)</span>
                    <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">flush</span><span class="p">()</span>
            <span class="k">except</span> <span class="nb">OSError</span><span class="p">:</span>
                <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
                    <span class="n">listeners</span><span class="p">.</span><span class="n">remove</span><span class="p">(</span><span class="n">q</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
            <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">doc</span><span class="p">).</span><span class="n">encode</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">reply</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">do_PUT</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">msg</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">rfile</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="s">"Content-Length"</span><span class="p">])))</span>
        <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">SAVE_DELAY</span><span class="p">)</span>
        <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">msg</span><span class="p">[</span><span class="s">"seq"</span><span class="p">]</span> <span class="o">&gt;</span> <span class="n">doc</span><span class="p">[</span><span class="s">"seqs"</span><span class="p">].</span><span class="n">get</span><span class="p">(</span><span class="n">msg</span><span class="p">[</span><span class="s">"session"</span><span class="p">],</span> <span class="mi">0</span><span class="p">):</span>   <span class="c1"># drop late, older writes
</span>                <span class="n">doc</span><span class="p">[</span><span class="s">"text"</span><span class="p">]</span> <span class="o">=</span> <span class="n">msg</span><span class="p">[</span><span class="s">"text"</span><span class="p">]</span>
                <span class="n">doc</span><span class="p">[</span><span class="s">"seqs"</span><span class="p">][</span><span class="n">msg</span><span class="p">[</span><span class="s">"session"</span><span class="p">]]</span> <span class="o">=</span> <span class="n">msg</span><span class="p">[</span><span class="s">"seq"</span><span class="p">]</span>
            <span class="n">event</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span><span class="s">"session"</span><span class="p">:</span> <span class="n">msg</span><span class="p">[</span><span class="s">"session"</span><span class="p">],</span> <span class="s">"seq"</span><span class="p">:</span> <span class="n">msg</span><span class="p">[</span><span class="s">"seq"</span><span class="p">]})</span>
            <span class="k">for</span> <span class="n">q</span> <span class="ow">in</span> <span class="n">listeners</span><span class="p">:</span>
                <span class="n">q</span><span class="p">.</span><span class="n">put</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">reply</span><span class="p">(</span><span class="sa">b</span><span class="s">"{}"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">reply</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">200</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"application/json"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">body</span><span class="p">)))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>

<span class="n">ThreadingHTTPServer</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8767</span><span class="p">),</span> <span class="n">Handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>Every save carries a random <code class="language-plaintext highlighter-rouge">session</code> id (one per open editor) and a <code class="language-plaintext highlighter-rouge">seq</code> that
counts that session’s saves. The server already uses them to drop a save that
arrives after a newer one from the same session, so out-of-order requests can’t
store old text — that is <em>not</em> the bug here. It also records the last applied
<code class="language-plaintext highlighter-rouge">seq</code> per session in <code class="language-plaintext highlighter-rouge">doc.seqs</code>, which the fix below uses.</p>

<p>The client is a Node script that behaves like the editor: it presses one key
every 30 ms, saves after every key, and refetches on every change event. With no
flag it applies whatever the refetch returns. It also measures how long each of
its own saves took to come back as an event:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// editor.mjs — types a sentence, saves after every keystroke,</span>
<span class="c1">// and refetches the document whenever the server says it changed.</span>
<span class="c1">//   node editor.mjs             apply every refetch (the bug)</span>
<span class="c1">//   node editor.mjs --skip-own  ignore events for my own saves</span>
<span class="c1">//   node editor.mjs --fix       ignore refetches that don't include all my saves</span>
<span class="kd">const</span> <span class="nx">BASE</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">http://127.0.0.1:8767</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">MODE</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">argv</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">??</span> <span class="dl">""</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mi">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">sentence</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">the quick brown fox jumps over the lazy dog</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">KEY_MS</span> <span class="o">=</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">KEY_MS</span> <span class="o">??</span> <span class="mi">30</span><span class="p">);</span>   <span class="c1">// time between key presses</span>
<span class="kd">let</span> <span class="nx">text</span> <span class="o">=</span> <span class="dl">""</span><span class="p">;</span>        <span class="c1">// what the user sees in the editor</span>
<span class="kd">let</span> <span class="nx">seq</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>          <span class="c1">// saves this session has sent</span>
<span class="kd">let</span> <span class="nx">overwrites</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>   <span class="c1">// times a refetch replaced what the editor showed</span>
<span class="kd">const</span> <span class="nx">sentAt</span> <span class="o">=</span> <span class="p">{};</span>    <span class="c1">// seq -&gt; when that save was sent</span>
<span class="kd">const</span> <span class="nx">echoMs</span> <span class="o">=</span> <span class="p">[];</span>    <span class="c1">// ms from sending a save to its event arriving</span>

<span class="kd">function</span> <span class="nx">save</span><span class="p">()</span> <span class="p">{</span>
  <span class="nx">seq</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">;</span>
  <span class="nx">sentAt</span><span class="p">[</span><span class="nx">seq</span><span class="p">]</span> <span class="o">=</span> <span class="nx">performance</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
  <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">BASE</span><span class="p">}</span><span class="s2">/doc`</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">PUT</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span> <span class="p">},</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">seq</span><span class="p">,</span> <span class="nx">text</span> <span class="p">}),</span>
  <span class="p">});</span>
<span class="p">}</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">onChange</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">session</span> <span class="o">===</span> <span class="nx">session</span><span class="p">)</span> <span class="nx">echoMs</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">performance</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">sentAt</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">seq</span><span class="p">]);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">MODE</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">--skip-own</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">event</span><span class="p">.</span><span class="nx">session</span> <span class="o">===</span> <span class="nx">session</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="p">(</span><span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">BASE</span><span class="p">}</span><span class="s2">/doc`</span><span class="p">)).</span><span class="nx">json</span><span class="p">();</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">MODE</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">--fix</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">doc</span><span class="p">.</span><span class="nx">seqs</span><span class="p">[</span><span class="nx">session</span><span class="p">]</span> <span class="o">??</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nx">seq</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// server hasn't got all my saves yet</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">doc</span><span class="p">.</span><span class="nx">text</span> <span class="o">!==</span> <span class="nx">text</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">overwrites</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`event </span><span class="p">${</span><span class="nx">event</span><span class="p">.</span><span class="nx">session</span> <span class="o">===</span> <span class="nx">session</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">mine</span><span class="dl">"</span> <span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">session</span><span class="p">}</span><span class="s2"> seq=</span><span class="p">${</span><span class="nx">event</span><span class="p">.</span><span class="nx">seq</span><span class="p">}</span><span class="s2">: editor "</span><span class="p">${</span><span class="nx">text</span><span class="p">}</span><span class="s2">" -&gt; "</span><span class="p">${</span><span class="nx">doc</span><span class="p">.</span><span class="nx">text</span><span class="p">}</span><span class="s2">"`</span><span class="p">);</span>
    <span class="nx">text</span> <span class="o">=</span> <span class="nx">doc</span><span class="p">.</span><span class="nx">text</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// listen for change events (Server-Sent Events)</span>
<span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">BASE</span><span class="p">}</span><span class="s2">/events`</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">decoder</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">TextDecoder</span><span class="p">();</span>
<span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">buf</span> <span class="o">=</span> <span class="dl">""</span><span class="p">;</span>
  <span class="k">for</span> <span class="k">await</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">chunk</span> <span class="k">of</span> <span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">buf</span> <span class="o">+=</span> <span class="nx">decoder</span><span class="p">.</span><span class="nx">decode</span><span class="p">(</span><span class="nx">chunk</span><span class="p">,</span> <span class="p">{</span> <span class="na">stream</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>
    <span class="kd">let</span> <span class="nx">i</span><span class="p">;</span>
    <span class="k">while</span> <span class="p">((</span><span class="nx">i</span> <span class="o">=</span> <span class="nx">buf</span><span class="p">.</span><span class="nx">indexOf</span><span class="p">(</span><span class="dl">"</span><span class="se">\n\n</span><span class="dl">"</span><span class="p">))</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">line</span> <span class="o">=</span> <span class="nx">buf</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">i</span><span class="p">);</span>
      <span class="nx">buf</span> <span class="o">=</span> <span class="nx">buf</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">i</span> <span class="o">+</span> <span class="mi">2</span><span class="p">);</span>
      <span class="nx">onChange</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">line</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="dl">"</span><span class="s2">data: </span><span class="dl">"</span><span class="p">.</span><span class="nx">length</span><span class="p">)));</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">})();</span>

<span class="c1">// the user presses one key every KEY_MS; each key goes at the end of what the editor shows</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">key</span> <span class="k">of</span> <span class="nx">sentence</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">text</span> <span class="o">+=</span> <span class="nx">key</span><span class="p">;</span>
  <span class="nx">save</span><span class="p">();</span>
  <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="nx">KEY_MS</span><span class="p">));</span>
<span class="p">}</span>
<span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>  <span class="c1">// let the last events arrive</span>
<span class="kd">const</span> <span class="nx">ms</span> <span class="o">=</span> <span class="nx">echoMs</span><span class="p">.</span><span class="nx">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span> <span class="o">-</span> <span class="nx">b</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">);</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`final editor: "</span><span class="p">${</span><span class="nx">text</span><span class="p">}</span><span class="s2">"`</span><span class="p">);</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`keys pressed: </span><span class="p">${</span><span class="nx">sentence</span><span class="p">.</span><span class="nx">length</span><span class="p">}</span><span class="s2">, overwrites: </span><span class="p">${</span><span class="nx">overwrites</span><span class="p">}</span><span class="s2">, `</span> <span class="o">+</span>
  <span class="s2">`save-&gt;event ms: min </span><span class="p">${</span><span class="nx">ms</span><span class="p">[</span><span class="mi">0</span><span class="p">]}</span><span class="s2"> median </span><span class="p">${</span><span class="nx">ms</span><span class="p">[</span><span class="nx">ms</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">]}</span><span class="s2"> max </span><span class="p">${</span><span class="nx">ms</span><span class="p">.</span><span class="nx">at</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)}</span><span class="s2">`</span><span class="p">);</span>
<span class="nx">process</span><span class="p">.</span><span class="nx">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
</code></pre></div></div>

<p>Start the server, keep its PID, and type (output trimmed in the middle):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>node editor.mjs
<span class="gp">event mine seq=3: editor "the qu" -&gt;</span><span class="w"> </span><span class="s2">"the"</span>
<span class="gp">event mine seq=5: editor "theic" -&gt;</span><span class="w"> </span><span class="s2">"the q"</span>
<span class="gp">event mine seq=4: editor "the qk " -&gt;</span><span class="w"> </span><span class="s2">"theic"</span>
<span class="gp">event mine seq=6: editor "theicbr" -&gt;</span><span class="w"> </span><span class="s2">"theic"</span>
<span class="gp">event mine seq=9: editor "theicow" -&gt;</span><span class="w"> </span><span class="s2">"the qk"</span>
<span class="c">...
</span><span class="gp">event mine seq=42: editor "theicox jsee d" -&gt;</span><span class="w"> </span><span class="s2">"theicox jsee do"</span>
<span class="gp">event mine seq=43: editor "theicox jsee do" -&gt;</span><span class="w"> </span><span class="s2">"theicox jse lg"</span>
<span class="go">final editor: "theicox jse lg"
</span><span class="gp">keys pressed: 43, overwrites: 31, save-&gt;</span>event ms: min 88 median 203 max 233
</code></pre></div></div>

<p>The first line is the bug in one step. The editor shows <code class="language-plaintext highlighter-rouge">the qu</code>; the event for
save 3 arrives; the refetch returns <code class="language-plaintext highlighter-rouge">the</code>, because that’s all the server had
stored at that moment; the editor drops back to <code class="language-plaintext highlighter-rouge">the</code>. The next keys are added to
the rolled-back text (<code class="language-plaintext highlighter-rouge">theic</code> — the <code class="language-plaintext highlighter-rouge">qu</code> is gone), those saves go out, their
echoes roll the editor back again, and it snowballs. Events also arrive out of
order (5 before 4): the server handles saves on separate threads, so they don’t
finish in the order they were sent.</p>

<p>And it isn’t only on screen — the garbled text is what got saved:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> http://127.0.0.1:8767/doc
<span class="go">{"text": "theicox jse lg", "seqs": {"lpd5f2hm3hh": 43}}
</span></code></pre></div></div>

<p>Earlier runs the same morning: one ended as <code class="language-plaintext highlighter-rouge">the qubx oe  dog</code> (25 overwrites),
and three more gave 34, 26 and 28 overwrites out of 43 keys.</p>

<h2 id="it-depends-on-typing-speed-vs-the-save-round-trip">It depends on typing speed vs. the save round trip</h2>

<p>Slower typing helps only once the gap between keys is longer than <em>save + event +
refetch</em>. Three runs at each key interval, against the same server:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="k">for </span>k <span class="k">in </span>120 200 300 500<span class="p">;</span> <span class="k">do for </span>i <span class="k">in </span>1 2 3<span class="p">;</span> <span class="k">do </span><span class="nv">KEY_MS</span><span class="o">=</span><span class="nv">$k</span> node editor.mjs | <span class="nb">tail</span> <span class="nt">-1</span><span class="p">;</span> <span class="k">done</span><span class="p">;</span> <span class="k">done</span>
<span class="gp">keys pressed: 43, overwrites: 41, save-&gt;</span>event ms: min 84 median 222 max 241
<span class="gp">keys pressed: 43, overwrites: 41, save-&gt;</span>event ms: min 106 median 224 max 235
<span class="gp">keys pressed: 43, overwrites: 43, save-&gt;</span>event ms: min 124 median 219 max 238
<span class="gp">keys pressed: 43, overwrites: 43, save-&gt;</span>event ms: min 124 median 224 max 235
<span class="gp">keys pressed: 43, overwrites: 36, save-&gt;</span>event ms: min 96 median 210 max 234
<span class="gp">keys pressed: 43, overwrites: 39, save-&gt;</span>event ms: min 104 median 223 max 262
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 86 median 200 max 241
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 93 median 217 max 236
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 91 median 214 max 235
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 107 median 219 max 242
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 87 median 224 max 237
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 104 median 213 max 242
</code></pre></div></div>

<p>At 120 and 200 ms between keys almost every event rolled the editor back; at 300
and 500 ms none did. The last column says why: a save took about 85–260 ms
(median a bit over 200) to come back as an event, so at 200 ms the next key was
usually pressed before the refetch returned, and at 300 ms it never was.</p>

<p>That’s longer than the 0.08 s <code class="language-plaintext highlighter-rouge">SAVE_DELAY</code>. Part of it is <code class="language-plaintext highlighter-rouge">time.sleep</code> itself:
in the session I ran this from, five calls to <code class="language-plaintext highlighter-rouge">time.sleep(0.08)</code> timed with
<code class="language-plaintext highlighter-rouge">time.perf_counter()</code> took 0.12–0.23 s. I didn’t dig into why. The measured
save-to-event time is the number that matters, whatever its cause.</p>

<p>Make the save slower and the cut-off moves with it (I’ve left out the shell’s
job messages):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py 0.3 &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>k <span class="k">in </span>250 300 500 700<span class="p">;</span> <span class="k">do </span><span class="nv">KEY_MS</span><span class="o">=</span><span class="nv">$k</span> node editor.mjs | <span class="nb">tail</span> <span class="nt">-1</span><span class="p">;</span> <span class="k">done</span>
<span class="gp">keys pressed: 43, overwrites: 43, save-&gt;</span>event ms: min 309 median 443 max 457
<span class="gp">keys pressed: 43, overwrites: 43, save-&gt;</span>event ms: min 305 median 433 max 463
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 307 median 430 max 455
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 314 median 446 max 462
</code></pre></div></div>

<p>And with no delay at all, it never showed up:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py 0 &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3<span class="p">;</span> <span class="k">do </span>node editor.mjs | <span class="nb">tail</span> <span class="nt">-1</span><span class="p">;</span> <span class="k">done</span>
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 1 median 3 max 6
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 1 median 3 max 7
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 1 median 4 max 6
</code></pre></div></div>

<p>So a user who pauses longer than your save round trip won’t see it, and one who
types faster will. On localhost with a tiny document the round trip is a few
milliseconds, which is why this can pass local testing and break once there’s a
slow disk, a sync step or a real network in the save path.</p>

<h2 id="fix-a-per-session-write-sequence">Fix: a per-session write sequence</h2>

<p>The client already numbers its saves (<code class="language-plaintext highlighter-rouge">seq</code>), and the server records the last
<code class="language-plaintext highlighter-rouge">seq</code> it applied for each session (<code class="language-plaintext highlighter-rouge">doc.seqs</code>) and returns it with the document.
So the client can tell whether a refetched document <strong>includes all of its own
saves</strong>. If it doesn’t, the editor is ahead of the server and the refetch must
not overwrite it:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nx">MODE</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">--fix</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">doc</span><span class="p">.</span><span class="nx">seqs</span><span class="p">[</span><span class="nx">session</span><span class="p">]</span> <span class="o">??</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nx">seq</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// server hasn't got all my saves yet</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">seq</code> is read when the refetch <em>returns</em>, not when the event arrived. If you
typed (and saved) while the refetch was in flight, the check sees the new <code class="language-plaintext highlighter-rouge">seq</code>
and skips that stale response too.</p>

<p>With the check, the same typing at 30 ms per key gave no overwrites, and neither
did the slow server at 250 ms per key, which had overwritten all 43 keys before:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3<span class="p">;</span> <span class="k">do </span>node editor.mjs <span class="nt">--fix</span> | <span class="nb">tail</span> <span class="nt">-1</span><span class="p">;</span> <span class="k">done</span>
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 96 median 223 max 233
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 91 median 209 max 234
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 97 median 203 max 235
<span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py 0.3 &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="nv">KEY_MS</span><span class="o">=</span>250 node editor.mjs <span class="nt">--fix</span> | <span class="nb">tail</span> <span class="nt">-1</span>
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 308 median 444 max 456
</code></pre></div></div>

<p>Each fixed run ended with the full sentence in the editor, and on the server
(checked with <code class="language-plaintext highlighter-rouge">GET /doc</code> after a repeat of the slow-server run).</p>

<h2 id="why-not-just-skip-events-with-my-own-session-id">Why not just skip events with my own session id?</h2>

<p>That’s the obvious first fix (<code class="language-plaintext highlighter-rouge">--skip-own</code> in the script), and on its own it
removes the echo:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3<span class="p">;</span> <span class="k">do </span>node editor.mjs <span class="nt">--skip-own</span> | <span class="nb">tail</span> <span class="nt">-1</span><span class="p">;</span> <span class="k">done</span>
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 95 median 212 max 235
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 93 median 209 max 236
<span class="gp">keys pressed: 43, overwrites: 0, save-&gt;</span>event ms: min 85 median 208 max 234
</code></pre></div></div>

<p>The difference shows up when <strong>another</strong> tab saves while you type. Its event is
not yours, so the editor refetches — and that refetch can return a document
holding an <em>older copy of your own text</em>, because your latest saves are still in
flight. To test it, a second “tab” (<code class="language-plaintext highlighter-rouge">curl</code>, session <code class="language-plaintext highlighter-rouge">tab-b</code>) saves four times,
about half a second apart, while the editor types:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>node editor.mjs <span class="nt">--skip-own</span> &amp; <span class="nb">sleep </span>0.25<span class="p">;</span> <span class="k">for </span>n <span class="k">in </span>1 2 3 4<span class="p">;</span> <span class="k">do </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-X</span> PUT <span class="se">\</span>
<span class="gp">    -H "Content-Type: application/json" -d '{"session":"tab-b","seq":'$</span>n<span class="s1">',"text":"B'</span><span class="nv">$n</span><span class="s1">' "}'</span> <span class="se">\</span>
<span class="gp">    http://127.0.0.1:8767/doc;</span><span class="w"> </span><span class="nb">sleep </span>0.25<span class="p">;</span> <span class="k">done</span><span class="p">;</span> <span class="nb">wait</span> <span class="nv">$!</span>
<span class="gp">event tab-b seq=1: editor "the quick b" -&gt;</span><span class="w"> </span><span class="s2">"B1 "</span>
<span class="gp">event tab-b seq=2: editor "B1 rown fox jumps over " -&gt;</span><span class="w"> </span><span class="s2">"B1 rown fox jumps"</span>
<span class="gp">event tab-b seq=3: editor "B1 rown fox jumpsthe lazy dog" -&gt;</span><span class="w"> </span><span class="s2">"B3 "</span>
<span class="gp">event tab-b seq=4: editor "B3 " -&gt;</span><span class="w"> </span><span class="s2">"B4 "</span>
<span class="go">final editor: "B4 "
</span><span class="gp">keys pressed: 43, overwrites: 4, save-&gt;</span>event ms: min 87 median 208 max 275
</code></pre></div></div>

<p>Look at the second line. The event is tab-b’s, but the refetch returned
<code class="language-plaintext highlighter-rouge">B1 rown fox jumps</code> — this editor’s own text from a few keys earlier, because
tab-b’s save 2 had been overwritten on the server by this editor’s later saves,
and its newest saves hadn’t landed yet. The editor rolled back to it and the
next keys ran together (<code class="language-plaintext highlighter-rouge">jumpsthe</code>). That happened in 3 of 6 runs (restarting the
server before each so <code class="language-plaintext highlighter-rouge">tab-b</code> starts at <code class="language-plaintext highlighter-rouge">seq</code> 1 again); in the other three, every
refetch happened to return tab-b’s text.</p>

<p>Six runs of the same test with <code class="language-plaintext highlighter-rouge">--fix</code> never rolled back to the editor’s own
text. The tab-b saves whose refetch came back while the editor still had saves in
flight were skipped (<code class="language-plaintext highlighter-rouge">B1</code> and <code class="language-plaintext highlighter-rouge">B2</code> in every run, <code class="language-plaintext highlighter-rouge">B3</code> too in one), and the later
ones were shown:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$SERVER</span><span class="p">;</span> python3 server.py &amp; <span class="nv">SERVER</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>node editor.mjs <span class="nt">--fix</span> &amp; <span class="nb">sleep </span>0.25<span class="p">;</span> <span class="k">for </span>n <span class="k">in </span>1 2 3 4<span class="p">;</span> <span class="k">do </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-X</span> PUT <span class="se">\</span>
<span class="gp">    -H "Content-Type: application/json" -d '{"session":"tab-b","seq":'$</span>n<span class="s1">',"text":"B'</span><span class="nv">$n</span><span class="s1">' "}'</span> <span class="se">\</span>
<span class="gp">    http://127.0.0.1:8767/doc;</span><span class="w"> </span><span class="nb">sleep </span>0.25<span class="p">;</span> <span class="k">done</span><span class="p">;</span> <span class="nb">wait</span> <span class="nv">$!</span>
<span class="gp">event tab-b seq=3: editor "the quick brown fox jumps over the lazy dog" -&gt;</span><span class="w"> </span><span class="s2">"B3 "</span>
<span class="gp">event tab-b seq=4: editor "B3 " -&gt;</span><span class="w"> </span><span class="s2">"B4 "</span>
<span class="go">final editor: "B4 "
</span><span class="gp">keys pressed: 43, overwrites: 2, save-&gt;</span>event ms: min 86 median 217 max 235
</code></pre></div></div>

<p>That’s not a merge either: tab-b’s <code class="language-plaintext highlighter-rouge">B1</code> and <code class="language-plaintext highlighter-rouge">B2</code> were stored and then replaced
by this editor’s next save, and the user never saw them. Whose text should win
when two people type into the same field at once is a different problem
(locking, operational transforms, CRDTs). What the sequence check fixes is
narrower and is the bug in the title: <strong>your own edits aren’t undone by a stale
copy of themselves</strong>, whichever event triggered the refetch.</p>

<p>When you’re done, stop the server with the PID you saved: <code class="language-plaintext highlighter-rouge">kill $SERVER</code>.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>Text jumps back while typing, or saved text is missing characters, in an
editor that refetches on server change events: suspect the echo of your own save.</li>
  <li>It may not show up on localhost with a fast save. Add a delay to the save path
and type faster than the save round trip.</li>
  <li>Give each editor instance a session id and number its saves; have the server
record the last applied number per session and return it with the document.</li>
  <li>Before applying a refetched document, check that it includes all your saves
(<code class="language-plaintext highlighter-rouge">doc.seqs[session] &gt;= seq</code>), reading <code class="language-plaintext highlighter-rouge">seq</code> when the response arrives.</li>
  <li>Have the server drop a save whose number is lower than the last one it applied
for that session, so out-of-order requests can’t store old text.</li>
  <li>Skipping your own session’s events is not enough: a refetch triggered by
someone else’s change can still carry your own stale text.</li>
  <li>This doesn’t merge concurrent edits. If two people really type into the same
text, you need locking, operational transforms or CRDTs.</li>
  <li>If you debounce saves, unsaved local edits also make the editor newer than any
refetch. I only tested saving on every key; with a debounce you’d also skip
refetches while there are unsaved edits.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>,
Command Line Tools), Node 24.14.1 (built-in <code class="language-plaintext highlighter-rouge">fetch</code>) and curl 8.7.1. Server bound
to <code class="language-plaintext highlighter-rouge">127.0.0.1</code> only. I didn’t test in a browser (<code class="language-plaintext highlighter-rouge">EventSource</code>) or over
WebSocket; the client logic would be the same, but I haven’t run it there.</em></p>]]></content><author><name></name></author><category term="javascript" /><category term="python" /><category term="sse" /><category term="web" /><summary type="html"><![CDATA[An autosaving editor that refetches the document on every server "changed" event kept eating characters: the text jumped back mid-word and the saved result was garbled. The change event for your own save arrives after you've typed more, and the refetch returns older text. Reproduced with a small Python SSE server and a Node client, then fixed with a per-session write sequence number. Skipping events with your own session id is not enough.]]></summary></entry><entry><title type="html">Server returns 413 but the client gets BrokenPipeError / ECONNRESET: drain the body first</title><link href="https://hanminsoo.github.io/2026/10/413-payload-too-large-broken-pipe-drain-body/" rel="alternate" type="text/html" title="Server returns 413 but the client gets BrokenPipeError / ECONNRESET: drain the body first" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/413-payload-too-large-broken-pipe-drain-body</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/413-payload-too-large-broken-pipe-drain-body/"><![CDATA[<p>Your server checks <code class="language-plaintext highlighter-rouge">Content-Length</code>, sees the upload is too big, and answers
<code class="language-plaintext highlighter-rouge">413 Payload Too Large</code>. In <code class="language-plaintext highlighter-rouge">curl</code> you see the 413. But a Python client gets
<code class="language-plaintext highlighter-rouge">BrokenPipeError: [Errno 32] Broken pipe</code> or <code class="language-plaintext highlighter-rouge">ConnectionResetError: [Errno 54]
Connection reset by peer</code>, and Node’s <code class="language-plaintext highlighter-rouge">fetch</code> throws <code class="language-plaintext highlighter-rouge">TypeError: fetch failed</code>
with <code class="language-plaintext highlighter-rouge">EPIPE</code> or <code class="language-plaintext highlighter-rouge">ECONNRESET</code> — the client never sees the 413 you sent.</p>

<p>I reproduced this on my Mac. The server sent the 413 and <strong>closed the connection
without reading the request body</strong>. The client was still writing the body. When a socket is
closed with unread data in its receive buffer, TCP answers with a reset (RST) rather
than a normal close, and the client’s next <code class="language-plaintext highlighter-rouge">send()</code> or <code class="language-plaintext highlighter-rouge">recv()</code> fails. (I didn’t
capture packets; the client-side errors below fit that.) Clients that read the response <em>while</em> uploading (curl,
Chrome) pick up the 413 in time; clients that finish writing before reading
(Python’s <code class="language-plaintext highlighter-rouge">http.client</code>/<code class="language-plaintext highlighter-rouge">urllib</code>, and Node’s <code class="language-plaintext highlighter-rouge">fetch</code> in some runs) don’t.</p>

<h2 id="reproduce-it">Reproduce it</h2>

<p>A server that accepts bodies up to 1 MB. Without <code class="language-plaintext highlighter-rouge">--drain</code> it answers 413 straight
away; with <code class="language-plaintext highlighter-rouge">--drain</code> it reads and discards the body first (up to a cap — more on
that below):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server.py
</span><span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">ThreadingHTTPServer</span><span class="p">,</span> <span class="n">BaseHTTPRequestHandler</span>

<span class="n">LIMIT</span> <span class="o">=</span> <span class="mi">1_000_000</span>          <span class="c1"># largest body we accept
</span><span class="n">MAX_DRAIN</span> <span class="o">=</span> <span class="mi">50_000_000</span>     <span class="c1"># largest body we'll read just to throw it away
</span><span class="n">DRAIN</span> <span class="o">=</span> <span class="s">"--drain"</span> <span class="ow">in</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="n">timeout</span> <span class="o">=</span> <span class="mi">10</span>           <span class="c1"># a client that stops sending can't hold a thread forever
</span>
    <span class="k">def</span> <span class="nf">do_POST</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">length</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
        <span class="k">if</span> <span class="n">length</span> <span class="o">&gt;</span> <span class="n">LIMIT</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">DRAIN</span> <span class="ow">and</span> <span class="n">length</span> <span class="o">&lt;=</span> <span class="n">MAX_DRAIN</span><span class="p">:</span>
                <span class="bp">self</span><span class="p">.</span><span class="n">drain</span><span class="p">(</span><span class="n">length</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">reply</span><span class="p">(</span><span class="mi">413</span><span class="p">,</span> <span class="sa">b</span><span class="s">"too large</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">rfile</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">length</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">reply</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="sa">b</span><span class="s">"ok</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">drain</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">length</span><span class="p">):</span>
        <span class="c1"># read and discard the body so the client can finish sending
</span>        <span class="k">while</span> <span class="n">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
            <span class="n">chunk</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">rfile</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="nb">min</span><span class="p">(</span><span class="n">length</span><span class="p">,</span> <span class="mi">65536</span><span class="p">))</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">chunk</span><span class="p">:</span>
                <span class="k">break</span>
            <span class="n">length</span> <span class="o">-=</span> <span class="nb">len</span><span class="p">(</span><span class="n">chunk</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">reply</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">code</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"text/plain"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">body</span><span class="p">)))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Connection"</span><span class="p">,</span> <span class="s">"close"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">close_connection</span> <span class="o">=</span> <span class="bp">True</span>

<span class="n">ThreadingHTTPServer</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8766</span><span class="p">),</span> <span class="n">Handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>A client using the standard library, posting N bytes:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># client.py
</span><span class="kn">import</span> <span class="nn">http.client</span><span class="p">,</span> <span class="n">sys</span>
<span class="n">body</span> <span class="o">=</span> <span class="sa">b</span><span class="s">"x"</span> <span class="o">*</span> <span class="nb">int</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
<span class="n">conn</span> <span class="o">=</span> <span class="n">http</span><span class="p">.</span><span class="n">client</span><span class="p">.</span><span class="n">HTTPConnection</span><span class="p">(</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8766</span><span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
    <span class="n">conn</span><span class="p">.</span><span class="n">request</span><span class="p">(</span><span class="s">"POST"</span><span class="p">,</span> <span class="s">"/"</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="n">body</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s">"Content-Type"</span><span class="p">:</span> <span class="s">"application/octet-stream"</span><span class="p">})</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="n">getresponse</span><span class="p">()</span>
    <span class="k">print</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">status</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">read</span><span class="p">())</span>
<span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="nb">type</span><span class="p">(</span><span class="n">e</span><span class="p">).</span><span class="n">__name__</span><span class="p">,</span> <span class="n">e</span><span class="p">)</span>
</code></pre></div></div>

<p>Start the server without <code class="language-plaintext highlighter-rouge">--drain</code>, send a small body, then a 20 MB one five times:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py &amp;
<span class="gp">$</span><span class="w"> </span>python3 client.py 100
<span class="go">200 b'ok\n'
</span><span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3 4 5<span class="p">;</span> <span class="k">do </span>python3 client.py 20000000<span class="p">;</span> <span class="k">done</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span>
<span class="go">   4 BrokenPipeError [Errno 32] Broken pipe
   1 ConnectionResetError [Errno 54] Connection reset by peer
</span></code></pre></div></div>

<p>Not one 413. Which error you get depends on timing — whether the reset arrives while
the client is still writing (<code class="language-plaintext highlighter-rouge">EPIPE</code>) or when it reads (<code class="language-plaintext highlighter-rouge">ECONNRESET</code>). Across several
runs of five I saw both, in varying proportions (one run was five <code class="language-plaintext highlighter-rouge">BrokenPipeError</code>s),
but never a 413.</p>

<p><code class="language-plaintext highlighter-rouge">urllib.request</code> is built on <code class="language-plaintext highlighter-rouge">http.client</code> and fails the same way — you get a
<code class="language-plaintext highlighter-rouge">URLError</code>, not the <code class="language-plaintext highlighter-rouge">HTTPError</code> with <code class="language-plaintext highlighter-rouge">.code == 413</code> your code probably handles:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-c</span> <span class="s1">'
</span><span class="go">import urllib.request, urllib.error
try:
    urllib.request.urlopen(urllib.request.Request("http://127.0.0.1:8766/", data=b"x" * 20_000_000))
except urllib.error.HTTPError as e:
    print("HTTPError", e.code)
except Exception as e:
    print(type(e).__name__, e)'
</span><span class="gp">URLError &lt;urlopen error [Errno 32] Broken pipe&gt;</span><span class="w">
</span></code></pre></div></div>

<p>Node’s built-in <code class="language-plaintext highlighter-rouge">fetch</code> failed in two to four of every five tries (one run shown):</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// upload.mjs</span>
<span class="k">try</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">http://127.0.0.1:8766/</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span> <span class="na">body</span><span class="p">:</span> <span class="k">new</span> <span class="nb">Uint8Array</span><span class="p">(</span><span class="mi">20</span><span class="nx">_000_000</span><span class="p">)</span> <span class="p">});</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">status</span><span class="p">,</span> <span class="p">(</span><span class="k">await</span> <span class="nx">r</span><span class="p">.</span><span class="nx">text</span><span class="p">()).</span><span class="nx">trim</span><span class="p">());</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nb">String</span><span class="p">(</span><span class="nx">e</span><span class="p">),</span> <span class="nx">e</span><span class="p">.</span><span class="nx">cause</span><span class="p">?.</span><span class="nx">code</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3 4 5<span class="p">;</span> <span class="k">do </span>node upload.mjs<span class="p">;</span> <span class="k">done</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span>
<span class="go">   2 413 too large
   1 TypeError: fetch failed ECONNRESET
   1 TypeError: fetch failed EPIPE
   1 TypeError: terminated EPIPE
</span></code></pre></div></div>

<h2 id="why-curl-says-everything-is-fine">Why curl says everything is fine</h2>

<p>The same server, the same 20 MB, with curl:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">head</span> <span class="nt">-c</span> 20000000 /dev/zero <span class="o">&gt;</span> big.bin
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s1">'%{http_code}\n'</span> <span class="nt">--data-binary</span> @big.bin http://127.0.0.1:8766/
<span class="go">413
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">curl -v</code> shows why (excerpt): for a large body curl sends <code class="language-plaintext highlighter-rouge">Expect: 100-continue</code>
and waits before uploading, so the 413 arrives before any body is sent:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">&gt;</span><span class="w"> </span>Content-Length: 20000000
<span class="gp">&gt;</span><span class="w"> </span>Content-Type: application/x-www-form-urlencoded
<span class="gp">&gt;</span><span class="w"> </span>Expect: 100-continue
<span class="gp">&gt;</span><span class="w">
</span><span class="go">* HTTP 1.0, assume close after body
&lt; HTTP/1.0 413 Request Entity Too Large
</span></code></pre></div></div>

<p>Even with <code class="language-plaintext highlighter-rouge">-H 'Expect:'</code> (upload immediately) curl got the 413 in five of five
tries — it watches for a response while sending. Headless Chrome (<code class="language-plaintext highlighter-rouge">fetch</code> from a page
on the same server) also got <code class="language-plaintext highlighter-rouge">413</code> five times out of five. So if you only test with
curl or a browser, the bug is invisible; it shows up when a script or another
service is the client.</p>

<h2 id="fix-read-drain-the-body-then-send-413">Fix: read (drain) the body, then send 413</h2>

<p>Stop the previous server first (Ctrl-C, or <code class="language-plaintext highlighter-rouge">kill</code> the PID the shell printed when you
started it), then start it with <code class="language-plaintext highlighter-rouge">--drain</code>:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py <span class="nt">--drain</span> &amp;
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3 4 5<span class="p">;</span> <span class="k">do </span>python3 client.py 20000000<span class="p">;</span> <span class="k">done</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span>
<span class="go">   5 413 b'too large\n'
</span><span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in </span>1 2 3 4 5<span class="p">;</span> <span class="k">do </span>node upload.mjs<span class="p">;</span> <span class="k">done</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span>
<span class="go">   5 413 too large
</span></code></pre></div></div>

<p>and the <code class="language-plaintext highlighter-rouge">urllib</code> snippet above now prints <code class="language-plaintext highlighter-rouge">HTTPError 413</code>. Draining 20 MB over
loopback took about 50 ms for the whole client run here.</p>

<p>Once the server has read the whole body there is no unread data when it closes, so
the close should be a normal one instead of a reset, and the client reads the 413
after it finishes writing — which is what all four clients did.</p>

<h3 id="dont-drain-without-limits">Don’t drain without limits</h3>

<p>Draining means you still receive the bytes you wanted to refuse. Two guards in
<code class="language-plaintext highlighter-rouge">server.py</code> above:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">MAX_DRAIN</code></strong>: above it, don’t drain — answer and close, and accept that this
client will see a reset. A client claiming a 10 GB body shouldn’t get to make you
read 10 GB. With <code class="language-plaintext highlighter-rouge">--drain</code>, a 60 MB body (over the 50 MB cap) gave the same
<code class="language-plaintext highlighter-rouge">BrokenPipeError</code> / <code class="language-plaintext highlighter-rouge">ConnectionResetError</code> as before.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">timeout</code></strong> on the handler: a client that sends the headers and then trickles
(or stops) can’t hold a thread forever. With a client that sent the headers for a
2 MB body plus 100 bytes and then went quiet, the server gave up and closed the
connection after 10 s (without a response — the client just sees the close).</li>
</ul>

<p>Pick <code class="language-plaintext highlighter-rouge">MAX_DRAIN</code> a few times above your real limit — big enough that honest clients
who are slightly over get a readable 413.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>A client reports <code class="language-plaintext highlighter-rouge">Broken pipe</code> / <code class="language-plaintext highlighter-rouge">EPIPE</code> / <code class="language-plaintext highlighter-rouge">ECONNRESET</code> on upload, but the server
log says it sent <code class="language-plaintext highlighter-rouge">413</code>: the server probably closed without reading the body (a proxy can cause the same symptom).</li>
  <li>Test with a client that writes the whole body before reading — Python
<code class="language-plaintext highlighter-rouge">http.client</code>/<code class="language-plaintext highlighter-rouge">urllib</code>, Node <code class="language-plaintext highlighter-rouge">fetch</code>. curl and browsers hide the problem.</li>
  <li>Before answering 413 (and likely any early error on a request with a body; I only tested 413), read and
discard the remaining <code class="language-plaintext highlighter-rouge">Content-Length</code> bytes.</li>
  <li>Cap the drain and set a socket timeout; past the cap, closing (and the reset) is
the trade-off I chose.</li>
  <li>This server only handles <code class="language-plaintext highlighter-rouge">Content-Length</code> bodies; a chunked upload needs the same
idea applied to the chunked stream.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>,
Command Line Tools) and Python 3.12.13 (Homebrew; same results with <code class="language-plaintext highlighter-rouge">client.py</code>),
Node 24.14.1, curl 8.7.1, Google Chrome 154.0.8037.99 (headless, fresh profile).
Server bound to <code class="language-plaintext highlighter-rouge">127.0.0.1</code> only, so all traffic was loopback; I didn’t test over a
real network, where timing — and which error you see — may differ.</em></p>]]></content><author><name></name></author><category term="python" /><category term="http" /><category term="node" /><category term="macos" /><summary type="html"><![CDATA[A Python server that rejects oversized uploads with 413 looked fine in curl and Chrome, but Python clients failed with "BrokenPipeError: [Errno 32] Broken pipe" or "ConnectionResetError: [Errno 54]", and Node's fetch with EPIPE/ECONNRESET. The server replied and closed before reading the body. Reproduced with a small http.server script, then fixed by draining (up to a cap) before sending 413.]]></summary></entry><entry><title type="html">CORS blocked the response but the POST still ran: text/plain skips the preflight (require application/json)</title><link href="https://hanminsoo.github.io/2026/10/cors-simple-request-no-preflight-text-plain/" rel="alternate" type="text/html" title="CORS blocked the response but the POST still ran: text/plain skips the preflight (require application/json)" /><published>2026-10-12T00:00:00+00:00</published><updated>2026-10-12T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/cors-simple-request-no-preflight-text-plain</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/cors-simple-request-no-preflight-text-plain/"><![CDATA[<p>A page on another origin calls your local API, the browser console says
<code class="language-plaintext highlighter-rouge">TypeError: Failed to fetch</code> (CORS error) — and the write <strong>happened anyway</strong>. Your
server logged the <code class="language-plaintext highlighter-rouge">POST</code>, the data is in the store, and only the <em>response</em> was
hidden from the page.</p>

<p>I reproduced this on my Mac with headless Chrome. The cause: a <code class="language-plaintext highlighter-rouge">POST</code> whose
<code class="language-plaintext highlighter-rouge">Content-Type</code> is <code class="language-plaintext highlighter-rouge">text/plain</code> (or <code class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> or
<code class="language-plaintext highlighter-rouge">multipart/form-data</code>) is a CORS <strong>simple request</strong>. The browser sends it straight
to the server <strong>without an <code class="language-plaintext highlighter-rouge">OPTIONS</code> preflight</strong>, and CORS is only checked
afterwards, when the page tries to read the response. Your <code class="language-plaintext highlighter-rouge">OPTIONS</code> handler —
however strict — never gets a say.</p>

<h2 id="reproduce-it">Reproduce it</h2>

<p>Work in an empty folder (I used <code class="language-plaintext highlighter-rouge">/tmp/cors-demo</code>). A small JSON API on port 8766 that is meant to accept writes only from
<code class="language-plaintext highlighter-rouge">http://127.0.0.1:8765</code>. With <code class="language-plaintext highlighter-rouge">--strict</code> it adds the fix shown later; ignore that
for now.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># api.py — a local JSON API on 127.0.0.1:8766 that only allows one origin
</span><span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">ThreadingHTTPServer</span><span class="p">,</span> <span class="n">BaseHTTPRequestHandler</span>

<span class="n">ALLOWED_ORIGIN</span> <span class="o">=</span> <span class="s">"http://127.0.0.1:8765"</span>
<span class="n">STRICT</span> <span class="o">=</span> <span class="s">"--strict"</span> <span class="ow">in</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_OPTIONS</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="c1"># CORS preflight: only the allowed origin gets the green light
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">204</span><span class="p">)</span>
        <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Origin"</span><span class="p">)</span> <span class="o">==</span> <span class="n">ALLOWED_ORIGIN</span><span class="p">:</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Access-Control-Allow-Origin"</span><span class="p">,</span> <span class="n">ALLOWED_ORIGIN</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Access-Control-Allow-Methods"</span><span class="p">,</span> <span class="s">"POST"</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Access-Control-Allow-Headers"</span><span class="p">,</span> <span class="s">"Content-Type"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">do_POST</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">length</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
        <span class="n">body</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">rfile</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">length</span><span class="p">)</span>
        <span class="n">ctype</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">STRICT</span> <span class="ow">and</span> <span class="n">ctype</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">";"</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="n">strip</span><span class="p">().</span><span class="n">lower</span><span class="p">()</span> <span class="o">!=</span> <span class="s">"application/json"</span><span class="p">:</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">415</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="s">"0"</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
            <span class="k">return</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"WRITE from Origin=</span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'Origin'</span><span class="p">)</span><span class="si">}</span><span class="s"> "</span>
              <span class="sa">f</span><span class="s">"Content-Type=</span><span class="si">{</span><span class="n">ctype</span><span class="si">!r}</span><span class="s"> body=</span><span class="si">{</span><span class="n">body</span><span class="si">!r}</span><span class="s">"</span><span class="p">,</span> <span class="n">flush</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">200</span><span class="p">)</span>
        <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Origin"</span><span class="p">)</span> <span class="o">==</span> <span class="n">ALLOWED_ORIGIN</span><span class="p">:</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Access-Control-Allow-Origin"</span><span class="p">,</span> <span class="n">ALLOWED_ORIGIN</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="s">"0"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>

<span class="n">ThreadingHTTPServer</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8766</span><span class="p">),</span> <span class="n">Handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">WRITE</code> line stands in for “the data was saved”.</p>

<p>A page served from a <strong>different</strong> origin (port 8767) that sends the same JSON
body twice — once labelled <code class="language-plaintext highlighter-rouge">application/json</code>, once labelled <code class="language-plaintext highlighter-rouge">text/plain</code>. Save it
as <code class="language-plaintext highlighter-rouge">evil/index.html</code> (<code class="language-plaintext highlighter-rouge">mkdir evil</code> first):</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- evil/index.html — served from a different origin (port 8767) --&gt;</span>
<span class="nt">&lt;script&gt;</span>
<span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">http://127.0.0.1:8766/notes</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">log</span> <span class="o">=</span> <span class="p">(</span><span class="nx">m</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">insertAdjacentHTML</span><span class="p">(</span><span class="dl">"</span><span class="s2">beforeend</span><span class="dl">"</span><span class="p">,</span> <span class="s2">`&lt;p&gt;</span><span class="p">${</span><span class="nx">m</span><span class="p">}</span><span class="s2">&lt;/p&gt;`</span><span class="p">);</span>

<span class="c1">// 1. JSON content type: not a "simple" request, so the browser preflights first</span>
<span class="nx">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
             <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span> <span class="p">},</span>
             <span class="na">body</span><span class="p">:</span> <span class="dl">'</span><span class="s1">{"title":"from json"}</span><span class="dl">'</span> <span class="p">})</span>
  <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">json: </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">r</span><span class="p">.</span><span class="nx">status</span><span class="p">),</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">json: </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">e</span><span class="p">));</span>

<span class="c1">// 2. text/plain: a "simple" request, sent straight away with no preflight</span>
<span class="nx">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span> <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
             <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text/plain</span><span class="dl">"</span> <span class="p">},</span>
             <span class="na">body</span><span class="p">:</span> <span class="dl">'</span><span class="s1">{"title":"from text/plain"}</span><span class="dl">'</span> <span class="p">})</span>
  <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">text: </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">r</span><span class="p">.</span><span class="nx">status</span><span class="p">),</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">text: </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">e</span><span class="p">));</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Start the API and serve the page, keeping each PID so you can stop exactly these
processes later. <code class="language-plaintext highlighter-rouge">python3 -u</code> keeps the server’s log unbuffered so you see it
immediately:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-u</span> api.py <span class="o">&gt;</span> api.log 2&gt;&amp;1 &amp; <span class="nv">API</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-m</span> http.server 8767 <span class="nt">--bind</span> 127.0.0.1 <span class="nt">--directory</span> evil <span class="o">&gt;</span> /dev/null 2&gt;&amp;1 &amp; <span class="nv">EVIL</span><span class="o">=</span><span class="nv">$!</span>
</code></pre></div></div>

<p>Then load the page in headless Chrome with a throwaway profile in <code class="language-plaintext highlighter-rouge">/tmp</code> (opening
the URL in normal Chrome and watching the console works too). On my machine
headless Chrome sometimes printed the DOM and then didn’t exit, so I run it in the
background, give it five seconds, and stop it by its PID:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nv">CHROME</span><span class="o">=</span><span class="s2">"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"</span>
<span class="gp">$</span><span class="w"> </span><span class="s2">"</span><span class="nv">$CHROME</span><span class="s2">"</span> <span class="nt">--headless</span> <span class="nt">--user-data-dir</span><span class="o">=</span>/tmp/cors-demo-profile <span class="nt">--no-first-run</span> <span class="se">\</span>
<span class="gp">    --virtual-time-budget=3000 --dump-dom http://127.0.0.1:8767/ &gt;</span><span class="w"> </span>dom.html 2&gt;/dev/null &amp; <span class="nv">TAB</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>5<span class="p">;</span> <span class="nb">kill</span> <span class="nv">$TAB</span> 2&gt;/dev/null<span class="p">;</span> <span class="nb">grep</span> <span class="nt">-o</span> <span class="s1">'&lt;body&gt;.*&lt;/body&gt;'</span> dom.html
<span class="gp">&lt;body&gt;</span>&lt;p&gt;text: TypeError: Failed to fetch&lt;/p&gt;&lt;p&gt;json: TypeError: Failed to fetch&lt;/p&gt;&lt;/body&gt;
</code></pre></div></div>

<p>(The two <code class="language-plaintext highlighter-rouge">&lt;p&gt;</code> lines can come out in either order.)</p>

<p>From the page’s point of view, both requests failed the same way. The server log
tells a different story:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">cat </span>api.log
<span class="go">127.0.0.1 - - [12/Oct/2026 10:10:12] "OPTIONS /notes HTTP/1.1" 204 -
WRITE from Origin=http://127.0.0.1:8767 Content-Type='text/plain' body=b'{"title":"from text/plain"}'
127.0.0.1 - - [12/Oct/2026 10:10:12] "POST /notes HTTP/1.1" 200 -
</span></code></pre></div></div>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">application/json</code> request was preflighted. The <code class="language-plaintext highlighter-rouge">OPTIONS</code> answer had no
<code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> for port 8767, so Chrome <strong>never sent the <code class="language-plaintext highlighter-rouge">POST</code></strong>.</li>
  <li>The <code class="language-plaintext highlighter-rouge">text/plain</code> request had <strong>no preflight</strong>. The <code class="language-plaintext highlighter-rouge">POST</code> reached the handler and
the write ran. Chrome then saw no <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> on the <code class="language-plaintext highlighter-rouge">200</code> and
hid the response from the page. In this test, that was all CORS did for the
simple request: it hid the answer, it didn’t stop the write.</li>
</ul>

<p>The body was JSON both times. The server parsed whatever arrived; only the label
differed, and the label decides whether there’s a preflight.</p>

<p>Stop both servers before the next step (port 8766 has to be free for the fixed
server):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$API</span> <span class="nv">$EVIL</span>
</code></pre></div></div>

<h2 id="fix-refuse-writes-that-arent-applicationjson">Fix: refuse writes that aren’t <code class="language-plaintext highlighter-rouge">application/json</code></h2>

<p><code class="language-plaintext highlighter-rouge">application/json</code> is not one of the three content types a simple request may
carry, so a browser has to preflight it — and the preflight is where your origin
check actually stops a request. Make that the only way in: reject any write whose
<code class="language-plaintext highlighter-rouge">Content-Type</code> isn’t <code class="language-plaintext highlighter-rouge">application/json</code>, <strong>before</strong> processing the request. (<code class="language-plaintext highlighter-rouge">api.py</code>
reads the body before the check, but on a <code class="language-plaintext highlighter-rouge">415</code> it doesn’t use or store it.)
That is the <code class="language-plaintext highlighter-rouge">STRICT</code> branch in <code class="language-plaintext highlighter-rouge">api.py</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">STRICT</span> <span class="ow">and</span> <span class="n">ctype</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">";"</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="n">strip</span><span class="p">().</span><span class="n">lower</span><span class="p">()</span> <span class="o">!=</span> <span class="s">"application/json"</span><span class="p">:</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">415</span><span class="p">)</span>
</code></pre></div></div>

<p>It compares only the media type, so <code class="language-plaintext highlighter-rouge">application/json; charset=utf-8</code> still
passes. Same test with <code class="language-plaintext highlighter-rouge">--strict</code>. This time I also serve a copy of the same page
from the allowed origin (port 8765), to check the legitimate client still works:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">mkdir </span>good <span class="o">&amp;&amp;</span> <span class="nb">cp </span>evil/index.html good/
<span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-u</span> api.py <span class="nt">--strict</span> <span class="o">&gt;</span> api.log 2&gt;&amp;1 &amp; <span class="nv">API</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-m</span> http.server 8767 <span class="nt">--bind</span> 127.0.0.1 <span class="nt">--directory</span> evil <span class="o">&gt;</span> /dev/null 2&gt;&amp;1 &amp; <span class="nv">EVIL</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span>python3 <span class="nt">-m</span> http.server 8765 <span class="nt">--bind</span> 127.0.0.1 <span class="nt">--directory</span> good <span class="o">&gt;</span> /dev/null 2&gt;&amp;1 &amp; <span class="nv">GOOD</span><span class="o">=</span><span class="nv">$!</span>
</code></pre></div></div>

<p>Load the page from the other origin (8767), then from the allowed origin (8765):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> <span class="nt">-rf</span> /tmp/cors-demo-profile
<span class="gp">$</span><span class="w"> </span><span class="s2">"</span><span class="nv">$CHROME</span><span class="s2">"</span> <span class="nt">--headless</span> <span class="nt">--user-data-dir</span><span class="o">=</span>/tmp/cors-demo-profile <span class="nt">--no-first-run</span> <span class="se">\</span>
<span class="gp">    --virtual-time-budget=3000 --dump-dom http://127.0.0.1:8767/ &gt;</span><span class="w"> </span>dom.html 2&gt;/dev/null &amp; <span class="nv">TAB</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>5<span class="p">;</span> <span class="nb">kill</span> <span class="nv">$TAB</span> 2&gt;/dev/null<span class="p">;</span> <span class="nb">grep</span> <span class="nt">-o</span> <span class="s1">'&lt;body&gt;.*&lt;/body&gt;'</span> dom.html
<span class="gp">&lt;body&gt;</span>&lt;p&gt;json: TypeError: Failed to fetch&lt;/p&gt;&lt;p&gt;text: TypeError: Failed to fetch&lt;/p&gt;&lt;/body&gt;
<span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> <span class="nt">-rf</span> /tmp/cors-demo-profile
<span class="gp">$</span><span class="w"> </span><span class="s2">"</span><span class="nv">$CHROME</span><span class="s2">"</span> <span class="nt">--headless</span> <span class="nt">--user-data-dir</span><span class="o">=</span>/tmp/cors-demo-profile <span class="nt">--no-first-run</span> <span class="se">\</span>
<span class="gp">    --virtual-time-budget=3000 --dump-dom http://127.0.0.1:8765/ &gt;</span><span class="w"> </span>dom.html 2&gt;/dev/null &amp; <span class="nv">TAB</span><span class="o">=</span><span class="nv">$!</span>
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>5<span class="p">;</span> <span class="nb">kill</span> <span class="nv">$TAB</span> 2&gt;/dev/null<span class="p">;</span> <span class="nb">grep</span> <span class="nt">-o</span> <span class="s1">'&lt;body&gt;.*&lt;/body&gt;'</span> dom.html
<span class="gp">&lt;body&gt;</span>&lt;p&gt;text: TypeError: Failed to fetch&lt;/p&gt;&lt;p&gt;json: 200&lt;/p&gt;&lt;/body&gt;
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">cat </span>api.log
<span class="go">127.0.0.1 - - [12/Oct/2026 10:10:20] "OPTIONS /notes HTTP/1.1" 204 -
127.0.0.1 - - [12/Oct/2026 10:10:20] "POST /notes HTTP/1.1" 415 -
127.0.0.1 - - [12/Oct/2026 10:10:25] "POST /notes HTTP/1.1" 415 -
127.0.0.1 - - [12/Oct/2026 10:10:25] "OPTIONS /notes HTTP/1.1" 204 -
WRITE from Origin=http://127.0.0.1:8765 Content-Type='application/json' body=b'{"title":"from json"}'
127.0.0.1 - - [12/Oct/2026 10:10:25] "POST /notes HTTP/1.1" 200 -
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">text/plain</code> writes now get <code class="language-plaintext highlighter-rouge">415</code> and no <code class="language-plaintext highlighter-rouge">WRITE</code> line, from either origin. The
only write that ran is the preflighted JSON one from the allowed origin.</p>

<p>For comparison, when I loaded the 8765 page against the server <em>without</em>
<code class="language-plaintext highlighter-rouge">--strict</code>, its <code class="language-plaintext highlighter-rouge">text/plain</code> request also wrote (and got <code class="language-plaintext highlighter-rouge">200</code>) — so if your own front end sends <code class="language-plaintext highlighter-rouge">text/plain</code>, for example
to avoid a preflight round trip, switch it to <code class="language-plaintext highlighter-rouge">application/json</code> before turning
the check on.</p>

<p>Checking the header directly with curl (curl isn’t a browser and doesn’t do CORS;
this only shows the server’s answer):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s1">'%{http_code}\n'</span> <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s1">'Content-Type: text/plain'</span> <span class="nt">-d</span> <span class="s1">'{}'</span> http://127.0.0.1:8766/notes
<span class="go">415
</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s1">'%{http_code}\n'</span> <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s1">'Content-Type: application/json; charset=utf-8'</span> <span class="nt">-d</span> <span class="s1">'{}'</span> http://127.0.0.1:8766/notes
<span class="go">200
</span></code></pre></div></div>

<p>Stop the three servers by the PIDs you saved, and delete the throwaway profile:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> <span class="nv">$API</span> <span class="nv">$EVIL</span> <span class="nv">$GOOD</span>
<span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> <span class="nt">-rf</span> /tmp/cors-demo-profile
</code></pre></div></div>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>“CORS error” in the browser does <strong>not</strong> mean the request didn’t reach the
server. For simple requests it only means the page can’t read the response.</li>
  <li>Simple request = <code class="language-plaintext highlighter-rouge">GET</code>/<code class="language-plaintext highlighter-rouge">HEAD</code>/<code class="language-plaintext highlighter-rouge">POST</code> with only basic headers and a
<code class="language-plaintext highlighter-rouge">Content-Type</code> of <code class="language-plaintext highlighter-rouge">text/plain</code>, <code class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> or
<code class="language-plaintext highlighter-rouge">multipart/form-data</code>. These are sent without a preflight.</li>
  <li>A strict <code class="language-plaintext highlighter-rouge">OPTIONS</code> handler protects nothing against those. Check the server log
for a <code class="language-plaintext highlighter-rouge">POST</code> with no <code class="language-plaintext highlighter-rouge">OPTIONS</code> before it.</li>
  <li>On write endpoints, require <code class="language-plaintext highlighter-rouge">Content-Type: application/json</code> (compare the media
type, ignore parameters) and answer <code class="language-plaintext highlighter-rouge">415</code> otherwise, before processing the request.</li>
  <li>Make sure your own client sends <code class="language-plaintext highlighter-rouge">application/json</code>.</li>
  <li>Keep checking <code class="language-plaintext highlighter-rouge">Origin</code> in the preflight; the content-type rule only makes sure the
preflight happens.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Google Chrome 154.0.8037.99 (headless,
fresh profile), Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>, Command Line Tools), curl 8.7.1.
All servers bound to <code class="language-plaintext highlighter-rouge">127.0.0.1</code>; both origins were local ports on the same machine.
I did not test a public website calling localhost, or other browsers.</em></p>]]></content><author><name></name></author><category term="cors" /><category term="http" /><category term="python" /><category term="chrome" /><category term="security" /><summary type="html"><![CDATA[A cross-origin fetch failed with "TypeError: Failed to fetch", yet the local API had already written the data. A POST with Content-Type text/plain is a CORS "simple request": the browser sends it without an OPTIONS preflight and only blocks reading the response. Reproduced with headless Chrome and a 35-line Python server, then fixed by rejecting writes that aren't application/json (415).]]></summary></entry><entry><title type="html">Python HTTPServer hangs: one idle connection blocks every request (use ThreadingHTTPServer)</title><link href="https://hanminsoo.github.io/2026/10/python-http-server-hangs-idle-connection/" rel="alternate" type="text/html" title="Python HTTPServer hangs: one idle connection blocks every request (use ThreadingHTTPServer)" /><published>2026-10-02T00:00:00+00:00</published><updated>2026-10-02T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/python-http-server-hangs-idle-connection</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/python-http-server-hangs-idle-connection/"><![CDATA[<p>A Python server built on <code class="language-plaintext highlighter-rouge">http.server.HTTPServer</code> can <strong>stop answering</strong> without
a traceback or a log line: requests hang until the client gives up
(<code class="language-plaintext highlighter-rouge">curl: (28) Operation timed out</code>), and then everything works again.</p>

<p>I reproduced this on my Mac. <code class="language-plaintext highlighter-rouge">HTTPServer</code> handles <strong>one connection at a time</strong>. If a
client opens a TCP connection and doesn’t send a request on it — for example a
browser’s speculative connection (preconnect) — the server waits on that connection,
and every other request queues behind it. (I simulated the idle client with a Python
socket below; I did not reproduce it with a browser.)</p>

<h2 id="reproduce-it-in-two-files">Reproduce it in two files</h2>

<p>A minimal server. With <code class="language-plaintext highlighter-rouge">--threading</code> it uses <code class="language-plaintext highlighter-rouge">ThreadingHTTPServer</code>, otherwise the
plain <code class="language-plaintext highlighter-rouge">HTTPServer</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server.py
</span><span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">HTTPServer</span><span class="p">,</span> <span class="n">ThreadingHTTPServer</span><span class="p">,</span> <span class="n">BaseHTTPRequestHandler</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">body</span> <span class="o">=</span> <span class="sa">b</span><span class="s">"ok</span><span class="se">\n</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">200</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Type"</span><span class="p">,</span> <span class="s">"text/plain"</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">body</span><span class="p">)))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>

<span class="n">server_class</span> <span class="o">=</span> <span class="n">ThreadingHTTPServer</span> <span class="k">if</span> <span class="s">"--threading"</span> <span class="ow">in</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span> <span class="k">else</span> <span class="n">HTTPServer</span>
<span class="n">server_class</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8765</span><span class="p">),</span> <span class="n">Handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>And a client that simulates a preconnect: it connects and then says nothing.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># preconnect.py
# Open a TCP connection and send nothing (simulates an idle preconnect).
</span><span class="kn">import</span> <span class="nn">socket</span><span class="p">,</span> <span class="n">time</span>
<span class="n">s</span> <span class="o">=</span> <span class="n">socket</span><span class="p">.</span><span class="n">create_connection</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8765</span><span class="p">))</span>
<span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">30</span><span class="p">)</span>
</code></pre></div></div>

<p>Run the plain server, make one normal request, then open the idle connection and try
again:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py &amp;
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-m</span> 5 <span class="nt">-w</span> <span class="s1">'%{http_code} %{time_total}s\n'</span> http://127.0.0.1:8765/
<span class="go">ok
200 0.002656s
</span><span class="gp">$</span><span class="w"> </span>python3 preconnect.py &amp;
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>0.5
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-m</span> 5 <span class="nt">-w</span> <span class="s1">'%{http_code} %{time_total}s\n'</span> http://127.0.0.1:8765/<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"curl exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">000 5.112399s
curl exit 28
</span><span class="gp">$</span><span class="w"> </span><span class="nb">kill</span> %2      <span class="c"># close the idle connection</span>
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-m</span> 5 <span class="nt">-w</span> <span class="s1">'%{http_code} %{time_total}s\n'</span> http://127.0.0.1:8765/
<span class="go">ok
200 0.001716s
</span></code></pre></div></div>

<p>The second request never got an answer. As soon as the idle connection went away,
the server was healthy again — so the hang can look intermittent from the outside.</p>

<h2 id="where-the-server-is-stuck">Where the server is stuck</h2>

<p>While the request hangs, <code class="language-plaintext highlighter-rouge">sample</code> shows the server’s main thread blocked in a socket
<code class="language-plaintext highlighter-rouge">recv</code> (the full output is long; these are the lines around <code class="language-plaintext highlighter-rouge">recv</code>):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>sample &lt;server-pid&gt; 1
<span class="c">...
</span><span class="go">    776 sock_recv_into  (in _socket.cpython-38-darwin.so) + 268
    776 sock_call_ex  (in _socket.cpython-38-darwin.so) + 368
    776 sock_recv_impl  (in _socket.cpython-38-darwin.so) + 36
    776 __recvfrom  (in libsystem_kernel.dylib) + 8
</span></code></pre></div></div>

<p>The stack doesn’t say <em>which</em> connection that <code class="language-plaintext highlighter-rouge">recv</code> is on. My reading: <code class="language-plaintext highlighter-rouge">HTTPServer</code>
accepts a connection and handles it to completion before calling <code class="language-plaintext highlighter-rouge">accept()</code> again,
and <code class="language-plaintext highlighter-rouge">BaseHTTPRequestHandler.timeout</code> is <code class="language-plaintext highlighter-rouge">None</code> by default, so “to completion” means
“until the client sends something or hangs up”. The idle connection was accepted
first, so the thread is most likely waiting for its request line, while <code class="language-plaintext highlighter-rouge">curl</code> waits
in the listen backlog. That fits the fact that closing the idle connection
immediately unblocked the server.</p>

<h2 id="fix-1-use-threadinghttpserver">Fix 1: use ThreadingHTTPServer</h2>

<p><code class="language-plaintext highlighter-rouge">http.server.ThreadingHTTPServer</code> (Python 3.7+) is <code class="language-plaintext highlighter-rouge">HTTPServer</code> with
<code class="language-plaintext highlighter-rouge">socketserver.ThreadingMixIn</code>: one thread per connection, so an idle connection only
blocks its own thread. It is a one-word change:</p>

<p>Stop the previous server and the idle client first (Ctrl-C them, or <code class="language-plaintext highlighter-rouge">kill</code> the PIDs
the shell printed when you started them).</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server.py <span class="nt">--threading</span> &amp;
<span class="gp">$</span><span class="w"> </span>python3 preconnect.py &amp;
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>0.5
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-m</span> 5 <span class="nt">-w</span> <span class="s1">'%{http_code} %{time_total}s\n'</span> http://127.0.0.1:8765/<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"curl exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">ok
200 0.001708s
curl exit 0
</span></code></pre></div></div>

<p>Per the Python docs its <code class="language-plaintext highlighter-rouge">daemon_threads</code> is <code class="language-plaintext highlighter-rouge">True</code>, so threads stuck on idle connections
shouldn’t keep the process alive when you stop the server (I didn’t test that part).</p>

<p><code class="language-plaintext highlighter-rouge">python3 -m http.server</code> already uses <code class="language-plaintext highlighter-rouge">ThreadingHTTPServer</code> (since 3.7), so the
command-line file server doesn’t show this problem — the same test returned <code class="language-plaintext highlighter-rouge">200</code>
immediately. It bites when you write <code class="language-plaintext highlighter-rouge">HTTPServer(...)</code> in your own code, which is
what many examples and older snippets do.</p>

<h2 id="fix-2-if-you-must-stay-single-threaded-a-per-connection-timeout">Fix 2 (if you must stay single-threaded): a per-connection timeout</h2>

<p>If the handler isn’t safe to run in parallel, at least stop one connection from
holding the server forever. Setting <code class="language-plaintext highlighter-rouge">timeout</code> on the handler applies a socket timeout
to each connection; an idle one is dropped after that many seconds:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># server_timeout.py
</span><span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">HTTPServer</span><span class="p">,</span> <span class="n">BaseHTTPRequestHandler</span>

<span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
    <span class="n">timeout</span> <span class="o">=</span> <span class="mi">2</span>   <span class="c1"># seconds to wait for a request line before dropping the connection
</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">body</span> <span class="o">=</span> <span class="sa">b</span><span class="s">"ok</span><span class="se">\n</span><span class="s">"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="mi">200</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">"Content-Length"</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">body</span><span class="p">)))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>

<span class="n">HTTPServer</span><span class="p">((</span><span class="s">"127.0.0.1"</span><span class="p">,</span> <span class="mi">8765</span><span class="p">),</span> <span class="n">Handler</span><span class="p">).</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<p>Stop the previous server and the idle client first (Ctrl-C them, or <code class="language-plaintext highlighter-rouge">kill</code> the PIDs
the shell printed when you started them).</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>python3 server_timeout.py &amp;
<span class="gp">$</span><span class="w"> </span>python3 preconnect.py &amp;
<span class="gp">$</span><span class="w"> </span><span class="nb">sleep </span>0.5
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-m</span> 5 <span class="nt">-w</span> <span class="s1">'%{http_code} %{time_total}s\n'</span> http://127.0.0.1:8765/<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"curl exit </span><span class="nv">$?</span><span class="s2">"</span>
<span class="go">ok
200 1.448683s
curl exit 0
</span></code></pre></div></div>

<p>The request now succeeds, but only after the idle connection’s timeout runs out
(the idle connection was opened about half a second before <code class="language-plaintext highlighter-rouge">curl</code> — the <code class="language-plaintext highlighter-rouge">sleep 0.5</code>
above — so the wait was ~1.5 s rather than the full 2 s).</p>

<p>With Python 3.12 the server’s stderr log shows the order of events — the idle
connection is dropped first, then <code class="language-plaintext highlighter-rouge">curl</code>’s request is served:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">127.0.0.1 - - [02/Oct/2026 10:04:48] Request timed out: TimeoutError('timed out')
127.0.0.1 - - [02/Oct/2026 10:04:48] "GET / HTTP/1.1" 200 -
</span></code></pre></div></div>

<p>Every idle connection costs every other client up to <code class="language-plaintext highlighter-rouge">timeout</code> seconds — fine
for a script, not for a page that loads several resources. Prefer Fix 1; you can
combine both.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>A Python HTTP server that intermittently stops answering, with no error in its log:
check whether it’s <code class="language-plaintext highlighter-rouge">HTTPServer</code> (single connection at a time).</li>
  <li>Reproduce by opening a TCP connection that sends nothing, then making a request.</li>
  <li>Switch to <code class="language-plaintext highlighter-rouge">ThreadingHTTPServer</code> — same constructor, same handler.</li>
  <li>If you need serial handling, set <code class="language-plaintext highlighter-rouge">timeout</code> on the handler so an idle connection
can’t hold the server forever.</li>
  <li><code class="language-plaintext highlighter-rouge">sample &lt;pid&gt;</code> showing the server’s only thread blocked in <code class="language-plaintext highlighter-rouge">recv</code> is a strong hint.</li>
</ul>

<p><em>Tested on macOS 26.5.2 (Apple silicon) with Python 3.8.2 (<code class="language-plaintext highlighter-rouge">/usr/bin/python3</code>,
Command Line Tools) and Python 3.12.13 (Homebrew), curl 8.7.1. Outputs above are
from 3.8.2 unless noted; the server log lines were checked on 3.12. Server bound to <code class="language-plaintext highlighter-rouge">127.0.0.1</code>
only. The idle client was a Python socket, not a browser.</em></p>]]></content><author><name></name></author><category term="python" /><category term="http" /><category term="macos" /><summary type="html"><![CDATA[A small Python server built on http.server.HTTPServer stopped answering — curl timed out with exit code 28 — while one client held a TCP connection open without sending anything. Reproduced in 20 lines, then fixed with ThreadingHTTPServer or a per-connection timeout.]]></summary></entry><entry><title type="html">Atomic file writes on macOS without losing the file’s creation date</title><link href="https://hanminsoo.github.io/2026/10/atomic-write-keep-creation-date-apfs/" rel="alternate" type="text/html" title="Atomic file writes on macOS without losing the file’s creation date" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/atomic-write-keep-creation-date-apfs</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/atomic-write-keep-creation-date-apfs/"><![CDATA[<p>I keep notes as plain Markdown files, and an app shows each note’s <strong>created</strong> date
from the file’s <code class="language-plaintext highlighter-rouge">birthtime</code>. Saving had one problem: a process killed halfway
through <code class="language-plaintext highlighter-rouge">write()</code> leaves a truncated note. On a small machine that runs out of
memory, the kernel does kill processes, and <code class="language-plaintext highlighter-rouge">SIGKILL</code> gives you no chance to clean up.</p>

<p>The textbook fix is the atomic write: write the new content to a temporary file in
the same directory, then <code class="language-plaintext highlighter-rouge">rename()</code> it over the original. A rename within one
directory is atomic — readers see the old file or the new file, never half of one.</p>

<p>It worked, and every note in the app suddenly said it was <strong>created today</strong>.</p>

<h2 id="what-rename-does-to-birthtime">What rename does to birthtime</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span><span class="p">,</span> <span class="n">time</span>

<span class="k">def</span> <span class="nf">show</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="n">p</span><span class="p">):</span>
    <span class="n">st</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">stat</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
    <span class="n">fmt</span> <span class="o">=</span> <span class="k">lambda</span> <span class="n">t</span><span class="p">:</span> <span class="n">time</span><span class="p">.</span><span class="n">strftime</span><span class="p">(</span><span class="s">"%Y-%m-%d %H:%M:%S"</span><span class="p">,</span> <span class="n">time</span><span class="p">.</span><span class="n">localtime</span><span class="p">(</span><span class="n">t</span><span class="p">))</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">tag</span><span class="si">:</span><span class="mi">22</span><span class="n">s</span><span class="si">}</span><span class="s"> birth=</span><span class="si">{</span><span class="n">fmt</span><span class="p">(</span><span class="n">st</span><span class="p">.</span><span class="n">st_birthtime</span><span class="p">)</span><span class="si">}</span><span class="s">  mtime=</span><span class="si">{</span><span class="n">fmt</span><span class="p">(</span><span class="n">st</span><span class="p">.</span><span class="n">st_mtime</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

<span class="n">p</span> <span class="o">=</span> <span class="s">"note.md"</span>
<span class="nb">open</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="s">"w"</span><span class="p">).</span><span class="n">write</span><span class="p">(</span><span class="s">"v1"</span><span class="p">)</span>
<span class="n">old</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="mi">86400</span> <span class="o">*</span> <span class="mi">30</span>
<span class="n">os</span><span class="p">.</span><span class="n">utime</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="p">(</span><span class="n">old</span><span class="p">,</span> <span class="n">old</span><span class="p">))</span>          <span class="c1"># pretend the note is a month old
</span><span class="n">show</span><span class="p">(</span><span class="s">"original"</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>

<span class="n">tmp</span> <span class="o">=</span> <span class="s">".note.md.tmp"</span>
<span class="nb">open</span><span class="p">(</span><span class="n">tmp</span><span class="p">,</span> <span class="s">"w"</span><span class="p">).</span><span class="n">write</span><span class="p">(</span><span class="s">"v2"</span><span class="p">)</span>
<span class="n">os</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="n">tmp</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>               <span class="c1"># atomic save
</span><span class="n">show</span><span class="p">(</span><span class="s">"after temp+rename"</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">original               birth=2026-09-01 09:45:23  mtime=2026-09-01 09:45:23
after temp+rename      birth=2026-10-01 09:45:23  mtime=2026-10-01 09:45:23
</span></code></pre></div></div>

<p>Of course: the file at that path is now a <em>different</em> file — the temp file — and it
was born a moment ago. The old inode, with the old birthtime, is gone.</p>

<p>(Notice also that setting <code class="language-plaintext highlighter-rouge">mtime</code> a month back in the first step pulled the
<code class="language-plaintext highlighter-rouge">birthtime</code> back with it. That is the behaviour we’ll use.)</p>

<h2 id="the-fix-lower-mtime-to-the-old-birthtime-then-put-mtime-back">The fix: lower mtime to the old birthtime, then put mtime back</h2>

<p>On APFS, when you set a file’s modification time to something <em>earlier</em> than its
birthtime, the birthtime moves back to match. So:</p>

<ol>
  <li>Remember the original file’s <code class="language-plaintext highlighter-rouge">st_birthtime</code> before replacing it.</li>
  <li>Replace the file atomically.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">mtime</code> to the old birthtime — birthtime follows it down.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">mtime</code> back to “now” — birthtime stays where it is.</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">atomic_write</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">data</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">born</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">stat</span><span class="p">(</span><span class="n">path</span><span class="p">).</span><span class="n">st_birthtime</span> <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">exists</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="k">else</span> <span class="bp">None</span>
    <span class="n">tmp</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="ow">or</span> <span class="s">"."</span><span class="p">,</span> <span class="s">"."</span> <span class="o">+</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">basename</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">+</span> <span class="s">".tmp"</span><span class="p">)</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">tmp</span><span class="p">,</span> <span class="s">"w"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">f</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
        <span class="n">f</span><span class="p">.</span><span class="n">flush</span><span class="p">()</span>
        <span class="n">os</span><span class="p">.</span><span class="n">fsync</span><span class="p">(</span><span class="n">f</span><span class="p">.</span><span class="n">fileno</span><span class="p">())</span>
    <span class="n">os</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="n">tmp</span><span class="p">,</span> <span class="n">path</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">born</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">now</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">stat</span><span class="p">(</span><span class="n">path</span><span class="p">).</span><span class="n">st_mtime</span>
        <span class="n">os</span><span class="p">.</span><span class="n">utime</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="p">(</span><span class="n">born</span><span class="p">,</span> <span class="n">born</span><span class="p">))</span>   <span class="c1"># birthtime drops to `born`
</span>        <span class="n">os</span><span class="p">.</span><span class="n">utime</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="p">(</span><span class="n">now</span><span class="p">,</span> <span class="n">now</span><span class="p">))</span>     <span class="c1"># mtime returns; birthtime stays
</span></code></pre></div></div>

<p>Running the same experiment with these two <code class="language-plaintext highlighter-rouge">utime</code> calls:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">after utime(birth)     birth=2026-09-01 09:45:23  mtime=2026-09-01 09:45:23
after restore mtime    birth=2026-09-01 09:45:23  mtime=2026-10-01 09:45:23
</span></code></pre></div></div>

<p>Created date preserved, modified date correct, and the save is still atomic.</p>

<h2 id="details-that-bite">Details that bite</h2>

<ul>
  <li><strong>Same directory.</strong> <code class="language-plaintext highlighter-rouge">rename()</code> is only atomic within one file system; keep the temp
file next to the target, not in <code class="language-plaintext highlighter-rouge">/tmp</code>.</li>
  <li><strong>Hide the temp file.</strong> A leading dot keeps it out of directory listings and file
watchers. If the process dies before the rename, the dot-file is left behind —
sweep <code class="language-plaintext highlighter-rouge">.*.tmp</code> files on the next start, or they pile up unnoticed.</li>
  <li><strong>Birthtime can only move backwards this way.</strong> The trick works because the old
birthtime is always earlier than the new file’s. You can’t push a birthtime
forward with <code class="language-plaintext highlighter-rouge">utime</code>.</li>
  <li><strong>This is APFS behaviour.</strong> It’s what I observe on macOS; don’t rely on it on Linux
file systems, which mostly don’t expose birthtime for writing at all.</li>
  <li><strong>Watchers see two extra events.</strong> The two <code class="language-plaintext highlighter-rouge">utime</code> calls are metadata changes. If
another process reacts to every change, debounce it.</li>
</ul>

<p><em>Tested on macOS 26.5, APFS, Python 3.12.</em></p>]]></content><author><name></name></author><category term="macos" /><category term="apfs" /><category term="python" /><category term="filesystems" /><summary type="html"><![CDATA[Writing to a temp file and renaming it is the safe way to save — but on APFS the renamed file gets a brand-new creation date (birthtime). A two-step utime trick restores it. Tested, with output.]]></summary></entry><entry><title type="html">A launchd job hung for hours opening ~/Downloads (macOS privacy prompt)</title><link href="https://hanminsoo.github.io/2026/10/launchd-python-hangs-opening-downloads/" rel="alternate" type="text/html" title="A launchd job hung for hours opening ~/Downloads (macOS privacy prompt)" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/launchd-python-hangs-opening-downloads</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/launchd-python-hangs-opening-downloads/"><![CDATA[<p>A scheduled job on my Mac mini downloads an image in Chrome and then a small Python
script moves the newest file out of <code class="language-plaintext highlighter-rouge">~/Downloads</code>. Run by hand, it took a second.
Run from a LaunchAgent at 9 a.m., it sat there <strong>for seven hours</strong> doing nothing.</p>

<p>No error, no CPU, no log line. The script had a 90-second retry loop, so it should
have given up long before.</p>

<h2 id="look-at-where-it-is-stuck-not-at-the-code">Look at where it is stuck, not at the code</h2>

<p>When a process is idle and “should” have timed out, ask the kernel where it is
blocked. macOS ships <code class="language-plaintext highlighter-rouge">sample</code>:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>sample &lt;pid&gt; 1
<span class="c">...
</span><span class="go">  783 __opendir2  (in libsystem_c.dylib) + 44
</span><span class="gp">    783 open$</span>NOCANCEL  <span class="o">(</span><span class="k">in </span>libsystem_kernel.dylib<span class="o">)</span> + 64
<span class="go">      783 __open_nocancel  (in libsystem_kernel.dylib) + 8
</span></code></pre></div></div>

<p>Every sample is inside <code class="language-plaintext highlighter-rouge">opendir()</code> → <code class="language-plaintext highlighter-rouge">open()</code>. My retry loop never ran a second time
because the <em>first</em> directory listing never returned. Python’s
<code class="language-plaintext highlighter-rouge">pathlib.Path.glob()</code> was simply waiting on the kernel.</p>

<h2 id="why-opening-a-folder-can-block-forever">Why opening a folder can block forever</h2>

<p><code class="language-plaintext highlighter-rouge">~/Downloads</code>, <code class="language-plaintext highlighter-rouge">~/Desktop</code> and <code class="language-plaintext highlighter-rouge">~/Documents</code> are protected by macOS privacy
controls (TCC). The first time a program you haven’t approved touches them, macOS
asks the user:</p>

<blockquote>
  <p>“Python” would like to access files in your Downloads folder.</p>
</blockquote>

<p>When you run the script from Terminal, Terminal already has that permission, so you
never see the question. When <strong>launchd</strong> starts the script, the request is attributed
to a different process chain, the prompt is raised again — and the <code class="language-plaintext highlighter-rouge">open()</code> call
waits for an answer. If nobody is at the screen (that’s the point of a scheduled
job), it waits indefinitely.</p>

<h2 id="fix-it-once-grant-the-permission">Fix it once: grant the permission</h2>

<p>Either click <strong>Allow</strong> on the prompt, or go to
<strong>System Settings → Privacy &amp; Security → Files and Folders</strong> and enable
<strong>Downloads Folder</strong> for the program that appears there (here: Python).</p>

<p>To be sure the grant applies to the <em>launchd</em> context — not just to your Terminal —
trigger the same access from a throwaway LaunchAgent and check its output:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- ~/Library/LaunchAgents/com.example.downloads-check.plist --&gt;</span>
<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;&lt;dict&gt;</span>
  <span class="nt">&lt;key&gt;</span>Label<span class="nt">&lt;/key&gt;&lt;string&gt;</span>com.example.downloads-check<span class="nt">&lt;/string&gt;</span>
  <span class="nt">&lt;key&gt;</span>ProgramArguments<span class="nt">&lt;/key&gt;&lt;array&gt;</span>
    <span class="nt">&lt;string&gt;</span>/bin/bash<span class="nt">&lt;/string&gt;&lt;string&gt;</span>-c<span class="nt">&lt;/string&gt;</span>
    <span class="nt">&lt;string&gt;</span>/path/to/venv/bin/python -c "import pathlib; print(len(list((pathlib.Path.home()/'Downloads').iterdir())), 'entries')"<span class="nt">&lt;/string&gt;</span>
  <span class="nt">&lt;/array&gt;</span>
  <span class="nt">&lt;key&gt;</span>RunAtLoad<span class="nt">&lt;/key&gt;&lt;true/&gt;</span>
  <span class="nt">&lt;key&gt;</span>StandardOutPath<span class="nt">&lt;/key&gt;&lt;string&gt;</span>/tmp/downloads-check.log<span class="nt">&lt;/string&gt;</span>
  <span class="nt">&lt;key&gt;</span>StandardErrorPath<span class="nt">&lt;/key&gt;&lt;string&gt;</span>/tmp/downloads-check.log<span class="nt">&lt;/string&gt;</span>
<span class="nt">&lt;/dict&gt;&lt;/plist&gt;</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>launchctl bootstrap gui/<span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span> ~/Library/LaunchAgents/com.example.downloads-check.plist
<span class="gp">$</span><span class="w"> </span><span class="nb">cat</span> /tmp/downloads-check.log
<span class="go">5 entries
</span><span class="gp">$</span><span class="w"> </span>launchctl bootout gui/<span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>/com.example.downloads-check
<span class="gp">$</span><span class="w"> </span><span class="nb">rm</span> ~/Library/LaunchAgents/com.example.downloads-check.plist
</code></pre></div></div>

<p>If the log stays empty and the process is alive, the prompt is waiting on screen.</p>

<p>Use the <strong>same interpreter path</strong> your real job uses. A virtualenv’s <code class="language-plaintext highlighter-rouge">python</code> is
usually a symlink to one framework build of Python, and that is the binary macOS
records the permission for.</p>

<h2 id="fix-it-forever-make-the-script-fail-fast">Fix it forever: make the script fail fast</h2>

<p>A grant can be revoked in System Settings, and the next machine won’t
have it. A scheduled job should never hang silently on a permission dialog. Probe the
folder in a background thread and give up after a few seconds:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span><span class="p">,</span> <span class="n">pathlib</span><span class="p">,</span> <span class="n">threading</span>

<span class="n">DOWNLOADS</span> <span class="o">=</span> <span class="n">pathlib</span><span class="p">.</span><span class="n">Path</span><span class="p">.</span><span class="n">home</span><span class="p">()</span> <span class="o">/</span> <span class="s">"Downloads"</span>

<span class="k">def</span> <span class="nf">folder_readable</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="n">pathlib</span><span class="p">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">timeout</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mi">10</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="n">done</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Event</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">peek</span><span class="p">():</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="nb">next</span><span class="p">(</span><span class="n">path</span><span class="p">.</span><span class="n">iterdir</span><span class="p">(),</span> <span class="bp">None</span><span class="p">)</span>
        <span class="k">finally</span><span class="p">:</span>
            <span class="n">done</span><span class="p">.</span><span class="nb">set</span><span class="p">()</span>

    <span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">peek</span><span class="p">,</span> <span class="n">daemon</span><span class="o">=</span><span class="bp">True</span><span class="p">).</span><span class="n">start</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">done</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">timeout</span><span class="p">)</span>

<span class="k">if</span> <span class="ow">not</span> <span class="n">folder_readable</span><span class="p">(</span><span class="n">DOWNLOADS</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Cannot open </span><span class="si">{</span><span class="n">DOWNLOADS</span><span class="si">}</span><span class="s">: a macOS privacy prompt is probably waiting. "</span>
          <span class="s">"Allow it, or enable it in System Settings → Privacy &amp; Security → "</span>
          <span class="s">"Files and Folders."</span><span class="p">,</span> <span class="n">flush</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">os</span><span class="p">.</span><span class="n">_exit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>   <span class="c1"># the blocked thread can't be joined, so don't wait for it
</span></code></pre></div></div>

<p>Two details matter:</p>

<ul>
  <li>The blocked call can’t be cancelled from Python, so the probe runs in a <strong>daemon
thread</strong> and the main thread only waits on an <code class="language-plaintext highlighter-rouge">Event</code>.</li>
  <li>Exit with <code class="language-plaintext highlighter-rouge">os._exit()</code>. It ends the process immediately without running
interpreter cleanup while another thread is still parked inside a system call —
there is nothing useful left to clean up anyway.</li>
</ul>

<p>Now the job ends in ten seconds with a message that says exactly what to click,
instead of holding the morning’s work hostage until someone notices.</p>

<h2 id="checklist">Checklist</h2>

<ul>
  <li>A launchd job that touches <code class="language-plaintext highlighter-rouge">~/Downloads</code>, <code class="language-plaintext highlighter-rouge">~/Desktop</code> or <code class="language-plaintext highlighter-rouge">~/Documents</code> needs its
own privacy grant — the one your Terminal has doesn’t count.</li>
  <li>An idle, never-finishing process: run <code class="language-plaintext highlighter-rouge">sample &lt;pid&gt;</code> before reading code.</li>
  <li>Trigger the access once from a throwaway LaunchAgent to raise and approve the prompt.</li>
  <li>Guard the first access with a timeout so the job fails loudly instead of hanging.</li>
</ul>

<p><em>Tested on macOS 26.5 (Apple silicon), Python 3.12, a LaunchAgent in the <code class="language-plaintext highlighter-rouge">gui/&lt;uid&gt;</code>
domain.</em></p>]]></content><author><name></name></author><category term="macos" /><category term="launchd" /><category term="tcc" /><category term="python" /><summary type="html"><![CDATA[A Python script started by a LaunchAgent froze inside opendir() on ~/Downloads. It was waiting for a macOS privacy (TCC) prompt nobody could see. How to spot it with `sample`, grant access once, and make the script fail fast next time.]]></summary></entry><entry><title type="html">Four launchd traps: Desktop scripts, logged-out agents, keychain-less daemons, and PATH</title><link href="https://hanminsoo.github.io/2026/10/launchd-traps-agent-daemon-keychain/" rel="alternate" type="text/html" title="Four launchd traps: Desktop scripts, logged-out agents, keychain-less daemons, and PATH" /><published>2026-10-01T00:00:00+00:00</published><updated>2026-10-01T00:00:00+00:00</updated><id>https://hanminsoo.github.io/2026/10/launchd-traps-agent-daemon-keychain</id><content type="html" xml:base="https://hanminsoo.github.io/2026/10/launchd-traps-agent-daemon-keychain/"><![CDATA[<p><code class="language-plaintext highlighter-rouge">launchd</code> is a great scheduler and a terrible narrator. When a job fails it usually
says nothing at all. These are the four failures that cost me the most time on a
Mac mini that runs a small server and a few daily jobs.</p>

<p>The one command to remember for all of them:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span>launchctl print gui/<span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>/com.example.job      <span class="c"># a LaunchAgent</span>
<span class="gp">$</span><span class="w"> </span><span class="nb">sudo </span>launchctl print system/com.example.job       <span class="c"># a LaunchDaemon</span>
</code></pre></div></div>

<p>Look at <code class="language-plaintext highlighter-rouge">state</code> and <code class="language-plaintext highlighter-rouge">last exit code</code>.</p>

<h2 id="1-the-script-lives-in-desktop-or-documents-or-downloads">1. The script lives in ~/Desktop (or Documents, or Downloads)</h2>

<p>I reproduced this one just now with a two-line script:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">cat</span> ~/Desktop/launchd-desktop-test.sh
<span class="gp">#</span><span class="o">!</span>/bin/bash
<span class="go">echo ran-ok

</span><span class="gp">$</span><span class="w"> </span>launchctl bootstrap gui/<span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span> ~/Library/LaunchAgents/com.example.desktop-test.plist
<span class="gp">$</span><span class="w"> </span>launchctl print gui/<span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>/com.example.desktop-test | <span class="nb">grep</span> <span class="s2">"last exit"</span>
<span class="go">	last exit code = 126
</span><span class="gp">$</span><span class="w"> </span><span class="nb">cat</span> /tmp/desktop-test.log
<span class="go">/bin/bash: /Users/me/Desktop/launchd-desktop-test.sh: Operation not permitted
</span></code></pre></div></div>

<p>The same script runs fine from Terminal. <code class="language-plaintext highlighter-rouge">~/Desktop</code>, <code class="language-plaintext highlighter-rouge">~/Documents</code> and
<code class="language-plaintext highlighter-rouge">~/Downloads</code> are privacy-protected folders; Terminal has been granted access to
them, a job started by launchd has not. If you didn’t set <code class="language-plaintext highlighter-rouge">StandardErrorPath</code>, the
only trace is that exit code.</p>

<p>Interestingly, a job whose <code class="language-plaintext highlighter-rouge">WorkingDirectory</code> is on the Desktop but whose program
lives elsewhere ran fine in my test — it’s reading the script file that fails.</p>

<p><strong>Fix:</strong> keep anything launchd runs outside the protected folders — a project
directory under your home, <code class="language-plaintext highlighter-rouge">~/bin</code>, <code class="language-plaintext highlighter-rouge">/usr/local/bin</code>. (A <em>different</em> symptom of
the same protection, a job hanging while it opens <code class="language-plaintext highlighter-rouge">~/Downloads</code>, is in
<a href="/2026/10/launchd-python-hangs-opening-downloads/">the previous post</a>.)</p>

<h2 id="2-the-launchagent-disappeared-when-nobody-was-logged-in">2. The LaunchAgent disappeared when nobody was logged in</h2>

<p>A LaunchAgent in <code class="language-plaintext highlighter-rouge">~/Library/LaunchAgents</code> is loaded into your <strong>login session</strong>
(<code class="language-plaintext highlighter-rouge">gui/&lt;uid&gt;</code>). It runs while that session exists. If the session ends — you log
out, or the GUI session crashes and the Mac sits at the login window — every agent
in it stops, and nothing is written to your job’s error log, because the job
didn’t crash. It just isn’t there.</p>

<p>From across the room a Mac at the login window looks exactly like a Mac that is
working. I lost a home server for two days this way before noticing.</p>

<p><strong>Fix:</strong> a service that must survive logouts belongs in <code class="language-plaintext highlighter-rouge">/Library/LaunchDaemons</code>.
You can still run it as your normal user — that’s what <code class="language-plaintext highlighter-rouge">UserName</code> is for — so files
it creates keep the right owner:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;key&gt;</span>UserName<span class="nt">&lt;/key&gt;&lt;string&gt;</span>me<span class="nt">&lt;/string&gt;</span>
<span class="nt">&lt;key&gt;</span>EnvironmentVariables<span class="nt">&lt;/key&gt;</span>
<span class="nt">&lt;dict&gt;</span>
  <span class="nt">&lt;key&gt;</span>HOME<span class="nt">&lt;/key&gt;&lt;string&gt;</span>/Users/me<span class="nt">&lt;/string&gt;</span>
  <span class="nt">&lt;key&gt;</span>PATH<span class="nt">&lt;/key&gt;&lt;string&gt;</span>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin<span class="nt">&lt;/string&gt;</span>
<span class="nt">&lt;/dict&gt;</span>
</code></pre></div></div>

<p>Daemons start with an almost empty environment, so set <code class="language-plaintext highlighter-rouge">HOME</code> explicitly — many
tools look for their config under <code class="language-plaintext highlighter-rouge">$HOME</code> and fail in confusing ways without it.</p>

<h2 id="3-the-daemon-cant-use-the-login-keychain">3. The daemon can’t use the login keychain</h2>

<p>Moving to a daemon fixed uptime and broke one feature: the server shells out to a
command-line tool that keeps its login token in the <strong>login keychain</strong>. From the
daemon, the tool reported:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Not logged in · Please run /login
</code></pre></div></div>

<p>…and exited 1, while the server itself kept running happily. Setting <code class="language-plaintext highlighter-rouge">HOME</code> and
<code class="language-plaintext highlighter-rouge">USER</code> did not help.</p>

<p>The reason: a system daemon runs <strong>outside any login session</strong>. You can see it:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gp">$</span><span class="w"> </span><span class="nb">id</span> <span class="nt">-A</span>        <span class="c"># from Terminal</span>
<span class="go">auid=501
</span><span class="gp">$</span><span class="w"> </span><span class="nb">id</span> <span class="nt">-A</span>        <span class="c"># from the daemon</span>
<span class="go">auid=-1
</span></code></pre></div></div>

<p>The login keychain belongs to the login session, so it isn’t in the daemon’s
keychain search list, no matter which user the daemon runs as.</p>

<p><strong>Fix (without giving up the daemon):</strong> when that one tool is needed, hand the
work <em>into</em> the user’s session. A daemon running as the same uid is allowed to
bootstrap a short-lived job into <code class="language-plaintext highlighter-rouge">gui/&lt;uid&gt;</code>; there the keychain opens normally.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># run-in-session.sh CMD ARGS... — run CMD inside the logged-in user's session.</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail
<span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-A</span> | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span><span class="s2">"</span> <span class="o">!=</span> <span class="s2">"auid=-1"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then </span><span class="nb">exec</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">fi</span>   <span class="c"># already in a session</span>

<span class="nv">LABEL</span><span class="o">=</span>com.example.in-session
<span class="nv">ROOM</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span> <span class="nt">-d</span><span class="si">)</span><span class="s2">"</span><span class="p">;</span> <span class="nb">trap</span> <span class="s1">'rm -rf "$ROOM"'</span> EXIT
<span class="nb">cat</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/stdin"</span>
<span class="o">{</span> <span class="nb">printf</span> <span class="s1">'#!/bin/bash\n'</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">'%q '</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
  <span class="nb">printf</span> <span class="s1">'&lt; %q &gt; %q 2&gt; %q\necho $? &gt; %q\n'</span> <span class="se">\</span>
    <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/stdin"</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/out"</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/err"</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/code"</span><span class="p">;</span> <span class="o">}</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/run"</span>
<span class="nb">chmod</span> +x <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/run"</span>
<span class="nb">cat</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/job.plist"</span> <span class="o">&lt;&lt;</span><span class="no">PLIST</span><span class="sh">
&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&gt;
&lt;plist version="1.0"&gt;&lt;dict&gt;
  &lt;key&gt;Label&lt;/key&gt;&lt;string&gt;</span><span class="nv">$LABEL</span><span class="sh">&lt;/string&gt;
  &lt;key&gt;ProgramArguments&lt;/key&gt;&lt;array&gt;&lt;string&gt;</span><span class="nv">$ROOM</span><span class="sh">/run&lt;/string&gt;&lt;/array&gt;
  &lt;key&gt;RunAtLoad&lt;/key&gt;&lt;true/&gt;
&lt;/dict&gt;&lt;/plist&gt;
</span><span class="no">PLIST
</span><span class="nv">GUI</span><span class="o">=</span><span class="s2">"gui/</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span><span class="s2">"</span>
launchctl bootout <span class="s2">"</span><span class="nv">$GUI</span><span class="s2">/</span><span class="nv">$LABEL</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
</span>launchctl bootstrap <span class="s2">"</span><span class="nv">$GUI</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/job.plist"</span>
<span class="k">while</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/code"</span> <span class="o">]</span><span class="p">;</span> <span class="k">do
  </span>launchctl print <span class="s2">"</span><span class="nv">$GUI</span><span class="s2">/</span><span class="nv">$LABEL</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1 <span class="o">||</span> <span class="o">{</span> <span class="nb">sleep </span>0.3<span class="p">;</span> <span class="o">[</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/code"</span> <span class="o">]</span> <span class="o">||</span> <span class="nb">break</span><span class="p">;</span> <span class="o">}</span>
  <span class="nb">sleep </span>0.2
<span class="k">done
</span>launchctl bootout <span class="s2">"</span><span class="nv">$GUI</span><span class="s2">/</span><span class="nv">$LABEL</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
cat</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/out"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
cat</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/err"</span> <span class="o">&gt;</span>&amp;2 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
exit</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$ROOM</span><span class="s2">/code"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo </span>70<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>The caller sees the same contract as the original tool: arguments in, stdin in,
stdout/stderr/exit code out. If nobody is logged in, only this one feature fails —
the rest of the daemon stays up, which is the trade I wanted.</p>

<p>Two notes:</p>

<ul>
  <li><strong>Read stdout too.</strong> The tool printed “Not logged in” on <em>stdout</em> and nothing on
stderr. If your server only logs stderr, you’ll just see “exited with 1”.</li>
  <li>Don’t “fix” this by turning the daemon back into an agent. That trades a broken
button for trap #2.</li>
</ul>

<h2 id="4-launchds-path-is-not-your-path">4. launchd’s PATH is not your PATH</h2>

<p>Jobs get a minimal <code class="language-plaintext highlighter-rouge">PATH</code> (<code class="language-plaintext highlighter-rouge">/usr/bin:/bin:/usr/sbin:/sbin</code>). Homebrew’s
<code class="language-plaintext highlighter-rouge">/opt/homebrew/bin</code> is not in it, so <code class="language-plaintext highlighter-rouge">ffmpeg</code>, <code class="language-plaintext highlighter-rouge">node</code> or <code class="language-plaintext highlighter-rouge">python3</code> from Homebrew
“aren’t installed” as far as your job is concerned.</p>

<p><strong>Fix:</strong> use absolute paths in <code class="language-plaintext highlighter-rouge">ProgramArguments</code>, or set <code class="language-plaintext highlighter-rouge">PATH</code> in
<code class="language-plaintext highlighter-rouge">EnvironmentVariables</code> (as above). For tools whose location you discover at
install time, write the resolved path into the plist when you install the job,
rather than hoping it’s on <code class="language-plaintext highlighter-rouge">PATH</code> at run time.</p>

<h2 id="checklist">Checklist</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Likely trap</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">last exit code = 126</code>, “Operation not permitted”</td>
      <td>script in a privacy-protected folder</td>
    </tr>
    <tr>
      <td>Job simply isn’t running, no errors anywhere</td>
      <td>LaunchAgent and the login session ended</td>
    </tr>
    <tr>
      <td>A CLI says “not logged in” only under launchd</td>
      <td>daemon outside the session, no login keychain</td>
    </tr>
    <tr>
      <td>“command not found” only under launchd</td>
      <td>minimal <code class="language-plaintext highlighter-rouge">PATH</code></td>
    </tr>
  </tbody>
</table>

<p><em>Tested on macOS 26.5. Trap 1 reproduced for this post; traps 2–4 are from running
a home server on the same machine.</em></p>]]></content><author><name></name></author><category term="macos" /><category term="launchd" /><category term="keychain" /><summary type="html"><![CDATA[Things that silently broke my scheduled jobs on macOS: a script in ~/Desktop exiting 126, a LaunchAgent that vanished when the login session ended, a LaunchDaemon that couldn't read the login keychain, and launchd's minimal PATH. How to see each one and what to do instead.]]></summary></entry></feed>