<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mustafa ERBAY</title>
    <description>The latest articles on DEV Community by Mustafa ERBAY (@merbayerp).</description>
    <link>https://dev.to/merbayerp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3921203%2Fe3a198a1-49a0-466f-99e6-74bdf202a867.png</url>
      <title>DEV Community: Mustafa ERBAY</title>
      <link>https://dev.to/merbayerp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/merbayerp"/>
    <language>en</language>
    <item>
      <title>fsck Found the Draft That reflog Never Saw; Two Weeks on Paper</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 15 Nov 2026 14:26:15 +0000</pubDate>
      <link>https://dev.to/merbayerp/fsck-found-the-draft-that-reflog-never-saw-two-weeks-on-paper-58ke</link>
      <guid>https://dev.to/merbayerp/fsck-found-the-draft-that-reflog-never-saw-two-weeks-on-paper-58ke</guid>
      <description>&lt;p&gt;Everyone who works with Git carries one reassuring sentence in their head: "it'll be in the reflog anyway." That sentence is as correct as it is incomplete. A reflog records where the tips of refs have moved — in other words, everything that happens &lt;em&gt;after&lt;/em&gt; a piece of work becomes a commit. Work that disappears before it becomes a commit sits in a place the reflog never looks at, and how long it sits there depends on an entirely different setting.&lt;/p&gt;

&lt;p&gt;So this week I sat down and put numbers on it, because there is a garbage collector running on my machine every morning at 04:30, and that collector deletes half-finished working directories. I wanted to know the exact distance between "it deletes them" and "data is gone."&lt;/p&gt;

&lt;p&gt;Here is the current inventory in the blog repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ /usr/bin/git count-objects -v
count: 1179
size: 30640
in-pack: 49845
packs: 21
size-pack: 336528
prune-packable: 34
garbage: 0
size-garbage: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;49,845 packed objects and 3,675 commits on &lt;code&gt;origin/main&lt;/code&gt;. Now the interesting part — how many of those objects are visible from no branch tip and no tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ /usr/bin/git fsck --unreachable --no-progress &lt;span class="o"&gt;|&lt;/span&gt; wc -l
2089
$ /usr/bin/git fsck --unreachable --no-reflogs --no-progress &lt;span class="o"&gt;|&lt;/span&gt; wc -l
2161
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One flag is the only difference between those two commands, and the gap between them is 72 objects. Those 72 are the work still counted as "protected" thanks to the reflog. Behind the remaining 2,089, there is nothing at all. By type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reflogs as roots            with --no-reflogs
1723 blob                   1729 blob
 103 commit                  118 commit
 263 tree                    314 tree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1,723 blobs. Rejected drafts, earlier states of &lt;code&gt;--amend&lt;/code&gt;ed commits, leftovers from deleted working directories. All of them on my disk, all of them readable, none of them with a name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the collector deletes, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Some context: Claude Code agents open a &lt;code&gt;git worktree&lt;/code&gt; under the repository for each task, and those pile up until the disk fills. At the end of September that pileup took my Mac's data volume to 100%. The collector I wrote that day runs under launchd every day at 04:30 and deletes a worktree that passes three gates: no tracked changes, not newer than two days, and no commits on HEAD that never reached a remote.&lt;/p&gt;

&lt;p&gt;Its running total, from its own log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ grep -c &lt;span class="s1"&gt;'SILINDI'&lt;/span&gt; ~/.claude/scripts/worktree-gc.log
176
$ grep &lt;span class="s1"&gt;'SILINDI'&lt;/span&gt; ~/.claude/scripts/worktree-gc.log &lt;span class="o"&gt;|&lt;/span&gt; grep -c mustafaerbay
29
$ grep OZET ~/.claude/scripts/worktree-gc.log &lt;span class="o"&gt;|&lt;/span&gt; tail -1
2026-11-15 04:30:16 OZET &lt;span class="nv"&gt;silindi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nv"&gt;korundu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;29 &lt;span class="nv"&gt;kazanc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;404MB &lt;span class="nv"&gt;bosalan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20GB &lt;span class="nv"&gt;dry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;176 working directories deleted, 29 of them in this blog repository. In the comment at the top of the script I had written, in my own hand: "deleting is not data loss — the branch ref and the objects stay in the main repo." I did not want to run it for another year without testing how much of that sentence is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three loss modes, three different answers
&lt;/h2&gt;

&lt;p&gt;I set up three loss scenarios in a clean lab. Same repository, same minute, same Git version: &lt;code&gt;/usr/bin/git&lt;/code&gt; 2.54.0 (Apple Git-157). To keep stray settings out of the results I pointed &lt;code&gt;GIT_CONFIG_GLOBAL&lt;/code&gt; and &lt;code&gt;GIT_CONFIG_SYSTEM&lt;/code&gt; at &lt;code&gt;/dev/null&lt;/code&gt; — a line in my own &lt;code&gt;~/.gitconfig&lt;/code&gt; had no business skewing the numbers.&lt;/p&gt;

&lt;p&gt;The scenarios: (1) write a file, never &lt;code&gt;git add&lt;/code&gt; it, delete it. (2) &lt;code&gt;git add&lt;/code&gt; it, don't commit, then take a &lt;code&gt;git reset --hard&lt;/code&gt;. (3) commit it, then step back with &lt;code&gt;git reset --hard HEAD~1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A1 untracked : expected blob 9d8e786752d6084b87d0712679878e243c8f8f47 -&amp;gt; object present? NO
A2 staged    : blob 756d8d4457346ad95d7fb3d51222ece2352462b3 -&amp;gt; object present? YES
A3 commit    : commit d8c76e02b01d09bd2ab78033af99fd98e9fcea17 -&amp;gt; object present? YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line settles the whole argument. A file you never &lt;code&gt;git add&lt;/code&gt;ed leaves no trace on the Git side — no blob, no name, no size. &lt;code&gt;git hash-object&lt;/code&gt; can compute what its name &lt;em&gt;would be&lt;/em&gt;, but there is no object behind that name. The real form of the recovery question in Git is not "do I have a backup," it is &lt;strong&gt;"did this work ever become an object."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had bumped into that sentence once before: when I &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/assume-unchanged-biti-koydum-13-komuttan-7si-dinlemedi/" rel="noopener noreferrer"&gt;set the assume-unchanged bit and found seven of thirteen commands ignoring it&lt;/a&gt;, I noted in a single line that the lost change had nothing in the reflog either. That was an aside then; this article is that aside, measured.&lt;/p&gt;

&lt;p&gt;In the second and third scenarios the object is there. But does the reflog see them?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git reflog --date=iso
2c996a2 HEAD@&lt;span class="o"&gt;{&lt;/span&gt;...&lt;span class="o"&gt;}&lt;/span&gt;: reset: moving to HEAD~1
d8c76e0 HEAD@&lt;span class="o"&gt;{&lt;/span&gt;...&lt;span class="o"&gt;}&lt;/span&gt;: commit: silinecek commit
2c996a2 HEAD@&lt;span class="o"&gt;{&lt;/span&gt;...&lt;span class="o"&gt;}&lt;/span&gt;: reset: moving to HEAD
2c996a2 HEAD@&lt;span class="o"&gt;{&lt;/span&gt;...&lt;span class="o"&gt;}&lt;/span&gt;: commit &lt;span class="o"&gt;(&lt;/span&gt;initial&lt;span class="o"&gt;)&lt;/span&gt;: ilk commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third scenario's commit is right there on the second line, by name. The second scenario's blob appears on no line at all — &lt;code&gt;git reflog | grep &amp;lt;blob&amp;gt;&lt;/code&gt; returns zero. That is the documented behaviour: the reflog docs say reflogs record when "the tips of branches and other references were updated." The index is not a reference. &lt;code&gt;git add&lt;/code&gt; is not a ref movement. So it has no place in the reflog.&lt;/p&gt;

&lt;p&gt;What follows from this is that the two tools are not backups for each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git fsck --unreachable
unreachable blob 756d8d4457346ad95d7fb3d51222ece2352462b3

$ git fsck --unreachable --no-reflogs | sort
unreachable blob 65271b600c21b07fa5711bec44cc7fc13077b0ad
unreachable blob 756d8d4457346ad95d7fb3d51222ece2352462b3
unreachable commit d8c76e02b01d09bd2ab78033af99fd98e9fcea17
unreachable tree 1bd47fcd374c27eda598a1ea6c1c86faaa831e5d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;fsck&lt;/code&gt; does not sort its output; I piped it through &lt;code&gt;sort&lt;/code&gt; to make it readable.)&lt;/p&gt;

&lt;p&gt;One line, four lines. The default &lt;code&gt;fsck&lt;/code&gt; counts the reflog as a root too — its own documentation puts it as "all reflogs (unless --no-reflogs is given) as heads." So a commit the reflog protects is not even lost in &lt;code&gt;fsck&lt;/code&gt;'s eyes. That is usually why people are surprised that "fsck found nothing": what they are looking for is still in the reflog and will come back from &lt;code&gt;git reflog&lt;/code&gt; in a single line.&lt;/p&gt;

&lt;p&gt;The reverse holds too, and that is where it bites. Work that never became a commit lives where &lt;code&gt;git reflog&lt;/code&gt; will never show it; only &lt;code&gt;fsck&lt;/code&gt; finds it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtMb3N0IHdvcmtdIC0tPiBCe1dhcyBpdCBldmVyIGdpdCBhZGRlZH0KICBCIC0tIE5vIC0tPiBDW05vIG9iamVjdCBleGlzdHM8YnIvPkdpdCBjYW5ub3QgaGVscDxici8-Y2hlY2sgZmlsZXN5c3RlbSBiYWNrdXBdCiAgQiAtLSBZZXMgLS0-IER7RGlkIGl0IGJlY29tZSBhIGNvbW1pdH0KICBEIC0tIFllcyAtLT4gRVtnaXQgcmVmbG9nPGJyLz5yZWYgbW92ZW1lbnQgcmVjb3JkZWRdCiAgRCAtLSBObyAtLT4gRltnaXQgZnNjayAtLXVucmVhY2hhYmxlPGJyLz5ubyByZWZsb2cgdHJhY2VdCiAgRSAtLT4gR3tXYXMgdGhlIHJlZmxvZyBlbnRyeSBwcnVuZWR9CiAgRyAtLSBObyAtLT4gSFtnaXQgYnJhbmNoIHJlc2N1ZSBTSEFdCiAgRyAtLSBZZXMgLS0-IEYKICBGIC0tPiBJe0lzIHRoZSBnYyBwcnVuZSB3aW5kb3cgb3Blbn0KICBJIC0tIFllcyAtLT4gSltnaXQgY2F0LWZpbGUgLXAgU0hBPGJyLz5jb250ZW50IGNvbWVzIGJhY2tdCiAgSSAtLSBObyAtLT4gS1tPYmplY3QgcHJ1bmVkPGJyLz5ubyByZWNvdmVyeV0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtMb3N0IHdvcmtdIC0tPiBCe1dhcyBpdCBldmVyIGdpdCBhZGRlZH0KICBCIC0tIE5vIC0tPiBDW05vIG9iamVjdCBleGlzdHM8YnIvPkdpdCBjYW5ub3QgaGVscDxici8-Y2hlY2sgZmlsZXN5c3RlbSBiYWNrdXBdCiAgQiAtLSBZZXMgLS0-IER7RGlkIGl0IGJlY29tZSBhIGNvbW1pdH0KICBEIC0tIFllcyAtLT4gRVtnaXQgcmVmbG9nPGJyLz5yZWYgbW92ZW1lbnQgcmVjb3JkZWRdCiAgRCAtLSBObyAtLT4gRltnaXQgZnNjayAtLXVucmVhY2hhYmxlPGJyLz5ubyByZWZsb2cgdHJhY2VdCiAgRSAtLT4gR3tXYXMgdGhlIHJlZmxvZyBlbnRyeSBwcnVuZWR9CiAgRyAtLSBObyAtLT4gSFtnaXQgYnJhbmNoIHJlc2N1ZSBTSEFdCiAgRyAtLSBZZXMgLS0-IEYKICBGIC0tPiBJe0lzIHRoZSBnYyBwcnVuZSB3aW5kb3cgb3Blbn0KICBJIC0tIFllcyAtLT4gSltnaXQgY2F0LWZpbGUgLXAgU0hBPGJyLz5jb250ZW50IGNvbWVzIGJhY2tdCiAgSSAtLSBObyAtLT4gS1tPYmplY3QgcHJ1bmVkPGJyLz5ubyByZWNvdmVyeV0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To see that the content really comes back, &lt;code&gt;cat-file&lt;/code&gt; is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git cat-file -p 756d8d4457346ad95d7fb3d51222ece2352462b3
staged taslak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather not copy SHAs by hand, &lt;code&gt;fsck&lt;/code&gt; has a mode that writes to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git fsck --lost-found
$ find .git/lost-found -type f
.git/lost-found/commit/d8c76e02b01d09bd2ab78033af99fd98e9fcea17
.git/lost-found/other/756d8d4457346ad95d7fb3d51222ece2352462b3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commits land under &lt;code&gt;commit/&lt;/code&gt;, everything else under &lt;code&gt;other/&lt;/code&gt;. For blobs the file's &lt;em&gt;name&lt;/em&gt; is the SHA and its &lt;em&gt;content&lt;/em&gt; is the blob itself — in the docs' words, "the contents are written into the file, rather than its object name." Which means you can walk into &lt;code&gt;other/&lt;/code&gt; and skim your drafts with &lt;code&gt;head&lt;/code&gt;. Staring at a directory full of nameless files is not a pleasant experience, but it works.&lt;/p&gt;

&lt;p&gt;A careful reader will stumble here: &lt;code&gt;--unreachable&lt;/code&gt; gave one line above, yet &lt;code&gt;--lost-found&lt;/code&gt; wrote the reflog-protected commit to disk too. Not a contradiction — a separate behaviour, and one the docs don't mention. I separated the two in a clean repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git fsck --unreachable --no-progress
&lt;span class="o"&gt;(&lt;/span&gt;no output&lt;span class="o"&gt;)&lt;/span&gt;
$ git fsck --lost-found --no-progress
dangling commit 6a9eca0b9f4a103672772c567ac949f53682b5f4
$ find .git/lost-found -type f
.git/lost-found/commit/6a9eca0b9f4a103672772c567ac949f53682b5f4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--unreachable&lt;/code&gt; finds nothing, because the commit is reachable from the reflog. &lt;code&gt;--lost-found&lt;/code&gt; looks at &lt;em&gt;dangling&lt;/em&gt; objects and does not treat reflogs as roots; it both lists that commit and writes it out. In practice this helps: a commit you know is in the reflog but cannot find the right line for, &lt;code&gt;--lost-found&lt;/code&gt; hands you as a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deleted worktree: prune takes the reflog with it
&lt;/h2&gt;

&lt;p&gt;Now to my own scenario. A main repository, one linked worktree, and inside that worktree a commit, a staged file, and a file that was never added. Then I removed the directory with &lt;code&gt;rm -rf&lt;/code&gt; and ran &lt;code&gt;git worktree prune&lt;/code&gt; — exactly what my collector does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### produced inside the worktree
  where are the objects? objects dir: .../ana/.git/objects
  per-worktree HEAD reflog: .../ana/.git/worktrees/is/logs/HEAD

### worktree directory DELETED (rm -rf) + git worktree prune
  worktrees/ admin dir left: 0 entries
  per-worktree reflog file: GONE

### looking from the main repo, are the objects there?
  commit 15edfb1...      : PRESENT
  staged blob d0b5675... : PRESENT
  untracked 42a97a7...   : GONE
  does branch 'is' still exist: YES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things at once. First: the object database is shared. A worktree has no &lt;code&gt;objects&lt;/code&gt; directory of its own; it uses the main repository's — the repository-layout doc says the &lt;code&gt;objects&lt;/code&gt; entry is redirected to the common directory when &lt;code&gt;$GIT_COMMON_DIR&lt;/code&gt; is set. So every object you produce in a worktree stays in the main repo even after the worktree is gone. On that point the sentence in my script's comment was right.&lt;/p&gt;

&lt;p&gt;Second: &lt;code&gt;git worktree prune&lt;/code&gt; &lt;strong&gt;deletes&lt;/strong&gt; that worktree's HEAD reflog. The &lt;code&gt;git-worktree&lt;/code&gt; doc does not say so; it only says it removes the information under &lt;code&gt;$GIT_DIR/worktrees&lt;/code&gt; for worktrees whose working trees are missing. The reflog file lives inside that directory (&lt;code&gt;worktrees/is/logs/HEAD&lt;/code&gt;), so it goes along with it.&lt;/p&gt;

&lt;p&gt;I wondered which of the two steps actually does the deleting, so I separated them — the reflog file sits inside the main repository, not inside the directory being removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1) while the worktree is alive   : PRESENT
2) after rm -rf, before prune    : PRESENT
   $ git worktree list
     .../ana  9599e0e [main]
     .../is   b667770 [is] prunable
3) after prune                   : GONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rm -rf&lt;/code&gt; doesn't touch the reflog; at that stage &lt;code&gt;worktree list&lt;/code&gt; still lists the worktree and marks it &lt;code&gt;prunable&lt;/code&gt;. It is &lt;code&gt;prune&lt;/code&gt; that takes the reflog. That's something the docs don't state, learned by testing; behaviour like this can shift between versions, so it is worth a single check on your own.&lt;/p&gt;

&lt;p&gt;Third: the branch ref survives. &lt;code&gt;prune&lt;/code&gt; only collects administrative data, and &lt;code&gt;refs/heads/is&lt;/code&gt; stays put. So the committed work in a worktree my collector deleted is still reachable by branch name — that isn't even recovery, &lt;code&gt;git checkout is&lt;/code&gt; does it.&lt;/p&gt;

&lt;p&gt;So what happens when the branch goes too? I ran &lt;code&gt;git branch -D is&lt;/code&gt; and looked again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- fsck --unreachable ---
unreachable blob 64ce9a3...
unreachable blob d0b5675...
unreachable tree b20221c...
unreachable commit 15edfb1...
--- fsck --unreachable --no-reflogs ---
(the same four lines)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two outputs are identical. This is the exact opposite of the first half of the lab: there is no reflog left anywhere, so &lt;code&gt;--no-reflogs&lt;/code&gt; changes nothing. &lt;code&gt;prune&lt;/code&gt; took the per-worktree reflog, &lt;code&gt;branch -D&lt;/code&gt; took the branch reflog; the main repo's HEAD reflog has no trace of that branch, because those commits were made under a different HEAD. This is the scenario where &lt;code&gt;git reflog&lt;/code&gt; is of no use whatsoever and &lt;code&gt;fsck&lt;/code&gt; is the only road. The rule in one sentence: without a ref the object is invisible, but it goes on &lt;em&gt;existing&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  stash drop: ref and reflog both go, the commit stays
&lt;/h2&gt;

&lt;p&gt;There is a fourth loss mode, and it is the most common one: &lt;code&gt;git stash drop&lt;/code&gt;. Dropping the wrong stash takes a second, and &lt;code&gt;stash list&lt;/code&gt; never shows it again. Here too, measurement changes the picture.&lt;/p&gt;

&lt;p&gt;First, what a stash actually is. With &lt;code&gt;git stash push -u&lt;/code&gt; I set aside both a tracked change and a file that was never added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stash commit: 49b98c1dcca45babfa71e4136bc55fc483ecd48e
stash list:
  stash@{0}: On main: yarim is
refs/stash reflog file: PRESENT
files in the stash tree: t.txt
third parent of the stash (untracked files): 39b709e2d270d395e3eae459a149283c1ad861c6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the most delightful detail in this article. A stash is a commit, and when invoked with &lt;code&gt;-u&lt;/code&gt; it gets a &lt;strong&gt;third parent&lt;/strong&gt;: a separate commit carrying the untracked files. The DISCUSSION section of the &lt;code&gt;git-stash&lt;/code&gt; doc describes two parents — the working-directory tree, the first parent HEAD, the second parent the index — and never mentions a third. Like the &lt;code&gt;worktree prune&lt;/code&gt; behaviour, this is something the docs don't state and a lab does. In other words &lt;code&gt;git stash push -u&lt;/code&gt; is the one command that turns the "never became an object" file from scenario A1 into an object. If you want a SHA to exist for a file you never &lt;code&gt;git add&lt;/code&gt;ed, that is the way.&lt;/p&gt;

&lt;p&gt;Now drop it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### stash drop
stash list: ''
refs/stash ref: GONE
refs/stash reflog file: GONE
stash commit object: PRESENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three "gone", one "present". &lt;code&gt;drop&lt;/code&gt; deletes both the &lt;code&gt;refs/stash&lt;/code&gt; ref and its reflog file — the same situation as the deleted worktree: &lt;code&gt;git reflog&lt;/code&gt; has nothing for you, but the object is in place. &lt;code&gt;fsck&lt;/code&gt; shows three commits at once: the stash itself, the commit holding the index state, and the commit holding the untracked files.&lt;/p&gt;

&lt;p&gt;Getting it back needs no stash list, because &lt;code&gt;apply&lt;/code&gt; accepts a raw SHA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git stash apply 49b98c1dcca45babfa71e4136bc55fc483ecd48e
On branch main
Changes not staged &lt;span class="k"&gt;for&lt;/span&gt; commit:
    modified:   t.txt
Untracked files:
    yeni.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the tracked change and the untracked file came back. &lt;code&gt;t.txt&lt;/code&gt;'s last line is in place, &lt;code&gt;yeni.txt&lt;/code&gt; is in the directory. A dropped stash commit reopens in full — on one condition, that &lt;code&gt;gc&lt;/code&gt; has not pruned it yet.&lt;/p&gt;

&lt;p&gt;The practical upshot: if you dropped the wrong stash, don't panic. List the commits with &lt;code&gt;git fsck --unreachable | grep commit&lt;/code&gt;, find the right one with &lt;code&gt;git stash show -p &amp;lt;SHA&amp;gt;&lt;/code&gt;, then &lt;code&gt;apply&lt;/code&gt; it. "I deleted the stash, it's gone" is wrong most of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window: two weeks, and it doesn't close unless gc runs
&lt;/h2&gt;

&lt;p&gt;An object being present is not yet a reason to relax. The lifetime of what &lt;code&gt;fsck&lt;/code&gt; finds depends on &lt;code&gt;gc&lt;/code&gt;. The defaults are written down in &lt;code&gt;config/gc.adoc&lt;/code&gt;, and they are all different from each other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gc.pruneExpire&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 weeks&lt;/td&gt;
&lt;td&gt;age at which an unreachable object is pruned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gc.reflogExpire&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;lifetime of reachable reflog entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gc.reflogExpireUnreachable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;td&gt;reflog entries pointing at unreachable commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gc.worktreePruneExpire&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 months&lt;/td&gt;
&lt;td&gt;administrative data of dead worktrees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gc.cruftPacks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unreachable objects go into a separate pack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I ran &lt;code&gt;git gc&lt;/code&gt; in the lab and looked at what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- BEFORE gc ---            --- AFTER gc ---
count: 7                     count: 0
in-pack: 0                   in-pack: 7
packs: 0                     packs: 2

pack-0d7c747...idx
pack-0d7c747...mtimes     &amp;lt;-- cruft pack marker
pack-0d7c747...pack
pack-0d7c747...rev
pack-76f69dc...idx
pack-76f69dc...pack
pack-76f69dc...rev

A2 blob after gc   : PRESENT
A3 commit after gc : PRESENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two packs came out, one of them with a &lt;code&gt;.mtimes&lt;/code&gt; file. That one is a &lt;strong&gt;cruft pack&lt;/strong&gt;: unreachable objects are placed into a separate pack instead of being left as loose files, and each object's modification time is kept in the &lt;code&gt;.mtimes&lt;/code&gt; file beside it, so the pruning decision can be made against that time. The mechanism arrived in 2.37; its becoming the default was announced in the 2.41 release notes: "Now the use of cruft packs has been made the default and no longer considered an experimental feature." After &lt;code&gt;gc&lt;/code&gt;, &lt;code&gt;fsck --unreachable&lt;/code&gt; still lists the same blob; being packed is not being lost.&lt;/p&gt;

&lt;p&gt;Let me underline that one sentence, because it matters shortly: the pruning decision reads the object's &lt;strong&gt;modification time&lt;/strong&gt;. Not the commit's date.&lt;/p&gt;

&lt;p&gt;Close the window and the table changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git gc --prune=now
  A2 blob   : PRUNED
  A3 commit : PRESENT
  reflog line count: 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That asymmetry is the summary of this article. Same command, same moment: the work that never became a commit is gone, the work that did is still here. The only difference between them is those four reflog lines. The NOTES section of the &lt;code&gt;git gc&lt;/code&gt; doc, listing what pruning leaves alone, names "the index, remote-tracking branches, reflogs" — a reflog entry is a root of reachability, so the commit survived while the blob had nobody. (The doc's other rule, that objects newer than the prune date are kept, cannot explain it: under &lt;code&gt;now&lt;/code&gt; no object is newer than the prune date. What saved the commit was not its age but the reflog.)&lt;/p&gt;

&lt;p&gt;In my real repository the trace looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ stat -f &lt;span class="s1"&gt;'%Sm %N'&lt;/span&gt; -t &lt;span class="s1"&gt;'%Y-%m-%d %H:%M'&lt;/span&gt; .git/objects/pack/*.mtimes
2026-10-03 09:29 .git/objects/pack/pack-5b5e6d6...mtimes
$ git reflog &lt;span class="o"&gt;|&lt;/span&gt; wc -l
89
$ git reflog --date=iso &lt;span class="o"&gt;|&lt;/span&gt; tail -1
6998be3b HEAD@&lt;span class="o"&gt;{&lt;/span&gt;2026-08-11 08:45:28 +0300&lt;span class="o"&gt;}&lt;/span&gt;: reset: moving to HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oldest reflog entry is from 11 August, 96 days ago. &lt;code&gt;gc.reflogExpire&lt;/code&gt; defaults to 90 days, and the first instinct is to say the entry should have been gone. Resist it — the 90-day clock is only consulted while &lt;code&gt;gc&lt;/code&gt; runs. The repository's cruft pack file is dated 3 October, the last trace &lt;code&gt;gc&lt;/code&gt; left here. From 11 August to 3 October is &lt;strong&gt;53 days&lt;/strong&gt;, so at that run the entry had not come close to 90. &lt;code&gt;gc&lt;/code&gt; has not run since, nobody has looked at the clock again, and the entry sits there at 96 days old.&lt;/p&gt;

&lt;p&gt;The lesson isn't in the number: when a deadline passes, nothing happens. These defaults are not an alarm on a timer, they are a criterion applied when &lt;code&gt;gc&lt;/code&gt; runs. That's good news for me — the window doesn't close without my noticing. For the same reason there's bad news too: my &lt;code&gt;.git&lt;/code&gt; directory has been carrying 2,089 unreachable objects for months, and some of them are the contents of rejected drafts.&lt;/p&gt;

&lt;p&gt;I repeated the measurement with two Git versions: &lt;code&gt;/usr/bin/git&lt;/code&gt; 2.54.0 and &lt;code&gt;/opt/homebrew/bin/git&lt;/code&gt; 2.48.1. The one-versus-four line difference, the cruft pack, and the &lt;code&gt;--prune=now&lt;/code&gt; asymmetry came out identical in both. Having two Gits on the machine changes nothing here; even so I called them by full path, because whichever &lt;code&gt;git&lt;/code&gt; the shell resolves is the one doing the measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding what you want among 1,723 blobs
&lt;/h2&gt;

&lt;p&gt;Everything up to here is mechanism. In practice the problem is this: &lt;code&gt;fsck&lt;/code&gt; hands you 2,089 lines of SHA and you are looking for one draft. I tried three approaches, on the real repository, with a word that appears inside a draft that genuinely got lost. The timings come from zsh's built-in &lt;code&gt;time&lt;/code&gt;, the &lt;code&gt;total&lt;/code&gt; column, run back to back on the same repository.&lt;/p&gt;

&lt;p&gt;The first is the obvious one — stream them all and count with &lt;code&gt;grep -c&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ awk &lt;span class="s1"&gt;'$2=="blob"{print $3}'&lt;/span&gt; u.txt &lt;span class="o"&gt;|&lt;/span&gt; git cat-file --batch &lt;span class="o"&gt;|&lt;/span&gt; grep -c &lt;span class="s1"&gt;'LaunchServices'&lt;/span&gt;
223
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.35 seconds, and it answers &lt;strong&gt;the wrong question&lt;/strong&gt;. &lt;code&gt;cat-file --batch&lt;/code&gt; pours every blob into a single stream; &lt;code&gt;grep -c&lt;/code&gt; counts matching &lt;em&gt;lines&lt;/em&gt;, not blobs. 223 does not mean "223 drafts," it means "this word appears on 223 lines." On top of that, blob boundaries are lost, so it can't tell you which object it was in. Speed buys nothing here.&lt;/p&gt;

&lt;p&gt;The second is correct but slow — scan each blob separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ awk &lt;span class="s1"&gt;'$2=="blob"{print $3}'&lt;/span&gt; u.txt &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; sha&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    git cat-file -p &lt;span class="s2"&gt;"$sha"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; grep -q &lt;span class="s1"&gt;'LaunchServices'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MATCH $sha"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; wc -l
94
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;94 blobs, 17.7 seconds. The right answer, but it spawns two processes per blob (&lt;code&gt;cat-file&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;) — over three thousand of them for 1,723 objects.&lt;/p&gt;

&lt;p&gt;The third uses a &lt;code&gt;git grep&lt;/code&gt; capability most people don't know about — it takes blob SHAs directly as arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ awk &lt;span class="s1"&gt;'$2=="blob"{print $3}'&lt;/span&gt; u.txt &lt;span class="o"&gt;|&lt;/span&gt; xargs git grep -l &lt;span class="s1"&gt;'LaunchServices'&lt;/span&gt;
7801e0eb00c5c6b7a0192ecb6581c8ac1d00c580
6606d42ef25c403b992e418e73162c8099894f39
480ad0940c1c3fc7aeb56ea67a169dd2958a129b
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same 94 objects, in 0.27 seconds. 65 times faster than the loop, and its output is directly usable: one object name per line. I'd reach for this first — except I didn't; the first thing I tried was the fast one that returns the wrong number. The scanning script itself produces findings, and whoever doesn't check what the finding counts puts it straight into the article.&lt;/p&gt;

&lt;p&gt;I took a look at one of the objects it found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git cat-file -p 7801e0eb00c5c6b7a0192ecb6581c8ac1d00c580 &lt;span class="o"&gt;|&lt;/span&gt; head -4
---
title: &lt;span class="s2"&gt;"I Deleted 18 Apps; 661 MiB of Data Stayed on Disk"&lt;/span&gt;
description: &lt;span class="s2"&gt;"I went looking for free space and found 79 leftovers..."&lt;/span&gt;
category: &lt;span class="s2"&gt;"life"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An article draft, on no branch tip. Its size comes from &lt;code&gt;git cat-file -s&lt;/code&gt;: 22,462 bytes. Its date comes from &lt;em&gt;inside&lt;/em&gt; the blob — the draft's own frontmatter carries &lt;code&gt;publishDate: 2026-10-12&lt;/code&gt;. A blob has no date of its own; an object is just the hash of its content, and you cannot ask Git when a file was written. This is an intermediate version of a published piece, most likely the remains of a commit that got &lt;code&gt;--amend&lt;/code&gt;ed after a review round. Still readable, and its name known only to &lt;code&gt;fsck&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lining up 103 unreachable commits by date
&lt;/h2&gt;

&lt;p&gt;The blob side is solved by content search. On the commit side there's something better: a commit object carries its date and subject line, which means those 103 unreachable commits can be turned into a chronological list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ awk &lt;span class="s1"&gt;'$2=="commit"{print $3}'&lt;/span&gt; u.txt \
  &lt;span class="o"&gt;|&lt;/span&gt; xargs -n1 git show -s --format=&lt;span class="s1"&gt;'%ci|%h|%s'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort -r
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1.1 seconds, 103 lines. The six most recent in my real repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-11-11 17:47:43 |24b21836| feat: yeni makale — tar'ın Yuttuğu Dosyayı pax...
2026-11-11 17:27:33 |105d570d| feat: yeni makale — Dosyayı tar Atladı, pax...
2026-11-11 17:05:22 |2931125d| feat: yeni makale — Arşivimde 3.214 Üye Saydım...
2026-11-11 17:04:11 |0b99f9c6| feat: yeni makale — Arşivimde 3.214 Üye Saydım...
2026-11-04 19:30:46 |c5c78013| feat: yeni makale — Kuralı Belgeye Yazdım...
2026-11-02 14:14:03 |3b948f14| feat: yeni makale — 499 Bayt İstedim...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That list reads like the negative of my own publishing history. There are four commits on 11 November and two of them carry the same title: that day I changed an article's title twice and rewrote the commit twice. The published version became &lt;code&gt;tar'ın Yuttuğu Dosyayı pax Aynı Arşivden Çıkardı&lt;/code&gt;; the &lt;code&gt;Dosyayı tar Atladı&lt;/code&gt; attempt is waiting right here, unreachable. This is exactly the trail rebase and &lt;code&gt;--amend&lt;/code&gt; leave behind.&lt;/p&gt;

&lt;p&gt;The monthly distribution says more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   2  2026-05
  60  2026-06
   2  2026-08
  22  2026-09
  11  2026-10
   6  2026-11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oldest unreachable commit is dated 29 May 2026; today is 15 November, 170 days later. The temptation here is to say "so the two-week window has been exceeded twelvefold." I wrote exactly that. It is wrong.&lt;/p&gt;

&lt;p&gt;I'm leaving the note in because this is precisely the kind of mistake the article warns about. Pruning does not look at a commit's &lt;strong&gt;date&lt;/strong&gt;; it looks at the object's &lt;strong&gt;modification time&lt;/strong&gt;. The two are independent. I tested it: I made a commit dated 29 May, rendered it unreachable, expired every reflog, then ran the prune:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commit date                        : 2026-05-29 10:00:00 +0300
object file mtime                  : 2026-11-15
after git gc --prune=2.weeks.ago   : PRESENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 170-day-old commit date survived a two-week prune, because the object itself had been written today. The list above tells me what was &lt;em&gt;authored&lt;/em&gt; when, not what will be &lt;em&gt;pruned&lt;/em&gt; when. A rebased or repacked object can carry an mtime months newer than its commit date.&lt;/p&gt;

&lt;p&gt;The real record of prune ages lives in the &lt;code&gt;.mtimes&lt;/code&gt; file beside the cruft pack. I looked into the repository's 3 October cruft pack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git verify-pack -v .git/objects/pack/pack-5b5e6d6....idx &lt;span class="o"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;'/^[0-9a-f]{40} /{print $2}'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; uniq -c
 450 blob
  86 commit
 183 tree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;719 objects. So the &lt;code&gt;gc&lt;/code&gt; on 3 October did not delete those unreachable objects; it moved them into a separate pack and kept them along with their mtimes — which is the entire reason cruft packs exist. Of today's 2,089 unreachable objects, 719 are waiting in there; the rest came into being after 3 October.&lt;/p&gt;

&lt;p&gt;The practical reading: "two weeks" is not a timeout, it is a &lt;strong&gt;criterion&lt;/strong&gt; applied to object mtimes whenever &lt;code&gt;gc&lt;/code&gt; runs. No &lt;code&gt;gc&lt;/code&gt;, no criterion; and when it does run, it reads file ages, not calendar dates. Leaving the recovery job for tomorrow on the grounds that "it'll still be there" is a gamble that is holding for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap in my own collector
&lt;/h2&gt;

&lt;p&gt;The measurement produced a by-product. Here is my collector's first gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="nv"&gt;$(&lt;/span&gt;git -C &lt;span class="s2"&gt;"$wt"&lt;/span&gt; status --porcelain &lt;span class="o"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;'^??'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  log &lt;span class="s2"&gt;"KORU/is  $rel"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;kept++&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;continue&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;grep -v '^??'&lt;/code&gt; drops untracked files from the list. So if the only work in a worktree is a file that was never &lt;code&gt;git add&lt;/code&gt;ed, the gate calls that worktree "clean" and lets it through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git status --porcelain
?? yeni-taslak.txt
$ git status --porcelain &lt;span class="o"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;'^??'&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;empty&lt;span class="o"&gt;)&lt;/span&gt;  -&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; gate 1 PASSES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is scenario A1 of the lab, exactly: since no object was ever created, neither &lt;code&gt;fsck&lt;/code&gt; nor the reflog nor a branch ref helps once it's deleted. The collector's other two gates — the two-day age check and the unpushed-commit check — catch most cases in practice; an agent going two days without either committing or adding a file is a rare combination. But "rare" is not "never." Tightening the gate to look at the raw &lt;code&gt;status --porcelain&lt;/code&gt; output instead of &lt;code&gt;grep -v '^??'&lt;/code&gt; is a one-line change; the cost is that clean worktrees holding a single scratch file get protected too, which means a little more disk.&lt;/p&gt;

&lt;p&gt;I'll leave that here as a lesson: the place where you noted "this is not data loss" in an automation deserves one measurement after you write the note. Mine was right for two of three cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not a backup: a clone brings none of it
&lt;/h2&gt;

&lt;p&gt;Everything so far holds on a single machine. To test that, I cloned the repository on my own disk — but with local shortcuts disabled, forcing it to behave like a real network clone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ git clone --no-local --bare file:///.../mustafaerbay /tmp/klontest
4.7 seconds

$ git -C /tmp/klontest count-objects -v
count: 0
in-pack: 48661
packs: 1

$ git -C /tmp/klontest fsck --unreachable &lt;span class="o"&gt;|&lt;/span&gt; wc -l
0
$ git -C /tmp/klontest reflog &lt;span class="o"&gt;|&lt;/span&gt; wc -l
0
$ git -C /tmp/klontest cat-file -e 7801e0eb...
ABSENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero unreachable objects, zero reflog entries. The draft I recovered a moment ago is &lt;strong&gt;not in the clone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There is a trap in comparing object counts: in &lt;code&gt;count-objects -v&lt;/code&gt; output, &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;in-pack&lt;/code&gt; do not add up. The &lt;code&gt;prune-packable: 34&lt;/code&gt; line in that same output says 34 of the loose objects are already in a pack as well. The correct arithmetic is 49,845 + 1,179 − 34 = &lt;strong&gt;50,990&lt;/strong&gt;, and counting distinct objects directly with &lt;code&gt;git cat-file --batch-all-objects&lt;/code&gt; gives the same figure. The clone holds 48,661, so the gap is &lt;strong&gt;2,329&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That gap is larger than the 2,161 unreachable, and equating the two would be wrong. A clone doesn't just skip unreachable objects; it also skips objects behind refs it doesn't copy. Beyond &lt;code&gt;refs/heads&lt;/code&gt; and &lt;code&gt;refs/tags&lt;/code&gt;, my local repository has 67 &lt;code&gt;refs/remotes&lt;/code&gt;, one &lt;code&gt;refs/stash&lt;/code&gt; and one &lt;code&gt;refs/codex&lt;/code&gt;; the clone has only 89 branches and one tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local                 clone
  89 refs/heads        89 refs/heads
   1 refs/tags           1 refs/tags
  67 refs/remotes
   1 refs/stash
   1 refs/codex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The practical consequence is blunt: the objects &lt;code&gt;fsck&lt;/code&gt; finds exist &lt;strong&gt;only on that machine&lt;/strong&gt;. &lt;code&gt;git push&lt;/code&gt; never sent them, they are not on the server, not in a colleague's clone, not in CI's checkout. If your disk goes, nothing you could have recovered with &lt;code&gt;fsck&lt;/code&gt; remains. I've written before about a ref-less object staying invisible on the wire, in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/3583-commite-not-yazdim-klon-hicbir-notu-getirmedi/" rel="noopener noreferrer"&gt;the notes I wrote on 3,583 commits that a clone never brought along&lt;/a&gt;; here is the hardest form of the same rule — no ref, no travel.&lt;/p&gt;

&lt;p&gt;So every recipe in this article is a second-chance mechanism, not a backup strategy. Carrying 2,089 unreachable objects on my disk does not save me from being unbacked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Once you step outside a single machine
&lt;/h2&gt;

&lt;p&gt;These recipes hold inside your own clone. Step outside it and five things change. None of them got a lab in this article, but anyone applying this in production should look at them first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git LFS.&lt;/strong&gt; &lt;code&gt;fsck&lt;/code&gt; recovers the LFS &lt;em&gt;pointer&lt;/em&gt;, not the file. The content lives under &lt;code&gt;.git/lfs/objects&lt;/code&gt; or on the LFS server, and that has its own pruning logic. If the blob you recovered is a 130-byte text file, look inside it before celebrating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bare repositories and the server side.&lt;/strong&gt; In a bare repository &lt;code&gt;core.logAllRefUpdates&lt;/code&gt; is off by default, so the second layer — the reflog — effectively doesn't exist there. On GitHub, GitLab or Gitea you cannot run &lt;code&gt;fsck&lt;/code&gt; at all; a force-pushed commit is reachable only through the provider's API, for a limited time. The checklist above quietly assumes you have a local clone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submodules.&lt;/strong&gt; &lt;code&gt;fsck&lt;/code&gt; in the superproject does not descend into a submodule's object database. If the lost work is inside a submodule, the whole drill has to be repeated in that directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale, and &lt;code&gt;--lost-found&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;fsck&lt;/code&gt; reads every object; on a multi-gigabyte repository that is not a cheap command. &lt;code&gt;--lost-found&lt;/code&gt; can dump thousands of files into &lt;code&gt;.git&lt;/code&gt;. And the &lt;code&gt;xargs git grep&lt;/code&gt; recipe will hit the argument limit with thousands of SHAs and split into several invocations — the answer stays correct, but "one process, 0.27 seconds" no longer holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaked secrets, not just disk.&lt;/strong&gt; Throughout this article I've framed unreachable objects as a disk cost. The sharper angle: a password that was committed and then "removed" stays readable for the whole window. The only thing that closes that window is &lt;code&gt;git gc --prune=now&lt;/code&gt; — and if it was ever pushed, there is a separate job waiting on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery checklist
&lt;/h2&gt;

&lt;p&gt;When a piece of work disappears, this is the order I go in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Was the file ever &lt;code&gt;git add&lt;/code&gt;ed?&lt;/strong&gt; If not, leave Git alone and go to a filesystem backup or your editor's local history. Git holds no trace of that file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git reflog&lt;/code&gt; and &lt;code&gt;git reflog show &amp;lt;branch&amp;gt;&lt;/code&gt;.&lt;/strong&gt; If what you want became a commit, the answer is most likely here and on a single line. &lt;code&gt;git branch rescue &amp;lt;SHA&amp;gt;&lt;/code&gt; is enough to give it a ref.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was it a deleted worktree?&lt;/strong&gt; The branch ref may still be there: &lt;code&gt;git branch --all | grep &amp;lt;name&amp;gt;&lt;/code&gt;. If it is, no recovery is needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was the wrong stash dropped?&lt;/strong&gt; The commit is there even when &lt;code&gt;git stash list&lt;/code&gt; is empty: &lt;code&gt;git fsck --unreachable | grep commit&lt;/code&gt;, identify the right one with &lt;code&gt;git stash show -p &amp;lt;SHA&amp;gt;&lt;/code&gt;, reopen it with &lt;code&gt;git stash apply &amp;lt;SHA&amp;gt;&lt;/code&gt;. A stash taken with &lt;code&gt;-u&lt;/code&gt; brings untracked files back too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git fsck --unreachable --no-reflogs&lt;/code&gt;.&lt;/strong&gt; I reach for the variant that doesn't treat reflogs as roots, because I want to see what the reflog is protecting as well. Add &lt;code&gt;--no-progress&lt;/code&gt; to keep the output readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking for a commit? Line them up by date:&lt;/strong&gt; &lt;code&gt;git fsck --unreachable | awk '$2=="commit"{print $3}' | xargs -n1 git show -s --format='%ci|%h|%s' | sort -r&lt;/code&gt;. Under a second for 103 commits, and the day you want jumps out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking for a blob? Search the content:&lt;/strong&gt; &lt;code&gt;git grep -l '&amp;lt;pattern&amp;gt;' &amp;lt;blob SHAs&amp;gt;&lt;/code&gt;. Don't write a loop; &lt;code&gt;git grep&lt;/code&gt; takes blob SHAs and is 65 times faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring it back:&lt;/strong&gt; for content, &lt;code&gt;git cat-file -p &amp;lt;SHA&amp;gt; &amp;gt; rescued.mdx&lt;/code&gt;; for a commit, &lt;code&gt;git branch rescue &amp;lt;SHA&amp;gt;&lt;/code&gt;. If you want a directory full of files, &lt;code&gt;git fsck --lost-found&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the window:&lt;/strong&gt; &lt;code&gt;git config --get gc.pruneExpire&lt;/code&gt;. Unset means two weeks. Don't run &lt;code&gt;git gc --prune=now&lt;/code&gt; in that repository until the recovery is finished, and if an automation would run it, stop it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The inverse of that last item is true as well. If you don't want to carry the contents of rejected drafts inside &lt;code&gt;.git&lt;/code&gt; for months, &lt;code&gt;git gc --prune=now&lt;/code&gt; cleans up exactly that — but knowingly, after you are sure there is nothing left to recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remaining question
&lt;/h2&gt;

&lt;p&gt;Git's recovery capability has three layers: refs, reflogs, and unreachable objects. Most writing covers the first two, because those are what daily life needs. The third layer comes into play on a person's worst day, and on that day there is no time to read documentation.&lt;/p&gt;

&lt;p&gt;Underneath those three layers, though, there is a more basic line, and it runs exactly where &lt;code&gt;git add&lt;/code&gt; passes. Git stubbornly keeps everything that became an object — ref deleted, worktree gone, reflog pruned, it still sits on your disk past the two-week threshold until &lt;code&gt;gc&lt;/code&gt; runs again. For anything that never became an object it can do nothing at all, and it gives no warning about that.&lt;/p&gt;

&lt;p&gt;The question to put to your own setup is this: which layer do your automations touch? My garbage collector doesn't touch two of them, and it believed it was protecting the third — the file that never became an object.&lt;/p&gt;

&lt;p&gt;Writing this, I fell into my own trap twice. First I read the repository's commit count off a stale working copy. Then I looked at a 170-day-old commit date and wrote that the two-week window had been exceeded twelvefold; testing that pruning reads object mtimes rather than commit dates, I had to delete the sentence. Both were right answers to the wrong question — much like scanning 1,723 blobs with &lt;code&gt;grep -c&lt;/code&gt; and getting 223. The hard part of recovery isn't remembering the commands; it's knowing what your number counts, and what your date is the date of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/git/git/blob/master/Documentation/git-fsck.adoc" rel="noopener noreferrer"&gt;git-fsck documentation&lt;/a&gt; — definitions of &lt;code&gt;--unreachable&lt;/code&gt;, &lt;code&gt;--dangling&lt;/code&gt;, &lt;code&gt;--lost-found&lt;/code&gt;, &lt;code&gt;--no-reflogs&lt;/code&gt;, and reflogs counting as roots by default&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/git/git/blob/master/Documentation/config/gc.adoc" rel="noopener noreferrer"&gt;config/gc documentation&lt;/a&gt; — defaults for &lt;code&gt;gc.pruneExpire&lt;/code&gt;, &lt;code&gt;gc.cruftPacks&lt;/code&gt;, &lt;code&gt;gc.reflogExpire&lt;/code&gt;, &lt;code&gt;gc.reflogExpireUnreachable&lt;/code&gt;, &lt;code&gt;gc.worktreePruneExpire&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/git/git/blob/master/Documentation/git-reflog.adoc" rel="noopener noreferrer"&gt;git-reflog documentation&lt;/a&gt; — reflogs recording ref tips, &lt;code&gt;expire&lt;/code&gt; defaults&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/git-worktree.adoc" rel="noopener noreferrer"&gt;git-worktree documentation&lt;/a&gt; — &lt;code&gt;prune&lt;/code&gt; removing administrative data under &lt;code&gt;$GIT_DIR/worktrees&lt;/code&gt;, shared versus per-worktree files&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/gitrepository-layout.adoc" rel="noopener noreferrer"&gt;Repository layout documentation&lt;/a&gt; — &lt;code&gt;objects&lt;/code&gt; and &lt;code&gt;logs&lt;/code&gt; being redirected via &lt;code&gt;$GIT_COMMON_DIR&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/git/git/blob/master/Documentation/RelNotes/2.41.0.adoc" rel="noopener noreferrer"&gt;Git 2.41.0 release notes&lt;/a&gt; — cruft packs becoming the default&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>git</category>
      <category>olcum</category>
      <category>guide</category>
      <category>sistemyonetimi</category>
    </item>
    <item>
      <title>I Wrote Sharing State Into the Post; My Archive Changes Daily</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 15 Nov 2026 06:47:21 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-wrote-sharing-state-into-the-post-my-archive-changes-daily-455e</link>
      <guid>https://dev.to/merbayerp/i-wrote-sharing-state-into-the-post-my-archive-changes-daily-455e</guid>
      <description>&lt;p&gt;The content folder of this blog holds 2,838 files today: 1,419 Turkish posts and an equal number of English twins. The first content commit is dated 31 March 2026, the last one yesterday. An archive written in seven and a half months — not bad.&lt;/p&gt;

&lt;p&gt;What I was curious about wasn't the size. I wanted to know how many times those 2,838 files had been edited after they were written. Writing is a one-off job; what about everything that comes after?&lt;/p&gt;

&lt;p&gt;I counted: &lt;strong&gt;9,721 edits&lt;/strong&gt;. Divided across the 2,838 files standing today, that's 3.4 edits per file — edits to files that were later deleted are inside that total too, so the average errs on the generous side.&lt;/p&gt;

&lt;p&gt;The number that actually stung came after the division. Of those 9,721 edits, only &lt;strong&gt;998 — that is 10.3% — changed a sentence in a post.&lt;/strong&gt; The other 8,723 never reached the prose at all: a field added, a field deleted, a value flipped from &lt;code&gt;false&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;. I thought I had spent seven and a half months writing; the history says I spent most of that time fixing the labels on files.&lt;/p&gt;

&lt;p&gt;I'd have liked to pin this on a bot. I can't: three quarters of those edits came from my own hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count first, claim second
&lt;/h2&gt;

&lt;p&gt;Every number here comes from one snapshot: &lt;code&gt;origin/main&lt;/code&gt; = &lt;code&gt;325c5012&lt;/code&gt;, the morning of 15 November 2026, with the &lt;code&gt;-- src/content/blog&lt;/code&gt; pathspec. That pathspec matters: run the same count with &lt;code&gt;-- 'src/content/blog/*.mdx'&lt;/code&gt; and you get 3,001 additions and 0 renames. If you're going to repeat this in your own repository, know which path you counted along.&lt;/p&gt;

&lt;p&gt;Git's &lt;code&gt;--diff-filter&lt;/code&gt; option separates changes by type: in the words of the documentation, "Added (&lt;code&gt;A&lt;/code&gt;)", "Modified (&lt;code&gt;M&lt;/code&gt;)", "Deleted (&lt;code&gt;D&lt;/code&gt;)" and "Renamed (&lt;code&gt;R&lt;/code&gt;)". Since I wanted to tell adding a file apart from editing one, this is exactly the cut I needed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.mdx&lt;/code&gt; count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Added (&lt;code&gt;A&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;2,999&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modified (&lt;code&gt;M&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;9,721&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted (&lt;code&gt;D&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renamed (&lt;code&gt;R&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There are 2,999 add events against 2,981 unique file paths. The 18 extra &lt;strong&gt;events&lt;/strong&gt; belong to 16 files: fifteen were added twice and one four times (deleted, then brought back). To arrive at today's count: 2,999 − 163 + 2 = 2,838. It balances exactly, which tells me the count isn't leaking.&lt;/p&gt;

&lt;p&gt;Let me state the method's two limits up front, because in this kind of counting the error usually hides in the definition. First, what I'm counting is not lines but &lt;strong&gt;events&lt;/strong&gt;: if a commit touches ten lines in one file, that's one edit. Second, git detects renames at 50% similarity by default, and the documentation doesn't say which letter reports a file that was renamed &lt;em&gt;and&lt;/em&gt; modified — so in theory &lt;code&gt;R&lt;/code&gt; events could mask &lt;code&gt;M&lt;/code&gt; counts. In my case the &lt;code&gt;R&lt;/code&gt; total is two, so that ambiguity cannot move any number here.&lt;/p&gt;

&lt;p&gt;I did not decide "frontmatter or prose" by reading commit messages, because trusting my own messages would be optimism rather than measurement. My first attempt was wrong too. A fixed threshold — "frontmatter ends within the first twenty-two lines" — looked safe enough, but in this repository the frontmatter ends anywhere between line 14 and line 32, and in 46% of the files it runs past line twenty-two.&lt;/p&gt;

&lt;p&gt;The right way is this: for every edit, pull the file as it was before and after the commit with &lt;code&gt;git cat-file&lt;/code&gt;, &lt;strong&gt;cut the frontmatter off, and compare what's left&lt;/strong&gt;. That gives three buckets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where did the edit land?&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontmatter only&lt;/td&gt;
&lt;td&gt;8,025&lt;/td&gt;
&lt;td&gt;82.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below it, but only an &lt;code&gt;import&lt;/code&gt; line, a component tag or a blank line&lt;/td&gt;
&lt;td&gt;698&lt;/td&gt;
&lt;td&gt;7.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An actual line of prose&lt;/td&gt;
&lt;td&gt;998&lt;/td&gt;
&lt;td&gt;10.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I keep the middle bucket separate on purpose: adding a &lt;code&gt;&amp;lt;Mermaid&amp;gt;&lt;/code&gt; component to a post changes the body of the file without improving a single sentence. Folding the two together would be counting in my own favour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the edits were about
&lt;/h2&gt;

&lt;p&gt;I also sorted the same 9,721 events by subject. I'm keeping this table separate from the count above, because this one is judgement rather than measurement: I put each commit in a bucket by its subject line and left what I couldn't classify under "other".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subject of the edit&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Social sharing markers&lt;/td&gt;
&lt;td&gt;4,211&lt;/td&gt;
&lt;td&gt;43.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead platform fields (mastodon, hashnode)&lt;/td&gt;
&lt;td&gt;2,081&lt;/td&gt;
&lt;td&gt;21.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cover/image schema&lt;/td&gt;
&lt;td&gt;1,618&lt;/td&gt;
&lt;td&gt;16.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other&lt;/td&gt;
&lt;td&gt;796&lt;/td&gt;
&lt;td&gt;8.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;De-fab: removing fabricated content&lt;/td&gt;
&lt;td&gt;641&lt;/td&gt;
&lt;td&gt;6.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n / EN structure&lt;/td&gt;
&lt;td&gt;282&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontmatter schema&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I came to this table with an expectation: I assumed I had been improving posts over time. Fixing a sentence, updating a measurement, refreshing a link. Of the 998 edits that changed a line of prose, 624 — 62.5% — come from a single cleanup campaign; the rest is scattered.&lt;/p&gt;

&lt;p&gt;There's one more thing. Of the 1,392 commits that contain edits, just &lt;strong&gt;eight&lt;/strong&gt; produced 56.0% of all of them — 5,442 events. The other 1,384 commits add up to 4,279. My archive's history consists of eight sweeps and the 1,384 small touches between them.&lt;/p&gt;

&lt;p&gt;I opened all eight. Not one of them improves a single sentence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;What it did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;27 Apr&lt;/td&gt;
&lt;td&gt;337&lt;/td&gt;
&lt;td&gt;Converted embedded SVG covers to PNG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 May&lt;/td&gt;
&lt;td&gt;457&lt;/td&gt;
&lt;td&gt;Migrated covers to a standard template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 May&lt;/td&gt;
&lt;td&gt;934&lt;/td&gt;
&lt;td&gt;Flipped &lt;code&gt;twitter&lt;/code&gt;/&lt;code&gt;bluesky&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; (and &lt;code&gt;linkedin&lt;/code&gt; in 490 files)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 May&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;td&gt;Wrote separate cover paths for EN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 May&lt;/td&gt;
&lt;td&gt;946&lt;/td&gt;
&lt;td&gt;Added &lt;code&gt;mastodon&lt;/code&gt;, &lt;code&gt;hashnode&lt;/code&gt;, &lt;code&gt;devto&lt;/code&gt; fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 May&lt;/td&gt;
&lt;td&gt;1,042&lt;/td&gt;
&lt;td&gt;Deleted the &lt;code&gt;mastodon&lt;/code&gt; field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 May&lt;/td&gt;
&lt;td&gt;212&lt;/td&gt;
&lt;td&gt;Removed duplicate &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; and &lt;code&gt;import&lt;/code&gt; lines from EN posts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 May&lt;/td&gt;
&lt;td&gt;1,034&lt;/td&gt;
&lt;td&gt;Deleted the &lt;code&gt;hashnode&lt;/code&gt; field&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three cover decisions, four social-field decisions, and one markup cleanup on the EN side. All mine, all after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting commits and counting edits tell different stories
&lt;/h2&gt;

&lt;p&gt;The first row of the subject table — 43.3% — is the bill for a single architectural choice. The frontmatter of a post carries a block like this; the field list has changed over time, and fewer than half the files in the archive carry all six today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;socialMedia&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;    &lt;span class="c1"&gt;# has it gone to X yet?
&lt;/span&gt;  &lt;span class="na"&gt;linkedin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# ...to LinkedIn?
&lt;/span&gt;  &lt;span class="na"&gt;bluesky&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;    &lt;span class="c1"&gt;# ...to Bluesky?
&lt;/span&gt;  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;      &lt;span class="c1"&gt;# ...to dev.to?
&lt;/span&gt;  &lt;span class="na"&gt;burncpu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;    &lt;span class="c1"&gt;# ...to burncpu.com?
&lt;/span&gt;  &lt;span class="na"&gt;hashnode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# ...to Hashnode?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These fields don't mean "should this be shared" but "has this been shared". When a post goes to X, a script comes back and writes &lt;code&gt;twitter: true&lt;/code&gt;. The idea is simple and, at first glance, tidy: let the record of where a post went live next to the post, so nothing else has to be consulted.&lt;/p&gt;

&lt;p&gt;Here's the consequence. A share is an &lt;strong&gt;event&lt;/strong&gt;; a file is a &lt;strong&gt;work&lt;/strong&gt;. The moment I wrote the event inside the work, every share became a commit. Since 9 May, a commit author named &lt;code&gt;Social Bot&lt;/code&gt; has pushed 1,236 commits into the content folder — an average of 6.5 commits a day over 189 days. I counted this five weeks ago too; &lt;a href="https://mustafaerbay.com.tr/en/blog/life/gecenin-433-commitini-kurdugum-botlar-atti/" rel="noopener noreferrer"&gt;the bot stood at 1,131 commits that day&lt;/a&gt;, and it's at 1,236 now.&lt;/p&gt;

&lt;p&gt;But the unit of this post is the edit, not the commit. Switch units and the picture inverts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Author&lt;/th&gt;
&lt;th&gt;Commits with edits&lt;/th&gt;
&lt;th&gt;Edit events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Me (across four email addresses)&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;td&gt;7,292 (75.0%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social Bot&lt;/td&gt;
&lt;td&gt;1,235&lt;/td&gt;
&lt;td&gt;2,330 (24.0%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other bots (FAQ, Editorial, Content)&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;99 (1.0%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bot has ten times my commits and a third of my edits (2,330 against 7,292). It touches one or two files each time; I make a decision once in a while and open a thousand files. Two units, two stories, both true — and if I don't say which one I picked, I'm misleading the reader. My first draft of this post opened with "the biggest editor of my archive is a bot"; counting the authors caught the claim on my own methodology warning.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtTaGFyZSBldmVudDo8YnIvPnBvc3Qgc2VudCB0byBYXSAtLT4gQntXaGVyZSBkb2VzIHN0YXRlIGdvP30KICBCIC0tPnxmcm9udG1hdHRlcnwgQ1tBIGZpbGUgaW4gdGhlIGFyY2hpdmU8YnIvPmdldHMgZWRpdGVkXQogIEMgLS0-IERbY29tbWl0XQogIEQgLS0-IEVbSWYgdGhlIHBsYXRmb3JtIGRpZXMsPGJyLz4xLDA0MiBmaWxlcyBjaGFuZ2VdCiAgQiAtLT58YSBzZXBhcmF0ZSB3cml0YWJsZSBzdG9yZXwgRltBIHNpbmdsZS1yb3cgVVBEQVRFXQogIEYgLS0-IEdbVGhlIHBvc3QgZmlsZTxici8-c3RheXMgdW50b3VjaGVkXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtTaGFyZSBldmVudDo8YnIvPnBvc3Qgc2VudCB0byBYXSAtLT4gQntXaGVyZSBkb2VzIHN0YXRlIGdvP30KICBCIC0tPnxmcm9udG1hdHRlcnwgQ1tBIGZpbGUgaW4gdGhlIGFyY2hpdmU8YnIvPmdldHMgZWRpdGVkXQogIEMgLS0-IERbY29tbWl0XQogIEQgLS0-IEVbSWYgdGhlIHBsYXRmb3JtIGRpZXMsPGJyLz4xLDA0MiBmaWxlcyBjaGFuZ2VdCiAgQiAtLT58YSBzZXBhcmF0ZSB3cml0YWJsZSBzdG9yZXwgRltBIHNpbmdsZS1yb3cgVVBEQVRFXQogIEYgLS0-IEdbVGhlIHBvc3QgZmlsZTxici8-c3RheXMgdW50b3VjaGVkXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="371" height="798"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is a point where I have to defend my own choice, because it wasn't entirely thoughtless. Keeping share state in the file bought me something: git history gave me a free audit trail of when each share happened. That's exactly why I can produce the bot numbers in this post. An &lt;code&gt;UPDATE&lt;/code&gt; in a database would have left no trace; a line in a file did. Still, I think the trade is clear: if I want an audit trail, I keep one deliberately, rather than making every file in the archive writable on a daily basis.&lt;/p&gt;

&lt;h2&gt;
  
  
  A field that never entered the schema still sits in 1,225 files
&lt;/h2&gt;

&lt;p&gt;The 21.4% held by dead platform fields is the second bill for the same choice — and the part that taught me the real lesson.&lt;/p&gt;

&lt;p&gt;On 10 May I wrote &lt;code&gt;mastodon: true&lt;/code&gt; into 946 files. At 12:30 on 16 May I deleted that field from 1,042 files. The reason was mundane: &lt;a href="https://mustafaerbay.com.tr/en/blog/career/mastodon-hesabim-askiya-alindiktan-sonra-ne-oldu/" rel="noopener noreferrer"&gt;my Mastodon account was suspended&lt;/a&gt;, and the information the field carried became meaningless.&lt;/p&gt;

&lt;p&gt;My first version of this story opened with "on 16 May I removed the field from the schema". The schema says otherwise: the &lt;code&gt;mastodon&lt;/code&gt; lines deleted from &lt;code&gt;src/content.config.ts&lt;/code&gt; that day belonged not to the blog but to the short-video collection I had opened a day earlier. The blog's &lt;code&gt;socialMedia&lt;/code&gt; schema recognised exactly three fields at the time — &lt;code&gt;twitter&lt;/code&gt;, &lt;code&gt;linkedin&lt;/code&gt;, &lt;code&gt;bluesky&lt;/code&gt;. So &lt;code&gt;mastodon&lt;/code&gt; had gone into the frontmatter of 946 posts &lt;strong&gt;without ever being defined&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then I counted today.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ grep -c "^  mastodon:" src/content/blog/*/*.mdx | grep -v ":0" | wc -l
1225
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field sits in 43% of the archive today — 1,223 &lt;code&gt;false&lt;/code&gt; and 2 &lt;code&gt;true&lt;/code&gt;. Their creation dates explain why:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month the file was added&lt;/th&gt;
&lt;th&gt;Carries &lt;code&gt;mastodon&lt;/code&gt; today&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-06&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The template that generates posts kept writing the field I had deleted in May, right through the end of August. The deletion didn't win; the template did. Nothing objected either: the field wasn't in the schema, so it tripped no validation — and since 29 June the blog isn't even an Astro collection, with content served from &lt;code&gt;content.db&lt;/code&gt;, so no gate inspects the extra frontmatter fields at all today.&lt;/p&gt;

&lt;p&gt;I fell for the same illusion once more. On 18 May, while &lt;code&gt;hashnode&lt;/code&gt; was being removed from 1,034 files, the same commit also cleaned up 24 &lt;code&gt;mastodon: false&lt;/code&gt; lines. My first reading was "the sweep two days earlier missed 24 files" — a neat lesson about thousand-file operations never landing in one pass. Then I checked when those 24 files were born: all twenty-four were created &lt;strong&gt;after&lt;/strong&gt; the sweep. The sweep missed nothing; what was missed, again, was the template.&lt;/p&gt;

&lt;p&gt;That's the correct lesson: &lt;strong&gt;a field is owned by the pipeline that writes it, not by the archive that holds it.&lt;/strong&gt; Close the gate first, then sweep; do it the other way round and every sweep breeds the next one. Eleven days ago, &lt;a href="https://mustafaerbay.com.tr/en/blog/career/kurali-belgeye-yazdim-3580-committen-dokuzu-uydu/" rel="noopener noreferrer"&gt;counting how nine of 3,580 commits followed the rule I had written into a document&lt;/a&gt;, I ended up somewhere similar: there a written rule was racing its own template, here what races is where the state lives — and this time the loser's bill gets multiplied by the file count. &lt;code&gt;hashnode&lt;/code&gt; is back in 1,593 files today and &lt;code&gt;devto&lt;/code&gt; sits in 2,827 — same cause. &lt;a href="https://mustafaerbay.com.tr/en/blog/career/1391-etiket-urettim-931i-tek-yazida-kaldi/" rel="noopener noreferrer"&gt;Producing 1,391 tags and leaving 931 of them on a single post each&lt;/a&gt; was the same class of mistake: I fixed the output instead of the gate on the production side.&lt;/p&gt;

&lt;h2&gt;
  
  
  I made the cover decision at 340 files
&lt;/h2&gt;

&lt;p&gt;The cover/image line — 16.6% — shows best how the cost grows with the size of the archive.&lt;/p&gt;

&lt;p&gt;On 27 April I converted covers from SVG to PNG, because RSS readers and social cards wouldn't render embedded SVG. I walked into a measurement trap here too: I asked "how many files were in the archive that day" and took the end-of-day count. The right denominator is the sweep's &lt;strong&gt;own&lt;/strong&gt; tree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sweep&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.mdx&lt;/code&gt; in the commit's tree&lt;/th&gt;
&lt;th&gt;Touched&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;27 Apr, SVG → PNG&lt;/td&gt;
&lt;td&gt;340&lt;/td&gt;
&lt;td&gt;337&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 May, cover template&lt;/td&gt;
&lt;td&gt;467&lt;/td&gt;
&lt;td&gt;457&lt;/td&gt;
&lt;td&gt;97.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 May, mastodon&lt;/td&gt;
&lt;td&gt;1,058&lt;/td&gt;
&lt;td&gt;1,042&lt;/td&gt;
&lt;td&gt;98.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With end-of-day numbers all three came out at "97%"; against their own trees they climb towards 99%. The direction doesn't change, but the pattern gets sharper: &lt;strong&gt;a schema decision means touching nearly the whole archive as it stands at that moment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That tree holds 2,838 files today. A decision that touched 340 files in April touches 2,838 now — 8.3 times more in six and a half months. The hundred posts I write next will nudge that multiplier further. This is the measured version of "decide early", and I did the opposite: I produced fast and fixed the schema on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wave that touched the prose
&lt;/h2&gt;

&lt;p&gt;The de-fab line — 641 events — is the largest piece of work in this archive that genuinely changed the text. At the end of June, across six commits, I cleaned fabricated numbers, outputs and anecdotes out of AI-generated posts; 455 distinct files were touched.&lt;/p&gt;

&lt;p&gt;One detail: all 455 of them are Turkish. The commits' own tree held 1,802 files, but half of those were English twins and the cleanup never reached them. (I took that tree count from the commits themselves rather than from the end of the day — so as not to fall into the trap from the previous section a second time.) I learned that afterwards, &lt;a href="https://mustafaerbay.com.tr/en/blog/career/455-yazimi-temizledim-474-sayi-ingilizce-ikizinde-kaldi/" rel="noopener noreferrer"&gt;while counting the 474 figures that stayed behind in the English twins of the 455 posts I had cleaned&lt;/a&gt; — so this line doesn't say "I cleaned the archive", it says "I cleaned the Turkish half of it".&lt;/p&gt;

&lt;p&gt;I don't bring it up as an achievement either. A ban on fabrication should have been a rule from the start; applying it retroactively to an archive of 901 Turkish posts in June is proof the rule arrived late. &lt;a href="https://mustafaerbay.com.tr/en/blog/career/357-duzeltmemin-167sini-yeniden-yazdim-52sini-bir-saat-icinde/" rel="noopener noreferrer"&gt;The post where I counted my own fixes&lt;/a&gt; turned up something similar: some of those fixes weren't failures but habits. This one is the same kind of thing — 641 edits, the bill for a policy gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  This work has a name
&lt;/h2&gt;

&lt;p&gt;Google's SRE book calls this sort of work &lt;em&gt;toil&lt;/em&gt; and names six characteristics: manual, repetitive, automatable, tactical, "devoid of enduring value" and — the one that cuts closest for me — "O(n) with service growth", meaning it grows linearly as the service grows.&lt;/p&gt;

&lt;p&gt;My 8,723 edits that never reached the prose satisfy all six — "manual" included, because three quarters of that work came from my own hands, not from a script; my commit count merely looks small because I touch a thousand files at a time. They satisfy the last one all too well: when the archive doubles, the cost of every schema decision doubles with it.&lt;/p&gt;

&lt;p&gt;The book's target is for toil to stay under half of an SRE's time, and Google's own surveys measured an average of 33%. My 89.7% is not in the same unit as those figures: theirs is a share of time, mine a share of edit events. I'm writing it down for the sense of scale, not as a comparison.&lt;/p&gt;

&lt;p&gt;Saying "I automated it" doesn't make the cost disappear either; it moves it. I checked this while writing: the deploy workflow's push trigger explicitly excludes the &lt;code&gt;src/content/**&lt;/code&gt; path, so those bot commits don't each kick off a publish run — that was my first guess and it was wrong. The cost sits elsewhere: my content folder never goes quiet. Every branch I prepare a post on sits on top of a main branch sliding underneath it, and every measurement turns into a snapshot that is already a few minutes stale. The numbers in this post had to be nailed to a commit for the same reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's price list
&lt;/h2&gt;

&lt;p&gt;How widespread each field is across the archive today is the price tag on every decision I'm postponing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Files containing it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;readingTime&lt;/code&gt;, &lt;code&gt;coverImageAlt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2,838&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;socialMedia&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;2,831&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;devto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,827&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hashnode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,593&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;translatedFrom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,419&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;burncpu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,349&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mastodon&lt;/code&gt; (not in the schema)&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision framework I've distilled comes down to three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is this field part of the work, or part of what happened to it?&lt;/strong&gt; What I wrote (title, text, sources) stays in the file. What happens to the post &lt;em&gt;afterwards&lt;/em&gt; (shared, viewed, liked) doesn't. One question is enough to separate them: can this value change without me touching the file? If yes, it doesn't belong in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many files is this decision worth?&lt;/strong&gt; Before I propose a schema change, I count today's files. 2,838 is too large a number to leave something to "we'll handle it later".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who writes the field?&lt;/strong&gt; Deleting it from the schema is not deleting it from the archive. Every cleanup done without closing the production path produces the raw material for the next cleanup — my 1,225 files are the proof.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Astro's content collections documentation describes the schema like this: with Zod it will "validate every file's data within a collection", and for a file that violates it, "Astro will provide a helpful error". So a schema is not a promise but a gate: the moment you add a new required field, the entire archive has to pass through it. The documentation doesn't describe a migration path for that; that part is left to you.&lt;/p&gt;

&lt;p&gt;My mistake was never using that gate at all. None of the sharing fields were defined in the blog's schema: it recognised three fields while I wrote six into the frontmatter, and the extras passed silently. A field that doesn't go through the gate doesn't report back when the gate changes — that's the short explanation for the dead field in 1,225 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding somewhere to move it is harder than moving it
&lt;/h2&gt;

&lt;p&gt;Everything up to here pointed at one recommendation: take the state out of the file and put it in the database. Since 29 June, posts have been served from &lt;code&gt;content.db&lt;/code&gt; at runtime rather than at build time, so that looked like the obvious destination.&lt;/p&gt;

&lt;p&gt;Opening the code showed me half my own advice doesn't work. &lt;code&gt;content.db&lt;/code&gt; isn't an independent ledger but a copy derived from the MDX: the &lt;code&gt;prebuild&lt;/code&gt; step runs &lt;code&gt;migrate-content.mjs&lt;/code&gt;, which fills the &lt;code&gt;social_media&lt;/code&gt; column straight out of the frontmatter. The runtime connection is opened &lt;code&gt;readonly: true&lt;/code&gt;, so the site cannot write to that ledger.&lt;/p&gt;

&lt;p&gt;The script has run incrementally since 29 June: a file whose content hash hasn't changed isn't re-rendered, and its row is carried over from the previous database. So a "shared" value I wrote by hand wouldn't vanish at the next build — it would vanish the first time that post's MDX changed. In practice it comes to the same thing: the source of truth is still the file, and the database is its echo.&lt;/p&gt;

&lt;p&gt;That makes the job bigger than I thought. Deleting the &lt;code&gt;socialMedia&lt;/code&gt; block from 2,831 files is the easy half. The hard half is giving share state its own writable ledger that the build doesn't reset, pointing the sharing script at it, and then inverting ownership. That isn't an evening's work — and it's the honest reason I keep postponing it, rather than "I didn't have time".&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I'm keeping
&lt;/h2&gt;

&lt;p&gt;Writing 1,419 posts in seven and a half months is a story about speed. But that speed turned the archive itself into a cost base: every schema decision I make today gets multiplied by 2,838 files, and tomorrow by a bigger number.&lt;/p&gt;

&lt;p&gt;Output speed is easy to measure, which is why we all measure it. Measuring maintenance debt means looking backwards, and the past is uncomfortable: nine out of every ten edits never touch a sentence. Through one architectural choice, I turned the upkeep of what I produce as a writer into a daily chore for myself.&lt;/p&gt;

&lt;p&gt;Four of my claims collapsed under measurement while I was preparing this post: that the sweep had missed 24 files, that a bot was the biggest editor of my archive (true only when counting commits), that I had removed the field from the schema, and that a fixed line threshold could tell prose edits from label edits. All four sounded good. That's the price of measuring your own history — the numbers may not like the story you're telling. The benefit sits in the same place: a dead field sitting in 1,225 files only becomes visible once you bother to count it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.astro.build/en/guides/content-collections/" rel="noopener noreferrer"&gt;Astro — Content Collections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/diff-options.adoc" rel="noopener noreferrer"&gt;git — diff-options documentation (&lt;code&gt;--diff-filter&lt;/code&gt;, &lt;code&gt;--find-renames&lt;/code&gt;, &lt;code&gt;--numstat&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sre.google/sre-book/eliminating-toil/" rel="noopener noreferrer"&gt;Google SRE Book — Eliminating Toil&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>olcum</category>
      <category>career</category>
      <category>uretkenlik</category>
      <category>sistemmimarisi</category>
    </item>
    <item>
      <title>Opening a File Doesn't Download It, Reading Does</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 14 Nov 2026 16:14:39 +0000</pubDate>
      <link>https://dev.to/merbayerp/opening-a-file-doesnt-download-it-reading-does-6bl</link>
      <guid>https://dev.to/merbayerp/opening-a-file-doesnt-download-it-reading-does-6bl</guid>
      <description>&lt;p&gt;Five days ago I wrote about my cloud quota running out. &lt;a href="https://mustafaerbay.com.tr/en/blog/life/1958-oge-kuyrukta-bulut-kotamda-47798-bayt-kaldi/" rel="noopener noreferrer"&gt;1,958 items were stuck in the&lt;br&gt;
queue&lt;/a&gt;, and that&lt;br&gt;
article's thesis was this: the files taking up disk space are precisely the ones that&lt;br&gt;
&lt;strong&gt;can't&lt;/strong&gt; get to the cloud. Files whose local copy survives because the upload failed.&lt;/p&gt;

&lt;p&gt;This week I asked the same question from the other direction. What happened to the ones&lt;br&gt;
that did go up? How much room do the files that escaped the queue take on my disk?&lt;/p&gt;

&lt;p&gt;The answer: none. My home directory holds &lt;strong&gt;236,583 files&lt;/strong&gt; that &lt;code&gt;ls&lt;/code&gt; reports a size&lt;br&gt;
for, totalling &lt;strong&gt;61.84 GiB&lt;/strong&gt;, and the space all of them occupy on disk is &lt;strong&gt;zero bytes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The more uncomfortable part: those 61.84 GiB don't fit in this machine's free space.&lt;br&gt;
I have 28.85 GiB available. If I asked for every file I own at once, more than half the&lt;br&gt;
request — 53.3% of the bytes — could not be served.&lt;/p&gt;
&lt;h2&gt;
  
  
  Size is a promise, a block is a fact
&lt;/h2&gt;

&lt;p&gt;macOS calls these files "dataless". The name, size, dates and permissions live on disk;&lt;br&gt;
the contents don't. In Finder you see a cloud icon, but from the shell there is no icon,&lt;br&gt;
just numbers. Two different numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ &lt;span class="nb"&gt;cd&lt;/span&gt; ~/Library/Mobile\ Documents/com~apple~CloudDocs
$ ls -lO _bootstrap.scss
-rw-------@ 1 mustafaerbay  staff  compressed,dataless 1340 Jun 20  2024 _bootstrap.scss
$ stat -f &lt;span class="s1"&gt;'size=%z  blocks=%b'&lt;/span&gt; _bootstrap.scss
&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1340  &lt;span class="nv"&gt;blocks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file 1,340 bytes long that occupies zero blocks. The third column of &lt;code&gt;ls -lO&lt;/code&gt; names&lt;br&gt;
the flag too: &lt;code&gt;dataless&lt;/code&gt;. This is the file system's official way of telling me "here is&lt;br&gt;
this file's size, but don't ask where it is."&lt;/p&gt;

&lt;p&gt;Apple explains the situation to users in two sentences: the files don't take up space on&lt;br&gt;
your Mac, and &lt;em&gt;"you can download the original files when you need them."&lt;/em&gt; I've just&lt;br&gt;
confirmed the first half — genuinely zero blocks. The second half, "when you need them",&lt;br&gt;
is what this article is actually about: which moment counts as needing, which operation&lt;br&gt;
triggers the download, and who decides. Apple's support page doesn't go there. I did.&lt;/p&gt;

&lt;p&gt;When I &lt;a href="https://mustafaerbay.com.tr/en/blog/life/diskimde-ne-kadar-yer-var-bes-cevap-13-gib-fark/" rel="noopener noreferrer"&gt;asked five different tools how much room was left on my disk and found a 13.5&lt;br&gt;
GiB gap&lt;/a&gt;, this flag&lt;br&gt;
wasn't in my accounting. It should have been — because one end of that gap is here.&lt;/p&gt;

&lt;p&gt;I didn't go looking for a tool to count them, because no tool is needed. &lt;code&gt;lstat(2)&lt;/code&gt;&lt;br&gt;
returns a file's flags, and the &lt;code&gt;SF_DATALESS&lt;/code&gt; bit is among them. So I wrote a scan that&lt;br&gt;
walks my whole home directory and opens nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="no"&gt;SF_DATALESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x40000000&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;followlinks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_flags&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;SF_DATALESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;   &lt;span class="c1"&gt;# count it, add it up, but DON'T READ it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan took 4 minutes 45 seconds, looked at 2,042,325 files and hit not a single&lt;br&gt;
&lt;code&gt;lstat&lt;/code&gt; error. The result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directory&lt;/th&gt;
&lt;th&gt;Dataless files&lt;/th&gt;
&lt;th&gt;Nominal size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/Library/CloudStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;71,368&lt;/td&gt;
&lt;td&gt;57.23 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/Documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;164,204&lt;/td&gt;
&lt;td&gt;4.39 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/Desktop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;803&lt;/td&gt;
&lt;td&gt;222.23 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/Library/Mobile Documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;208&lt;/td&gt;
&lt;td&gt;0.50 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;236,583&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.84 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 11.58% of the files scanned. The space all four rows take on disk, added&lt;br&gt;
together, is zero bytes. I scanned directories in a separate pass with the same method&lt;br&gt;
(&lt;code&gt;lstat&lt;/code&gt; only, this time over &lt;code&gt;os.walk&lt;/code&gt;'s directory list): there are no dataless&lt;br&gt;
&lt;strong&gt;directories&lt;/strong&gt; at all, 0. On this machine only file contents get emptied out; the&lt;br&gt;
directory structure is entirely local.&lt;/p&gt;

&lt;p&gt;The interesting part of the distribution: the most common extension is &lt;code&gt;.smali&lt;/code&gt; (81,770&lt;br&gt;
files) — decompiled Android sources left over from an old tablet rescue. Most of the&lt;br&gt;
file &lt;strong&gt;count&lt;/strong&gt; sits under &lt;code&gt;~/Documents&lt;/code&gt;, but 92.5% of the &lt;strong&gt;bytes&lt;/strong&gt; are in&lt;br&gt;
&lt;code&gt;~/Library/CloudStorage&lt;/code&gt;: large virtual disk images and video files. Two providers, two&lt;br&gt;
different characters. An inflation of small files on one side, heavy files on the other.&lt;/p&gt;

&lt;p&gt;That there are two providers matters, because this is not an iCloud feature. Microsoft&lt;br&gt;
says so plainly in its own documentation: "The system logic to decide what files count&lt;br&gt;
against used disk space and what files do not is provided by the File Provider platform,&lt;br&gt;
not OneDrive or Microsoft." The same page reaches for Apple's term, the word "dataless",&lt;br&gt;
when describing file state. The flag belongs to the operating system and so does the&lt;br&gt;
mechanism; the providers are only tenants.&lt;/p&gt;
&lt;h2&gt;
  
  
  I didn't set this flag, and I can't clear it
&lt;/h2&gt;

&lt;p&gt;Most file flags are mine to play with. I can set &lt;code&gt;nodump&lt;/code&gt; with &lt;code&gt;chflags&lt;/code&gt;, lock a file&lt;br&gt;
with &lt;code&gt;uchg&lt;/code&gt;, or hide it from Finder if I'm in that kind of mood. &lt;code&gt;SF_DATALESS&lt;/code&gt; is not&lt;br&gt;
like that. The definition in the kernel source says it in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define SF_SYNTHETIC    0xc0000000      /* mask of system read-only synthetic flags */&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="cp"&gt;#define SF_DATALESS     0x40000000     /* file is dataless object */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment above it is blunter: &lt;em&gt;"Synthetic flags. These are read-only. We keep them&lt;br&gt;
out of SF_SUPPORTED so that attempts to set them will fail."&lt;/em&gt; And sure enough, the flag&lt;br&gt;
falls outside the mask of superuser-changeable flags (&lt;code&gt;SF_SETTABLE&lt;/code&gt;, &lt;code&gt;0x3fff0000&lt;/code&gt;).&lt;br&gt;
0x40000000 isn't inside that mask, which means not even root can set this bit by hand.&lt;br&gt;
I didn't test that part; I derived it from the mask. What I did test is the ordinary-user&lt;br&gt;
side.&lt;/p&gt;

&lt;p&gt;And the ordinary-user side surprised me. I tried setting &lt;code&gt;SF_DATALESS&lt;/code&gt; on a local file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SF_DATALESS  chflags OK  -&amp;gt; flags=0x00000000  (bit still set: False)
SF_ARCHIVED  chflags ERROR -&amp;gt; errno=1 Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chflags&lt;/code&gt; &lt;strong&gt;returned success&lt;/strong&gt; and silently swallowed the bit. Whereas a flag that&lt;br&gt;
merely requires privilege — &lt;code&gt;SF_ARCHIVED&lt;/code&gt; — failed honestly with &lt;code&gt;EPERM&lt;/code&gt; in the same&lt;br&gt;
spot. The unsupported flag fails more quietly than the supported-but-forbidden one. A&lt;br&gt;
script that checks the return value and calls it done would be wrong here; I was wrong&lt;br&gt;
for a moment too, until I read the flags back.&lt;/p&gt;

&lt;p&gt;And that measurement corrects the comment I just quoted. The source says "attempts to&lt;br&gt;
set them will fail"; on macOS 26.6 the attempt does &lt;strong&gt;not&lt;/strong&gt; fail, it quietly does&lt;br&gt;
nothing. Those aren't the same thing: one returns &lt;code&gt;-1&lt;/code&gt;, the other returns &lt;code&gt;0&lt;/code&gt;. Because&lt;br&gt;
the bit sits outside the mask, the kernel drops it and doesn't complain. What the&lt;br&gt;
comment means — the flag can't be written — is true; the word "fail" is misleading in&lt;br&gt;
the sense a shell script would understand it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which operation downloads?
&lt;/h2&gt;

&lt;p&gt;This was the real question. What happens if I run &lt;code&gt;grep -r&lt;/code&gt; across those 236,583 files?&lt;br&gt;
If I list their names? If I back them up?&lt;/p&gt;

&lt;p&gt;I picked nine fresh dataless files — a different file for each operation, because the&lt;br&gt;
first thing to touch a file localises it and the second measurement is no longer the&lt;br&gt;
same experiment. Before and after each operation I read the flag and the block count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Downloaded?&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lstat(2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;0.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ls -l&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;6.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stat -f %z&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;3.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;du -k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;3.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;open(2)&lt;/code&gt;, without reading&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;head -c 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;1,243 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep -c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;1,177 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;md5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;1,206 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;3,869 ms ⚠&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top five work on metadata and never touch the file. &lt;code&gt;du&lt;/code&gt; included — which makes&lt;br&gt;
sense, since &lt;code&gt;du&lt;/code&gt; counts blocks and there are no blocks here to count. (The flagged&lt;br&gt;
3,869 ms was the session's very first measurement and carries the service's warm-up&lt;br&gt;
cost; trust the median below, not that row.)&lt;/p&gt;

&lt;p&gt;The bottom four read, and reading downloads. But look where &lt;code&gt;open(2)&lt;/code&gt; sits: in the top&lt;br&gt;
group. Opening the file, even read-only, pulls down nothing.&lt;/p&gt;

&lt;p&gt;Since the article's title rests on that row, I didn't leave it at one measurement. I&lt;br&gt;
opened nine fresh dataless files read-only and closed them without reading a byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  406 B  open  0.026 ms  dataless True-&amp;gt;True  blocks 0-&amp;gt;0
  ...
  450 B  open  0.018 ms  dataless True-&amp;gt;True  blocks 0-&amp;gt;0

open(2) RESULT: 9/9 files STAYED dataless, median 0.020 ms (min 0.016, max 0.026)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine out of nine stayed dataless, and the open took a median of &lt;strong&gt;0.020 milliseconds&lt;/strong&gt; —&lt;br&gt;
nothing that asks the network for anything can be that fast. The thing that downloads is&lt;br&gt;
the first &lt;strong&gt;read&lt;/strong&gt;. Microsoft's documentation says the file "won't download to your&lt;br&gt;
device until you open it"; in everyday terms that's true, because opening a file in an&lt;br&gt;
application involves reading it. At the syscall level, though, the line isn't at &lt;code&gt;open&lt;/code&gt;,&lt;br&gt;
it's at &lt;code&gt;read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I can't read &lt;em&gt;why&lt;/em&gt; the line falls exactly there out of the kernel source, and saying so&lt;br&gt;
is part of being honest about this: &lt;code&gt;vfs_materialize_file()&lt;/code&gt;, which calls the resolver,&lt;br&gt;
is open in xnu, but the code deciding which filesystem operation triggers it lives&lt;br&gt;
inside APFS, and APFS isn't open source. I can measure the boundary; I can't read it.&lt;/p&gt;

&lt;p&gt;So how long does the download take? The table's figures are single measurements; for a&lt;br&gt;
number free of warm-up cost I ran a clean series across nine files, reading a single&lt;br&gt;
byte each time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;median first read: &lt;strong&gt;1,166 ms&lt;/strong&gt; (fastest 1,092, slowest 1,441)&lt;/li&gt;
&lt;li&gt;median second read of the same file: &lt;strong&gt;0.208 ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About &lt;strong&gt;5,600×&lt;/strong&gt;. A file's first read costs five thousand times what its second read&lt;br&gt;
does. (That's the ratio of two medians, not the median of the per-file ratios.)&lt;/p&gt;

&lt;p&gt;And the "single byte" doesn't stay a single byte. From a dataless file of 3,693,410&lt;br&gt;
bytes I asked only for the first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nominal=3693410 B  before blocks=0  after blocks=7216 (=3694592 B)  dataless True-&amp;gt;False  2063 ms for 1 byte
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked for one byte and 3.52 MiB arrived. No partial download happened here: the file&lt;br&gt;
is either present or it isn't.&lt;/p&gt;

&lt;p&gt;It would be wrong to pin that on the mechanism as a whole, though, because the kernel&lt;br&gt;
side is open to range requests. The signature of the function that calls the resolver&lt;br&gt;
looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="nf"&gt;vfs_materialize_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;vnode&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;size&lt;/code&gt; are right there — "fetch this range of this file" is an expressible&lt;br&gt;
thing. The whole file arriving is not a kernel limit, it's a choice made by the provider&lt;br&gt;
at the end of this path. What the measurement shows is that a one-byte request ended&lt;br&gt;
with the whole file local; which link in the chain declined to use the range, I can't&lt;br&gt;
see from here.&lt;/p&gt;

&lt;p&gt;A practical consequence follows, and I don't like it: a &lt;code&gt;grep -r&lt;/code&gt; hunting for content in&lt;br&gt;
my home directory would try to pull down 61.84 GiB. With 28.85 GiB free, it would fill&lt;br&gt;
the disk short of halfway. Microsoft notes on that same page that Spotlight doesn't do&lt;br&gt;
this: it won't make dataless files locally available while indexing. Sensible. The&lt;br&gt;
question is how Spotlight manages &lt;em&gt;not&lt;/em&gt; to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who decides: the kernel's answer
&lt;/h2&gt;

&lt;p&gt;In the kernel source the mechanism is called the "namespace resolver". A comment block&lt;br&gt;
lays out how it works: when I/O is performed on a dataless object, the file system makes&lt;br&gt;
an upcall to &lt;code&gt;filecoordinationd&lt;/code&gt;, the calling thread goes into an interruptible sleep,&lt;br&gt;
and when the work is done the service wakes it by writing to the &lt;code&gt;vfs.nspace.complete&lt;/code&gt;&lt;br&gt;
node. Only a process registered as the resolver may write to that node, and the source&lt;br&gt;
notes: &lt;em&gt;"The system filecoordinationd runs as uid == 0."&lt;/em&gt; On my machine, there it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ ps -Ao pid,user,comm &lt;span class="o"&gt;|&lt;/span&gt; grep -E &lt;span class="s1"&gt;'filecoordinationd|fileproviderd|bird'&lt;/span&gt;
  715 mustafaerbay     /System/Library/PrivateFrameworks/FileProvider.framework/Support/fileproviderd
  732 root             /usr/sbin/filecoordinationd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver runs as root, the File Provider side as my user. (iCloud's own &lt;code&gt;bird&lt;/code&gt;&lt;br&gt;
process isn't in this list; it comes and goes on demand — it showed up in the first&lt;br&gt;
round of measurements, not this one.)&lt;/p&gt;

&lt;p&gt;What struck me came a few hundred lines further down the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * The default behavior is to not materialize dataless files;
 * return to the caller that deadlock was detected.
 */&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;EDEADLK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The default behaviour is not to download.&lt;/strong&gt; Even though every read in my shell&lt;br&gt;
downloads, the system's documented default is "don't fetch it, return an error." The&lt;br&gt;
&lt;code&gt;getiopolicy_np(3)&lt;/code&gt; man page says the same thing: when the default policy is selected at&lt;br&gt;
process scope, the system default applies, and that is&lt;br&gt;
&lt;code&gt;IOPOL_MATERIALIZE_DATALESS_FILES_OFF&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So why does my shell download? Because the policy is per-process and inherited. The man&lt;br&gt;
page puts it in one sentence: &lt;em&gt;"New processes inherit the policy of their parent&lt;br&gt;
process."&lt;/em&gt; The numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;this process (PROCESS): (2, 'ON(2)')
this thread  (THREAD):  (0, 'DEFAULT(0)')
child process PROCESS:  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My shell is ON. My shell's child is ON too. But if the system default is OFF, where did&lt;br&gt;
this ON come from? I took the same reading in a process started directly by &lt;code&gt;launchd&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pid=97432 ppid=1 comm=/sbin/launchd
sysctl prevent_materialization=1
getiopolicy PROCESS = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OFF. Exactly as documented. So on the same machine, running as the same user, one of two&lt;br&gt;
processes can read the file and the other cannot — and what makes the difference isn't&lt;br&gt;
the file, it's &lt;strong&gt;whose child you are in the process tree&lt;/strong&gt;. My shell runs underneath a&lt;br&gt;
desktop application; I inherited the ON from somewhere in that chain. From here I can't&lt;br&gt;
see which link set it, and I'm not going to invent one.&lt;/p&gt;

&lt;p&gt;When I turned the policy off in my own process and tried the same read, I got the&lt;br&gt;
kernel's answer first-hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setiopolicy_np(OFF) -&amp;gt; 0 | now: (1, 'OFF(1)')
READ ERROR: errno=11 (EDEADLK) -&amp;gt; Resource deadlock avoided   0.0 ms
file: before dataless=True blocks=0 | after dataless=True blocks=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero milliseconds, "Resource deadlock avoided", and an untouched file.&lt;/p&gt;

&lt;p&gt;That also answers the Spotlight question: it needs to do nothing special to avoid&lt;br&gt;
downloading — leaving the policy at its default is enough. I couldn't confirm this by&lt;br&gt;
measurement. &lt;code&gt;getiopolicy_np&lt;/code&gt; reports only the calling process's policy; there is no&lt;br&gt;
interface for asking about another process (the same is true of the &lt;code&gt;sysctl&lt;/code&gt; node). So&lt;br&gt;
this is an inference: Microsoft's sentence and the kernel's default together imply the&lt;br&gt;
indexer stays at the default, but I can't put the question to &lt;code&gt;mdworker&lt;/code&gt; and get an&lt;br&gt;
answer.&lt;/p&gt;

&lt;p&gt;To my mind that error message is the worst part of the whole mechanism. It's technically&lt;br&gt;
defensible — the kernel is refusing to wait on a service that would have to answer it,&lt;br&gt;
which is a deadlock guard. But for someone staring at "resource deadlock avoided" in a&lt;br&gt;
script's log, the last explanation that will come to mind is that the file is sitting in&lt;br&gt;
somebody else's data centre.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsicmVhZCAxIGJ5dGUiXSAtLT4gQnsiZG9lcyB0aGUgZmlsZSBjYXJyeTxici8-dGhlIFNGX0RBVEFMRVNTIGJpdD8ifQogIEIgLS0gIm5vIiAtLT4gQ1sicmVhZCBmcm9tIGRpc2s8YnIvPm1lZGlhbiAwLjIwOCBtcyJdCiAgQiAtLSAieWVzIiAtLT4gRHsiaXMgdGhlIHByb2Nlc3MgaW9wb2xpY3k8YnIvPm1hdGVyaWFsaXplIE9OPyJ9CiAgRCAtLSAibm8sIHRoZSBzeXN0ZW0gZGVmYXVsdCIgLS0-IEVbIkVERUFETEs8YnIvPlJlc291cmNlIGRlYWRsb2NrIGF2b2lkZWQ8YnIvPjAuMCBtcywgZmlsZSB1bnRvdWNoZWQiXQogIEQgLS0gInllcywgaW5oZXJpdGVkIiAtLT4gRlsia2VybmVsOiBuc3BhY2UgdXBjYWxsPGJyLz5maWxlY29vcmRpbmF0aW9uZCwgdWlkIDAiXQogIEYgLS0-IEdbImNhbGxpbmcgdGhyZWFkIHNsZWVwczxici8-d2FpdGluZyBvbiB2ZnMubnNwYWNlLmNvbXBsZXRlIl0KICBHIC0tPiBIWyJ0aGUgV0hPTEUgZmlsZSBhcnJpdmVzPGJyLz5tZWRpYW4gMSwxNjYgbXMiXQogIEggLS0-IElbIlNGX0RBVEFMRVNTIGNsZWFyZWQ8YnIvPmJsb2NrcyBhbGxvY2F0ZWQiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsicmVhZCAxIGJ5dGUiXSAtLT4gQnsiZG9lcyB0aGUgZmlsZSBjYXJyeTxici8-dGhlIFNGX0RBVEFMRVNTIGJpdD8ifQogIEIgLS0gIm5vIiAtLT4gQ1sicmVhZCBmcm9tIGRpc2s8YnIvPm1lZGlhbiAwLjIwOCBtcyJdCiAgQiAtLSAieWVzIiAtLT4gRHsiaXMgdGhlIHByb2Nlc3MgaW9wb2xpY3k8YnIvPm1hdGVyaWFsaXplIE9OPyJ9CiAgRCAtLSAibm8sIHRoZSBzeXN0ZW0gZGVmYXVsdCIgLS0-IEVbIkVERUFETEs8YnIvPlJlc291cmNlIGRlYWRsb2NrIGF2b2lkZWQ8YnIvPjAuMCBtcywgZmlsZSB1bnRvdWNoZWQiXQogIEQgLS0gInllcywgaW5oZXJpdGVkIiAtLT4gRlsia2VybmVsOiBuc3BhY2UgdXBjYWxsPGJyLz5maWxlY29vcmRpbmF0aW9uZCwgdWlkIDAiXQogIEYgLS0-IEdbImNhbGxpbmcgdGhyZWFkIHNsZWVwczxici8-d2FpdGluZyBvbiB2ZnMubnNwYWNlLmNvbXBsZXRlIl0KICBHIC0tPiBIWyJ0aGUgV0hPTEUgZmlsZSBhcnJpdmVzPGJyLz5tZWRpYW4gMSwxNjYgbXMiXQogIEggLS0-IElbIlNGX0RBVEFMRVNTIGNsZWFyZWQ8YnIvPmJsb2NrcyBhbGxvY2F0ZWQiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="505" height="1428"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Everything so far was one provider
&lt;/h2&gt;

&lt;p&gt;Every download measurement above was taken on iCloud Drive files. But 92.5% of my&lt;br&gt;
dataless bytes aren't there — they're with the second provider, under&lt;br&gt;
&lt;code&gt;~/Library/CloudStorage&lt;/code&gt;, 57.23 GiB of them. Saying "the mechanism works like this"&lt;br&gt;
without putting the question to that side too would be treating the half I didn't&lt;br&gt;
measure like the half I did.&lt;/p&gt;

&lt;p&gt;So I asked. Five files, all dataless, between 1 KB and 100 KB, one byte from each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    1184 B -&amp;gt; errno=60 Operation timed out    719.6 ms  dataless=True blocks=0
    1231 B -&amp;gt; errno=60 Operation timed out    653.4 ms  dataless=True blocks=0
    1763 B -&amp;gt; errno=60 Operation timed out    679.2 ms  dataless=True blocks=0
    1950 B -&amp;gt; errno=60 Operation timed out    708.1 ms  dataless=True blocks=0
  101240 B -&amp;gt; errno=60 Operation timed out   2308.0 ms  dataless=True blocks=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five out of five &lt;code&gt;ETIMEDOUT&lt;/code&gt;. Not one arrived. The provider's File Provider extension&lt;br&gt;
was &lt;strong&gt;running&lt;/strong&gt; on the machine at the time — it's right there in the process list — so&lt;br&gt;
this isn't a case to wave away with "the app was closed." I did not diagnose why it&lt;br&gt;
times out: the session may have expired, something may be wrong on the account side, it&lt;br&gt;
may be something else. I'm not writing it down, because I don't know.&lt;/p&gt;

&lt;p&gt;Even undiagnosed, this stands: 57.23 GiB that Finder shows with sizes &lt;strong&gt;cannot be&lt;br&gt;
fetched&lt;/strong&gt; on this machine right now. Not slow — not arriving. And the &lt;code&gt;brctl&lt;/code&gt; you'll see&lt;br&gt;
in the next section is no help on this side; that's iCloud's tool.&lt;/p&gt;

&lt;p&gt;So the "median 1,166 ms", the "asked for one byte, got the whole file" and the "5,600×"&lt;br&gt;
in this article are all statements about iCloud Drive. For the other provider, the only&lt;br&gt;
measurement I have is five timeouts out of five attempts.&lt;/p&gt;
&lt;h2&gt;
  
  
  I got it wrong twice while measuring
&lt;/h2&gt;

&lt;p&gt;The article would look tidier without this section. I'm keeping it, because both traps&lt;br&gt;
are ones somebody else will fall into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first.&lt;/strong&gt; To prove the policy difference I thought I had set up an elegant&lt;br&gt;
experiment: run the same reader script by hand and through &lt;code&gt;launchd&lt;/code&gt;. The one run by&lt;br&gt;
hand downloaded; the &lt;code&gt;launchd&lt;/code&gt; one failed with &lt;code&gt;EPERM&lt;/code&gt;. Just as I was about to call that&lt;br&gt;
proof, something nagged at me — the error I expected was &lt;code&gt;EDEADLK&lt;/code&gt;, and &lt;code&gt;EPERM&lt;/code&gt; came&lt;br&gt;
back. So I gave the same script one more target: a file in the same folder that is fully&lt;br&gt;
local and &lt;strong&gt;not&lt;/strong&gt; dataless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dataless iCloud file  -&amp;gt; errno=1 EPERM (Operation not permitted)   46.1 ms
LOCAL iCloud file     -&amp;gt; errno=1 EPERM (Operation not permitted)    0.0 ms
scratchpad local file -&amp;gt; READ OK                                    0.0 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;launchd&lt;/code&gt;-started process can't read the local file either. So the obstacle isn't the&lt;br&gt;
dataless policy, it's the privacy layer: a process with no identity has no access to user&lt;br&gt;
folders. The experiment said nothing whatsoever about materialisation. The policy&lt;br&gt;
difference is still real — &lt;code&gt;getiopolicy_np&lt;/code&gt; reports it directly — but that read attempt&lt;br&gt;
is not the evidence for it. Without drawing the distinction I would have reached a correct&lt;br&gt;
conclusion on false evidence. A number that supports your case deserves to be questioned&lt;br&gt;
twice; the convenient number is the least questioned one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second.&lt;/strong&gt; &lt;code&gt;vfs.nspace.prevent_materialization&lt;/code&gt; is a &lt;code&gt;sysctl&lt;/code&gt; node. Say &lt;code&gt;sysctl&lt;/code&gt; and&lt;br&gt;
people assume a system-wide setting; the name reads that way too. It isn't. I read it from&lt;br&gt;
three places and got three answers: &lt;code&gt;0&lt;/code&gt; in my shell, &lt;code&gt;1&lt;/code&gt; in the process where I had turned&lt;br&gt;
the policy off, &lt;code&gt;1&lt;/code&gt; in the &lt;code&gt;launchd&lt;/code&gt; process. The node reports the state of whoever asks.&lt;br&gt;
A monitoring script that reports this as a machine's "setting" will produce different&lt;br&gt;
truths depending on which process it runs from.&lt;/p&gt;
&lt;h2&gt;
  
  
  Counting is easy, putting it back needs a tool
&lt;/h2&gt;

&lt;p&gt;As soon as the measurements were done I evicted the files I'd downloaded: &lt;code&gt;brctl evict&lt;/code&gt;,&lt;br&gt;
11 out of 11 successful. Cleanup complete — or so I thought.&lt;/p&gt;

&lt;p&gt;While finishing the article I counted again, since this piece's whole thesis is "don't&lt;br&gt;
assume, count blocks." Good thing I did. My cleanup script only held the list of the 11&lt;br&gt;
files from the timing series; the run that produced the operation table was a different&lt;br&gt;
script, and it never wrote its 4 downloaded files to that list. On top of that, the&lt;br&gt;
target file from the EDEADLK test had been evicted once and then re-downloaded on a later&lt;br&gt;
round. Re-scanning the &lt;code&gt;~/Library/Mobile Documents&lt;/code&gt; tree said so plainly: dataless was&lt;br&gt;
203, not 208.&lt;/p&gt;

&lt;p&gt;I evicted the missing five and counted once more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FINAL STATE Mobile Documents: total 223 dataless 208  (first scan: 208)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen files downloaded in total: 11 in the timing series, 4 in the operation table, and&lt;br&gt;
one of them twice across two rounds. All evicted, and the count is back to the 208 it&lt;br&gt;
started at. Nothing came down at the second provider — nothing could — so there was&lt;br&gt;
nothing to clean up there.&lt;/p&gt;

&lt;p&gt;The difference between saying "I cleaned up" and having cleaned up was exactly the&lt;br&gt;
difference between reading a &lt;code&gt;brctl evict&lt;/code&gt; exit status and counting the tree again.&lt;/p&gt;

&lt;p&gt;Doing that walked me into a practical gap in the mechanism. &lt;code&gt;brctl&lt;/code&gt; is iCloud Drive's&lt;br&gt;
tool only: its man page lists &lt;code&gt;download &amp;lt;path&amp;gt;&lt;/code&gt; and &lt;code&gt;evict &amp;lt;path&amp;gt;&lt;/code&gt;, and both do their&lt;br&gt;
job. But 92.5% of my dataless bytes aren't in iCloud, they're with another provider&lt;br&gt;
running over File Provider. What's available for that one? &lt;code&gt;fileproviderctl&lt;/code&gt;'s command&lt;br&gt;
list offers &lt;code&gt;dump&lt;/code&gt;, &lt;code&gt;diagnose&lt;/code&gt;, &lt;code&gt;evaluate&lt;/code&gt;, &lt;code&gt;check | repair&lt;/code&gt; and &lt;code&gt;obfuscate&lt;/code&gt; — no&lt;br&gt;
per-file download or eviction. A deliberate eviction from the command line is documented&lt;br&gt;
only on the iCloud side; on the other side you fall back to Finder's context menu or the&lt;br&gt;
provider's own interface.&lt;/p&gt;

&lt;p&gt;Querying &lt;strong&gt;state&lt;/strong&gt;, though, is provider-independent. On 9 November I wrote that there was&lt;br&gt;
no command to ask about a single file's status, then found what I was after in &lt;code&gt;brctl&lt;/code&gt;'s&lt;br&gt;
own help text. This time the answer is cleaner, and a little humbling: no command is&lt;br&gt;
needed. The flag is on the file itself. And &lt;code&gt;lstat&lt;/code&gt; tells the same truth for both&lt;br&gt;
providers, because neither of them sets that flag — the kernel does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning the policy off myself
&lt;/h2&gt;

&lt;p&gt;If the kernel hands you a door, use it. I wrote a ten-line wrapper that calls&lt;br&gt;
&lt;code&gt;setiopolicy_np&lt;/code&gt; and then &lt;code&gt;exec&lt;/code&gt;s whatever command I give it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3&lt;/span&gt;
&lt;span class="s2"&gt;"""nofetch.py &amp;lt;command&amp;gt; [arg...] — runs it with materialization off"""&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="no"&gt;IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="no"&gt;IOPOL_SCOPE_PROCESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="no"&gt;IOPOL_MATERIALIZE_DATALESS_FILES_OFF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;libc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CDLL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_errno&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setiopolicy_np&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="no"&gt;IOPOL_SCOPE_PROCESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="no"&gt;IOPOL_MATERIALIZE_DATALESS_FILES_OFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'setiopolicy_np failed: %d'&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_errno&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execvp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the policy is inherited, it still holds after the &lt;code&gt;exec&lt;/code&gt;. I pointed &lt;code&gt;grep&lt;/code&gt; at&lt;br&gt;
three dataless files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./nofetch.py grep -l color &amp;lt;three-dataless-files&amp;gt;
grep: .../_bootstrap-dark.scss: Resource deadlock avoided
grep: .../_bootstrap-extended-dark.scss: Resource deadlock avoided
grep: .../_bootstrap-extended.scss: Resource deadlock avoided
$ echo $?
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three stayed dataless, block count zero. This was the thing I actually wanted to&lt;br&gt;
know: &lt;code&gt;grep&lt;/code&gt; doesn't skip the file quietly. It writes a line to stderr for each one and&lt;br&gt;
&lt;strong&gt;exits 2&lt;/strong&gt;. A scan run through this wrapper tells you which files it couldn't touch.&lt;br&gt;
No silent omission — which matters more than blocking the download does.&lt;/p&gt;

&lt;p&gt;I'm not offering this as a general backup recipe; I tried it with &lt;code&gt;grep&lt;/code&gt; on my own&lt;br&gt;
machine, and I haven't measured how &lt;code&gt;tar&lt;/code&gt; or anything else reacts to that error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm going to do
&lt;/h2&gt;

&lt;p&gt;My list for myself is short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stop counting the second provider's 57.23 GiB as a backup.&lt;/strong&gt; Five attempts, five
timeouts. If I can't read a file I can't back it up either, and Finder showing me a
size promises me nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put content searches through the wrapper.&lt;/strong&gt; A bare &lt;code&gt;grep -r&lt;/code&gt; in my home directory is
a download command as far as I'm concerned now. Run through &lt;code&gt;nofetch&lt;/code&gt;, the worst case
is that it counts the files it couldn't touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask "what's on disk" in blocks.&lt;/strong&gt; &lt;code&gt;ls&lt;/code&gt; tells you the size; &lt;code&gt;st_blocks&lt;/code&gt; tells you
whether it occupies anything. On this machine the gap between the two is 61.84 GiB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin what matters.&lt;/strong&gt; If a file matters enough that I want it to open when I have no
connection, it's too important to leave to the cloud's idea of freeing up space.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the exact inverse of the accounting error in the piece about&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/ayni-baytlar-icin-74-gib-odedim-diskimde-12-5-gib-kaldi/" rel="noopener noreferrer"&gt;APFS clones making me pay twice for the same bytes&lt;/a&gt;.&lt;br&gt;
There, one copy of a byte was counted in two places; here, bytes that don't exist are&lt;br&gt;
counted in one. Both lie to the same question in different directions: how much room&lt;br&gt;
does this file take?&lt;/p&gt;

&lt;p&gt;One closing note, in cloud storage's favour rather than against it: the iCloud side&lt;br&gt;
genuinely works well. I can wander around a five-year archive as if I were carrying it on&lt;br&gt;
a 460 GiB disk, and most days I don't feel the difference. What bothers me isn't the&lt;br&gt;
mechanism but its invisibility: the file system shows me not what I have, but what I&lt;br&gt;
&lt;em&gt;think&lt;/em&gt; I have.&lt;/p&gt;

&lt;p&gt;A file being "here" is no longer a binary state. Its name is here, its size is here, its&lt;br&gt;
date is here; its contents depend on a policy. I didn't write that policy, I mostly&lt;br&gt;
haven't seen it, and until today I had never once checked it. There is 61.84 GiB of&lt;br&gt;
promise on my disk — and today I learned that 57.23 GiB of it doesn't answer when asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/stat.h" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;bsd/sys/stat.h&lt;/code&gt;: SF_DATALESS, SF_SYNTHETIC and the flag masks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/main/bsd/vfs/vfs_syscalls.c" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;bsd/vfs/vfs_syscalls.c&lt;/code&gt;: the namespace resolver mechanism and the EDEADLK default&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/optimize-storage-space-sysp4ee93ca4/mac" rel="noopener noreferrer"&gt;Apple — Optimize storage space on your Mac&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/store-files-in-icloud-drive-mchle5a61431/mac" rel="noopener noreferrer"&gt;Apple — Store files in iCloud Drive on Mac&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.microsoft.com/en-us/onedrive/save-disk-space-with-onedrive-files-on-demand-for-mac" rel="noopener noreferrer"&gt;Microsoft — Save disk space with OneDrive Files On-Demand for Mac&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>icloud</category>
      <category>depolama</category>
      <category>olcum</category>
    </item>
    <item>
      <title>I Asked Permission Before Sending the Body: 100-continue</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 14 Nov 2026 11:24:02 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-asked-permission-before-sending-the-body-100-continue-30eb</link>
      <guid>https://dev.to/merbayerp/i-asked-permission-before-sending-the-body-100-continue-30eb</guid>
      <description>&lt;p&gt;HTTP has a polite question, and almost nobody asks it. A client that puts &lt;code&gt;Expect: 100-continue&lt;/code&gt; in its request headers is saying: the body is ready in my hands, but I want your approval before I put it on the wire. If the server says "go ahead", a megabyte travels. If it says "no", nothing travels.&lt;/p&gt;

&lt;p&gt;This week I asked that question on my own machine dozens of times. Two numbers came out of it: when the question goes unanswered the price is exactly one second, and when it is answered the saving is a megabyte. Whose fault the gap is remains unclear — every component in the path behaved correctly according to its own documentation.&lt;/p&gt;

&lt;p&gt;There was a side effect too: the threshold in my head had been wrong for six years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rig
&lt;/h2&gt;

&lt;p&gt;14 November 2026, my own laptop: macOS 26.6.2, Apple M4 Pro; &lt;code&gt;curl 8.7.1&lt;/code&gt;, Node.js 22.16.0, Go 1.26.1 and &lt;code&gt;nginx 1.29.8-alpine&lt;/code&gt; inside Docker 27.4.0. Client and server both sit on &lt;code&gt;127.0.0.1&lt;/code&gt;, so the delays below only show when one process turns back to the other.&lt;/p&gt;

&lt;p&gt;Seeing who asks the question took a raw TCP listener: a short Node script that prints the headers verbatim, can choose to send &lt;code&gt;100 Continue&lt;/code&gt; or stay quiet, reads the body to the end and returns &lt;code&gt;204&lt;/code&gt;. It also holds the number I was really after — when the first byte of the body landed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// \r\n\r\n seen: headers are over&lt;/span&gt;
  &lt;span class="n"&gt;headersDone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'continue'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HTTP/1.1 100 Continue\r\n\r\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on measurement hygiene: I threw my first round of numbers away. Five variants had run one after another on the same port number, and two rows contradicted themselves — one looked as if the request had never been sent, and the row with the suppressed &lt;code&gt;Expect&lt;/code&gt; looked as if the header had gone out anyway. Something was clearly getting mixed up between one process shutting down and the next run starting; I never pinned down the exact root cause, and did not try to. Every variant got its own port, the contradiction disappeared, and the rows became repeatable. The numbers below are from that second round.&lt;/p&gt;

&lt;h2&gt;
  
  
  One byte, one second
&lt;/h2&gt;

&lt;p&gt;The first question was simple: at what body size does curl add this header on its own? I sent the same request in three sizes against the silent server.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;Expect&lt;/code&gt; header sent&lt;/th&gt;
&lt;th&gt;First byte of body&lt;/th&gt;
&lt;th&gt;curl total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,024 B&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;0.3 ms&lt;/td&gt;
&lt;td&gt;0.01 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,048,576 B (exactly 1 MiB)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;td&gt;0.01 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,048,577 B (1 MiB + 1 B)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,003.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.01 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One byte, one second. The threshold is exactly 1 MiB and the comparison is strictly greater than: a whole mebibyte asks nothing, one byte more asks. The source says the same thing — curl's &lt;code&gt;lib/http.h&lt;/code&gt; carries &lt;code&gt;#define EXPECT_100_THRESHOLD (1024 * 1024)&lt;/code&gt;, and &lt;code&gt;addexpect()&lt;/code&gt; in &lt;code&gt;lib/http.c&lt;/code&gt; adds the header under &lt;code&gt;client_len &amp;gt; EXPECT_100_THRESHOLD || client_len &amp;lt; 0&lt;/code&gt;. That second condition matters: if the body length is unknown (&lt;code&gt;chunked&lt;/code&gt;), permission is requested whatever the size turns out to be.&lt;/p&gt;

&lt;p&gt;This is where my educated guess fell over. I had that threshold filed away as 1 KiB, and plenty of pages on the internet still say so. It was true — until 2020. curl 7.69.0 &lt;a href="https://github.com/curl/curl/commit/7ff9222ced8c1630bef87d7d744c286874e659ba" rel="noopener noreferrer"&gt;raised the threshold from 1024 to 1024*1024&lt;/a&gt;: a factor of a thousand. So the sentence "curl sends Expect on POSTs over 1 KB" has been wrong for six years, and I was still carrying it. You only notice this sort of thing by taking the measurement.&lt;/p&gt;

&lt;p&gt;Where does the one second come from? curl picks its own waiting period and exposes it as &lt;code&gt;--expect100-timeout&lt;/code&gt;; &lt;a href="https://curl.se/docs/manpage.html" rel="noopener noreferrer"&gt;curl's own manual&lt;/a&gt; documents the default as one second, and in the code it sits in &lt;code&gt;cr_exp100_read()&lt;/code&gt; as the comparison &lt;code&gt;ms &amp;lt; data-&amp;gt;set.expect_100_timeout&lt;/code&gt;. When the time is up curl does not give up — it sends the body anyway, and confesses in a single &lt;code&gt;-v&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; Expect: 100-continue
* Done waiting for 100-continue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four variants, the same 1 MiB + 1 B body — each a separate run, which is why the first row sits a few milliseconds away from the third row of the table above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;First byte of body&lt;/th&gt;
&lt;th&gt;curl total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server stays quiet&lt;/td&gt;
&lt;td&gt;1,004.6 ms&lt;/td&gt;
&lt;td&gt;1.009 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server sends &lt;code&gt;100 Continue&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.8 ms&lt;/td&gt;
&lt;td&gt;0.004 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--expect100-timeout 0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;102.2 ms&lt;/td&gt;
&lt;td&gt;0.107 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header suppressed with &lt;code&gt;-H 'Expect:'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;td&gt;0.005 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In raw values that is 1.009073 s against 0.004401 s: a factor of more than two hundred riding on whether one header line gets answered. The first row went through four runs; the first byte of the body landed between 1,001 and 1,005 ms every time, so that one second comes out of the same place on every run. Politeness is free if the other party is polite too.&lt;/p&gt;

&lt;p&gt;Go has the same number, but it arrives with the door shut. Go's client never adds this header by itself: when the same body went out through &lt;code&gt;http.DefaultClient&lt;/code&gt;, the server saw no &lt;code&gt;Expect&lt;/code&gt; at all and the request finished in 4 ms. With the header set by hand, Go waited exactly 1,000.5 ms — because &lt;code&gt;DefaultTransport&lt;/code&gt; carries &lt;code&gt;ExpectContinueTimeout: 1 * time.Second&lt;/code&gt;. &lt;a href="https://pkg.go.dev/net/http#Transport" rel="noopener noreferrer"&gt;The documentation&lt;/a&gt; spells out what zero means: send the body immediately, without waiting for the server to approve. Two separate teams, two languages, the same one second. No calculation produced that number; everyone copied it from everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the rule changes who is at fault
&lt;/h2&gt;

&lt;p&gt;Up to here I have painted the silent server as the villain. Reading the specification turned the picture around.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-10.1.1" rel="noopener noreferrer"&gt;RFC 9110 §10.1.1&lt;/a&gt; does not treat this wait as a one-sided wish; it gives orders to both ends. On the server side the language is firm: if an HTTP/1.1-or-later request carries &lt;code&gt;100-continue&lt;/code&gt;, the origin server MUST send &lt;strong&gt;either&lt;/strong&gt; the final status code that the method, target and header fields already determine, &lt;strong&gt;or&lt;/strong&gt; an immediate &lt;code&gt;100&lt;/code&gt;. There is a prohibition on top of that: the origin server MUST NOT wait for the content before sending the &lt;code&gt;100&lt;/code&gt;. Proxies get the same fork: either answer with a final status or forward the request inbound (MUST).&lt;/p&gt;

&lt;p&gt;The client side, surprisingly, is left loose. The text names no specific duration; it says the client is not required to wait for any specific length of time, and draws its only line with a SHOULD NOT: it should not wait indefinitely. That is all it says about duration; the client has other obligations (it must not generate this expectation on a request with no content, for one), but the only bound on waiting is that SHOULD NOT. curl's one second and Go's one second are the same number two teams independently dropped into that gap.&lt;/p&gt;

&lt;p&gt;The practical consequence: my raw listener, as the party that creates that one second, is the one breaking the rule. Against a well-behaved HTTP/1.1 server the delay never appears — it did not appear with nginx, and it did not appear with a default Node server. So when you see a one-second wait in the field, what you should be hunting is not "the price of the protocol" but a counterpart that ignores the rule: an application hook left half-finished, or an intermediary that does not care about this header. The next two sections measure exactly those two cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who answers the question
&lt;/h2&gt;

&lt;p&gt;The question has a recipient. Node.js's HTTP server answers it even if you do nothing: &lt;a href="https://nodejs.org/api/http.html#event-checkcontinue" rel="noopener noreferrer"&gt;the documented wording&lt;/a&gt; is clear — if the &lt;code&gt;'checkContinue'&lt;/code&gt; event is not listened for, the server "will automatically respond with a 100 Continue as appropriate". That is what the timing showed: against a default Node server the same request finished in 0.011 s, with a &lt;code&gt;100 Continue&lt;/code&gt; on the wire.&lt;/p&gt;

&lt;p&gt;The interesting part starts the moment you listen for that event. Adding a listener switches the automatic answer off and hands the responsibility to you — the docs say that too: when the event is handled, the &lt;code&gt;'request'&lt;/code&gt; event is never emitted. An application that adds the listener and forgets to call &lt;code&gt;response.writeContinue()&lt;/code&gt; inside it keeps its customer waiting a full second on every large POST:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node server&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;100&lt;/code&gt; arrived&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default (no listener)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;0.011 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'checkContinue'&lt;/code&gt; listened, no answer&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;1.012 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a bug that can easily go unnoticed for months. The requests succeed, the log says &lt;code&gt;204&lt;/code&gt;, no alarm fires; only every upload is one second longer. Black boxes spend their worst nights without telling anyone.&lt;/p&gt;

&lt;p&gt;nginx, for its part, answers the question itself and never passes it on. In the local lab I put nginx in front as a reverse proxy with my raw listener behind it. nginx sent the client exactly one &lt;code&gt;100 Continue&lt;/code&gt;, the request finished in 0.019 s — and the headers the upstream server saw contained &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;Expect&lt;/code&gt; line at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST / HTTP/1.1
Host: host.docker.internal:8777
Content-Length: 1048577
User-Agent: curl/8.7.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source confirms this in two separate places. The &lt;code&gt;ngx_http_test_expect()&lt;/code&gt; function in &lt;code&gt;src/http/ngx_http_request_body.c&lt;/code&gt; writes the literal &lt;code&gt;"HTTP/1.1 100 Continue" CRLF CRLF&lt;/code&gt; straight into the socket. And the table that builds the upstream request in the proxy module carries this row: &lt;code&gt;{ ngx_string("Expect"), ngx_string("") }&lt;/code&gt; — an empty value, which in nginx's idiom means "do not pass this header". So the application behind nginx takes no part in this negotiation and never even learns its outcome.&lt;/p&gt;

&lt;p&gt;You can make it pass through if you want. The line &lt;code&gt;proxy_set_header Expect $http_expect;&lt;/code&gt; overrides the empty value in that table: with it in place the upstream server saw the &lt;code&gt;Expect: 100-continue&lt;/code&gt; line and the request finished in 0.024 s. But nginx still gave the answer — the application staying quiet cost nobody a second. Passing the header through does not put the application in charge of the negotiation, it only keeps it informed.&lt;/p&gt;

&lt;p&gt;There is a conformance wrinkle as well. The RFC says a server that sees an expectation it does not recognise MAY respond with &lt;code&gt;417 Expectation Failed&lt;/code&gt;. I asked with a made-up value, &lt;code&gt;Expect: 200-ok&lt;/code&gt;: Node returned &lt;code&gt;417&lt;/code&gt; (0.005 s), while nginx said nothing at all — it processed the request normally, returned &lt;code&gt;204&lt;/code&gt;, and again did not pass the header upstream. The reason is visible in the code: there is no path inside &lt;code&gt;ngx_http_test_expect()&lt;/code&gt; that produces a &lt;code&gt;417&lt;/code&gt; at all — if the value is not &lt;code&gt;100-continue&lt;/code&gt;, the function simply returns &lt;code&gt;NGX_OK&lt;/code&gt;, and the expectation is silently ignored. Behind nginx a &lt;code&gt;417&lt;/code&gt; can never be born; nginx does not produce one, and your application is not allowed to either.&lt;/p&gt;

&lt;h2&gt;
  
  
  A megabyte of profit
&lt;/h2&gt;

&lt;p&gt;Looking at the tables so far, a reader could fairly ask: if the price is one second, why does this mechanism exist at all? To put a number on the answer I defined a small location in nginx — &lt;code&gt;client_max_body_size 1k&lt;/code&gt; — and sent it the same 1 MiB + 1 B body.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Bytes uploaded&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;With &lt;code&gt;Expect: 100-continue&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;413&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.002 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppressed with &lt;code&gt;-H 'Expect:'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;413&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,006,159 – 1,048,577&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.003 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In all four runs the request that asked permission sent zero bytes; the one that did not ask sent 1,006,159, then 1,023,167, then 1,048,577, then 1,048,577 bytes. The lower bound moves from run to run, because there is a race between nginx writing its &lt;code&gt;413&lt;/code&gt; and closing the connection and curl stopping its write; what does not move is the order of magnitude. The same rejected request: one sent not a single byte, the other put a megabyte on the wire and had the door slammed in its face halfway down the road. This mechanism is not about speed; it is about never sending bytes that are going to be refused.&lt;/p&gt;

&lt;p&gt;The same profit can be measured at the application layer. I wrote one handler for Go's server and ran it two ways: one reads the body, the other returns &lt;code&gt;401&lt;/code&gt; without touching it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Go handler&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Bytes uploaded&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;io.Copy(io.Discard, r.Body)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;204&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,048,577&lt;/td&gt;
&lt;td&gt;0.002 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WriteHeader(401)&lt;/code&gt; without reading the body&lt;/td&gt;
&lt;td&gt;&lt;code&gt;401&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.001 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row's secret is one sentence of documentation: the Go server sends the &lt;code&gt;100 (Continue)&lt;/code&gt; header &lt;strong&gt;on the first read from the request body&lt;/strong&gt;. In Go, granting permission arrives as a side effect of starting to read the body. If you want to refuse, what you have to do is nothing at all — write the final status without touching &lt;code&gt;r.Body&lt;/code&gt;. Early rejection is just a &lt;code&gt;WriteHeader&lt;/code&gt; written three lines earlier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiY2xpZW50OiAxIE1pQiBib2R5IHJlYWR5Il0gLS0-IEJ7ImJvZHkgPiAxIE1pQjxici8-b3IgbGVuZ3RoIHVua25vd24ifQogIEIgLS0gIm5vIiAtLT4gQ1siYm9keSBnb2VzIG91dCBpbW1lZGlhdGVseSJdCiAgQiAtLSAieWVzIiAtLT4gRFsic2VuZCBFeHBlY3Q6IDEwMC1jb250aW51ZTxici8-d2l0aCBoZWFkZXJzLCB0aGVuIHdhaXQiXQogIEQgLS0-IEV7IndoYXQgZGlkIHRoZSBzZXJ2ZXIgc2F5In0KICBFIC0tICIxMDAgQ29udGludWUiIC0tPiBDCiAgRSAtLSAiNDEzIC8gNDAxIC8gNDAzPGJyLz5iZWZvcmUgdGhlIGJvZHkiIC0tPiBGWyJib2R5IGlzIE5FVkVSIHNlbnQiXQogIEUgLS0gInNpbGVuY2UiIC0tPiBHWyJhZnRlciAxIHMsIHNlbmQgdGhlPGJyLz5ib2R5IGFueXdheSJd%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiY2xpZW50OiAxIE1pQiBib2R5IHJlYWR5Il0gLS0-IEJ7ImJvZHkgPiAxIE1pQjxici8-b3IgbGVuZ3RoIHVua25vd24ifQogIEIgLS0gIm5vIiAtLT4gQ1siYm9keSBnb2VzIG91dCBpbW1lZGlhdGVseSJdCiAgQiAtLSAieWVzIiAtLT4gRFsic2VuZCBFeHBlY3Q6IDEwMC1jb250aW51ZTxici8-d2l0aCBoZWFkZXJzLCB0aGVuIHdhaXQiXQogIEQgLS0-IEV7IndoYXQgZGlkIHRoZSBzZXJ2ZXIgc2F5In0KICBFIC0tICIxMDAgQ29udGludWUiIC0tPiBDCiAgRSAtLSAiNDEzIC8gNDAxIC8gNDAzPGJyLz5iZWZvcmUgdGhlIGJvZHkiIC0tPiBGWyJib2R5IGlzIE5FVkVSIHNlbnQiXQogIEUgLS0gInNpbGVuY2UiIC0tPiBHWyJhZnRlciAxIHMsIHNlbmQgdGhlPGJyLz5ib2R5IGFueXdheSJd%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="552" height="1036"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scaling it up is easy: at this blog's &lt;code&gt;16M&lt;/code&gt; limit, on a 1 Mbps uplink, pushing a doomed file all the way up takes roughly two minutes. I did not measure that; I derived it from the limit and the link speed — but even derived, it says enough about the bandwidth running into a drain on the server side. It resembles the lesson from the &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/damlatan-sunucu-yazdigim-zaman-asimlarinin-cogunu-tetiklemedi/" rel="noopener noreferrer"&gt;timeout semantics&lt;/a&gt; piece published here yesterday: where you think the client is keeping time, something else may be getting measured. There, a "timeout" turned out to be an hourglass set between two I/O events. Here, "one second" is not a calculation but a courtesy period.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over HTTP/2 the question hangs in the air
&lt;/h2&gt;

&lt;p&gt;Everything so far ran over HTTP/1.1. Two independent code bases say that is no accident.&lt;/p&gt;

&lt;p&gt;nginx's &lt;code&gt;ngx_http_test_expect()&lt;/code&gt; bails out quietly, before it even looks at the value, in these cases: the request version is below 1.1, &lt;code&gt;r-&amp;gt;stream != NULL&lt;/code&gt; (that is, it is an HTTP/2 stream), or &lt;code&gt;r-&amp;gt;connection-&amp;gt;quic != NULL&lt;/code&gt; (HTTP/3). curl takes the same side: &lt;code&gt;addexpect()&lt;/code&gt; only adds the header on its own while &lt;code&gt;httpversion == 11&lt;/code&gt;. So both ends treat this feature as belonging to HTTP/1.1 — even though &lt;a href="https://www.rfc-editor.org/rfc/rfc9113.html" rel="noopener noreferrer"&gt;RFC 9113&lt;/a&gt;, which defines HTTP/2, shows in §8.8.5 exactly the case of answering a request that carries &lt;code&gt;Expect: 100-continue&lt;/code&gt; with &lt;code&gt;:status = 100&lt;/code&gt;. The specification permits it; the implementations do not use it.&lt;/p&gt;

&lt;p&gt;I also took this measurement against my own site. I sent a POST with a 1 MiB + 1 B body to a path that does not exist (my site returns &lt;code&gt;403&lt;/code&gt; from the edge for requests like this; the &lt;code&gt;Server: cloudflare&lt;/code&gt; and &lt;code&gt;CF-RAY&lt;/code&gt; response headers name who answered, so the three rows below describe that edge rather than my own nginx):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;Expect&lt;/code&gt; sent&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;100&lt;/code&gt; arrived&lt;/th&gt;
&lt;th&gt;Uploaded&lt;/th&gt;
&lt;th&gt;Time (median of 4 runs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default (HTTP/2)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,048,577 B&lt;/td&gt;
&lt;td&gt;1.03 s (0.72–1.77)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--http1.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,048,577 B&lt;/td&gt;
&lt;td&gt;1.01 s (0.71–1.14)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP/2 + header by hand&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,048,577 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.79 s (1.69–1.95)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These timings crossed the public internet, so they are noisy; the ranges in brackets show it. The solid result is not a number but the "no" in the third row: with the header set by hand over HTTP/2, no answer came, curl printed &lt;code&gt;Done waiting for 100-continue&lt;/code&gt;, spent its full second, then sent the body regardless, and the request still ended in &lt;code&gt;403&lt;/code&gt;. Gain zero, cost one second. The second row got its &lt;code&gt;100 Continue&lt;/code&gt; but made no profit either: after the edge said "go ahead" it still returned &lt;code&gt;403&lt;/code&gt;, so the entire megabyte was wasted. For this mechanism to pay off, the party granting permission has to know about the refusal &lt;em&gt;in advance&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The old-version side is closed off by the same logic. I put the &lt;code&gt;Expect&lt;/code&gt; header by hand on an HTTP/1.0 request to the local nginx: no answer, a full 1.026 s. The same request over HTTP/1.1: 0.019 s. RFC 9110 §10.1.1 actually commands this — a server MUST ignore that expectation on an HTTP/1.0 request. nginx complies to the letter, and the waiting side pays for that compliance with one second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my own stack stands
&lt;/h2&gt;

&lt;p&gt;I looked at my own blog's live configuration and found something other than what I went looking for. The expected part first: &lt;code&gt;client_max_body_size&lt;/code&gt; for this domain is &lt;code&gt;16M&lt;/code&gt;, so an upload over 16 MiB will be refused — over HTTP/1.1, if permission was asked, without a single byte of body. There is not one line in my application code mentioning &lt;code&gt;checkContinue&lt;/code&gt; or &lt;code&gt;writeContinue&lt;/code&gt;; it would not help if there were, since the nginx in front never passes that header upstream. This negotiation belongs to those two layers, not to my application.&lt;/p&gt;

&lt;p&gt;What I did not expect was the version line: &lt;code&gt;nginx/1.30.0&lt;/code&gt;, the release from 14 April 2026. The same stable branch reached 1.30.6 on 14 October, and the changelog in between carries five separate &lt;code&gt;*) Security:&lt;/code&gt; entries — two buffer overflows in a worker process, a use-after-free with asynchronous file I/O, an uninitialised key in TLS session ticket rotation, and one where part of an unknown HTTP/3 frame can be taken for the request body and slip past the &lt;code&gt;client_max_body_size&lt;/code&gt; limit. That last one does not touch me: the blog vhost has no &lt;code&gt;quic&lt;/code&gt; listener, and all traffic is HTTP/1.1 and HTTP/2 over TLS. The others are open work.&lt;/p&gt;

&lt;p&gt;I was going to close this section with "nothing to change". I am not closing it that way. There is a seven-month patch debt on that server, and a one-line version check run for this article is what surfaced it — I am not doing the upgrade here, because patching a live system should not be the side errand of an article run; it goes in its own maintenance window. The second risk is smaller and entirely in my own hands: if I ever add an upload endpoint, I might leave a &lt;code&gt;'checkContinue'&lt;/code&gt; listener half-finished. The 1.012 s row above is the invoice for that mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision frame
&lt;/h2&gt;

&lt;p&gt;The questions worth asking about this mechanism in your own setup, in order of weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who refuses the upload?&lt;/strong&gt; If the party that knows about the refusal early (a size limit, authentication, a quota) is also the party that can grant permission, the saving is real. For an application that only learns of the refusal after reading the body, &lt;code&gt;100-continue&lt;/code&gt; is just one round trip of latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which version is on the wire?&lt;/strong&gt; Over HTTP/2 or HTTP/3 this header will most likely go unanswered: gain zero, cost one second. Deliberately keeping an upload endpoint on HTTP/1.1 is a defensible choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who sits in between?&lt;/strong&gt; With a proxy like nginx in the path, the proxy answers the question and does not pass it on. If you think your application returns &lt;code&gt;417&lt;/code&gt;, look at the proxy's table first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does your client even ask?&lt;/strong&gt; curl asks above 1 MiB or when the length is unknown; Go does not. Rather than assuming what your own client does, time it once against a silent server — the difference is a whole second, so it is visible to the naked eye.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the uploader a browser?&lt;/strong&gt; You cannot send this header from one. In my own browser, building &lt;code&gt;new Request('/x', {method:'POST', headers:{'Expect':'100-continue'}, body:'...'})&lt;/code&gt; and listing its headers showed no &lt;code&gt;Expect&lt;/code&gt; at all; putting the same value into a plain &lt;code&gt;Headers&lt;/code&gt; object keeps it, so the stripping happens when the request is constructed. Uploads through &lt;code&gt;fetch()&lt;/code&gt; and XHR never engage this mechanism; the saving exists only on paths where you write the client yourself — curl, SDKs, server-to-server calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the thing you measured yours?&lt;/strong&gt; With a cloud edge, a load balancer or a WAF in front, the &lt;code&gt;100 Continue&lt;/code&gt; probably comes from them. To learn what your own server says, you have to look at it directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The closing number is this: an unanswered courtesy question costs one second, an answered one saves a megabyte. Finishing the piece, I got curious about the mechanism's age, looked up its history, and found the opposite of what I expected. The first text of HTTP/1.1, &lt;a href="https://www.rfc-editor.org/rfc/rfc2068.html" rel="noopener noreferrer"&gt;RFC 2068&lt;/a&gt; from January 1997, has neither the &lt;code&gt;Expect&lt;/code&gt; header nor the &lt;code&gt;100-continue&lt;/code&gt; token — but the wait itself is there, and it is unconditional. §8.2 tells a server receiving such a request to "either respond with 100 (Continue) status and continue to read from the input stream, or respond with an error status" (MUST), and tells the client to send the header fields first and then wait for "either a 100 (Continue) response ... or with an error status" (MUST). RFC 2616 §14.20, in 1999, removed that obligation and put a header the client has to ask for in its place.&lt;/p&gt;

&lt;p&gt;So the mechanism is twenty-nine years old, and its one big change was a retreat rather than an expansion: a rule everyone had to obey became a request anyone may make. In the twenty-seven years since, HTTP/2 left even that request unanswered. The most expensive thing in a protocol is not a wrong rule; it is a right question nobody answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-10.1.1" rel="noopener noreferrer"&gt;RFC 9110 §10.1.1 — the Expect header field&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9113.html" rel="noopener noreferrer"&gt;RFC 9113 §8.8.5 — informational responses in HTTP/2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc2068.html" rel="noopener noreferrer"&gt;RFC 2068 §8.2 — the unconditional 1997 form of the wait&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/nginx/nginx/blob/master/src/http/ngx_http_request_body.c" rel="noopener noreferrer"&gt;nginx source — ngx_http_test_expect() (who writes the 100 Continue)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_proxy_module.c" rel="noopener noreferrer"&gt;nginx source — the proxy module's table of headers not passed upstream&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/api/http.html#event-checkcontinue" rel="noopener noreferrer"&gt;Node.js — the http.Server 'checkContinue' event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pkg.go.dev/net/http#Transport" rel="noopener noreferrer"&gt;Go — net/http Transport.ExpectContinueTimeout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/curl/curl/blob/master/lib/http.h" rel="noopener noreferrer"&gt;curl source — the EXPECT_100_THRESHOLD definition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/curl/curl/blob/master/docs/cmdline-opts/expect100-timeout.md" rel="noopener noreferrer"&gt;curl — the --expect100-timeout option&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>network</category>
      <category>olcum</category>
      <category>software</category>
      <category>performance</category>
    </item>
    <item>
      <title>Output Waits in the Pipe: Why stdbuf Often Isn't Enough</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 14 Nov 2026 06:31:36 +0000</pubDate>
      <link>https://dev.to/merbayerp/output-waits-in-the-pipe-why-stdbuf-often-isnt-enough-5ef1</link>
      <guid>https://dev.to/merbayerp/output-waits-in-the-pipe-why-stdbuf-often-isnt-enough-5ef1</guid>
      <description>&lt;p&gt;You run a script in your terminal and the lines scroll past. You then add &lt;code&gt;| grep error&lt;/code&gt;&lt;br&gt;
to filter it, and the screen stays empty. The program did not hang and did not get stuck;&lt;br&gt;
it is running, and it may well have finished. It just isn't talking.&lt;/p&gt;

&lt;p&gt;The thesis of this article: &lt;strong&gt;your program does not decide when its output becomes visible.&lt;/strong&gt;&lt;br&gt;
The destination decides — a terminal, a pipe, or a plain file. And &lt;code&gt;stdbuf&lt;/code&gt;, the tool&lt;br&gt;
everyone reaches for to undo that decision, worked in only two of the seven runtimes&lt;br&gt;
measured below. The rest keep their own buffer and never ask libc about it.&lt;/p&gt;

&lt;p&gt;This blog has no shortage of "I turned the knob and the system ignored me" pieces. The&lt;br&gt;
difference here: there is no knob to turn. Nothing is configured, no default is changed —&lt;br&gt;
the same binary, without a line altered, behaves like two different programs depending on&lt;br&gt;
what it is attached to.&lt;/p&gt;

&lt;p&gt;Everything below was measured on my own machine (MacBook Pro M4 Pro, macOS 26 / Darwin&lt;br&gt;
25.6.0, arm64) and, for comparison, inside Docker on Debian 13 / glibc 2.41 / aarch64.&lt;br&gt;
I ran every line three times; the table values are the median of three runs — and on this&lt;br&gt;
topic I got lucky, because all three runs came out identical.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I set up the measurement
&lt;/h2&gt;

&lt;p&gt;To measure the claim "output disappears," you have to make the loss visible. The method:&lt;br&gt;
a program writes a fixed number of lines with &lt;code&gt;printf&lt;/code&gt;, &lt;strong&gt;never calls &lt;code&gt;fflush&lt;/code&gt;&lt;/strong&gt;, then waits&lt;br&gt;
forever. While it waits, I kill it with &lt;code&gt;SIGKILL&lt;/code&gt;. &lt;code&gt;SIGKILL&lt;/code&gt; cannot be caught, so whatever&lt;br&gt;
sits in the buffer dies there. The number of bytes that &lt;strong&gt;reach&lt;/strong&gt; the other end of the pipe&lt;br&gt;
is the part that was genuinely flushed. Every byte that does not arrive is a log line you&lt;br&gt;
would have lost in real life.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* prod2.c — every line is exactly 10 bytes, no flush */&lt;/span&gt;
&lt;span class="cp"&gt;#include &amp;lt;stdio.h&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;#include &amp;lt;stdlib.h&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;#include &amp;lt;unistd.h&amp;gt;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%09ld\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="cm"&gt;/* whatever is in the buffer stays there */&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinning the line length at 10 bytes made things easier: 1,638 lines is exactly 16,380 bytes,&lt;br&gt;
four bytes under the 16 KiB boundary. Those four bytes will show up later in a place I did&lt;br&gt;
not expect.&lt;/p&gt;

&lt;p&gt;The reading side is a small hand-written Python script: it reads the pipe without blocking,&lt;br&gt;
records when the first byte arrives, counts the total, and kills the child when time is up.&lt;br&gt;
One subtlety: the measuring tool itself must not be buffered, or you end up&lt;br&gt;
measuring your own noise. I read from the raw file descriptor with &lt;code&gt;os.read&lt;/code&gt;, not through&lt;br&gt;
&lt;code&gt;sys.stdin&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The destination makes the call
&lt;/h2&gt;

&lt;p&gt;I attached the same compiled binary to two different destinations; the plain-file case gets&lt;br&gt;
its own table shortly. The program did not change; not one line of code differs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;16,380 bytes written → arrived&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real terminal (pty)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;18,018 bytes&lt;/strong&gt; (all of it)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipe (`\&lt;/td&gt;
&lt;td&gt;cat`)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 18,018 looks odd at first because it exceeds what was written. The reason is innocent:&lt;br&gt;
a pty turns every &lt;code&gt;\n&lt;/code&gt; into &lt;code&gt;\r\n&lt;/code&gt;, which is 1,638 extra bytes for 1,638 lines.&lt;br&gt;
16,380 + 1,638 = 18,018. So attached to a terminal, &lt;strong&gt;everything&lt;/strong&gt; got through. Attached&lt;br&gt;
to a pipe, &lt;strong&gt;nothing&lt;/strong&gt; did.&lt;/p&gt;

&lt;p&gt;The difference comes from two separate decisions libc makes: the buffer's &lt;em&gt;mode&lt;/em&gt; and its &lt;em&gt;size&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The mode decision is written down in POSIX. The&lt;br&gt;
&lt;a href="https://pubs.opengroup.org/onlinepubs/9799919799/functions/stdin.html" rel="noopener noreferrer"&gt;stdin/stdout/stderr definition&lt;/a&gt;&lt;br&gt;
says &lt;code&gt;stdin&lt;/code&gt; and &lt;code&gt;stdout&lt;/code&gt; shall be fully buffered "if and only if the file descriptor&lt;br&gt;
associated with the stream is determined not to be associated with an interactive device."&lt;br&gt;
So a terminal flushes line by line and a pipe waits until it is full. The standard leaves&lt;br&gt;
the definition of "interactive device" to the implementation.&lt;/p&gt;

&lt;p&gt;The size decision is not in the documentation at all. FreeBSD's&lt;br&gt;
&lt;a href="https://man.freebsd.org/cgi/man.cgi?query=setvbuf&amp;amp;sektion=3" rel="noopener noreferrer"&gt;setvbuf(3) page&lt;/a&gt; only says&lt;br&gt;
an "optimally-sized buffer is obtained" and gives no number. To learn the number you have&lt;br&gt;
to read Apple's own libc source,&lt;br&gt;
&lt;a href="https://github.com/apple-oss-distributions/Libc/blob/main/stdio/FreeBSD/makebuf.c" rel="noopener noreferrer"&gt;makebuf.c&lt;/a&gt;.&lt;br&gt;
The line that sets the size is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;fp&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_blksize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;bufsize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_blksize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MAXBUFSIZE&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="no"&gt;MAXBUFSIZE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_blksize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buffer size is the destination's &lt;code&gt;st_blksize&lt;/code&gt;, read via &lt;code&gt;fstat&lt;/code&gt;. If &lt;code&gt;fstat&lt;/code&gt; fails or&lt;br&gt;
&lt;code&gt;st_blksize&lt;/code&gt; is zero, it falls back to &lt;code&gt;BUFSIZ&lt;/code&gt;. Line buffering is enabled only for&lt;br&gt;
character devices where &lt;code&gt;isatty&lt;/code&gt; returns true — in the source the &lt;code&gt;couldbetty&lt;/code&gt; check is&lt;br&gt;
&lt;code&gt;(st.st_mode &amp;amp; S_IFMT) == S_IFCHR&lt;/code&gt;, so a pipe (a FIFO) can never pass through that gate.&lt;/p&gt;

&lt;p&gt;This is easy to confirm. Here is the &lt;code&gt;st_blksize&lt;/code&gt; of each destination:&lt;/p&gt;

&lt;p&gt;A caveat on &lt;code&gt;st_blksize&lt;/code&gt;: when I asked statx about O_DIRECT alignment, the result was that&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/statx-512-dedi-cekirdek-4096da-dogrudan-yazdi/" rel="noopener noreferrer"&gt;the story this same field tells does not hold across five filesystems&lt;/a&gt;.&lt;br&gt;
Here I am not using it as a promise about alignment, but as the number libc reads when&lt;br&gt;
choosing a buffer size — and in that role it matches exactly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;&lt;code&gt;st_blksize&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Measured flush quantum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain file (APFS)&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipe / FIFO&lt;/td&gt;
&lt;td&gt;16,384&lt;/td&gt;
&lt;td&gt;16,384&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/dev/null&lt;/code&gt; (character device)&lt;/td&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And measuring the quantum right at the boundary lines up exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### written to a PIPE, then SIGKILL
  written= 16380 bytes -&amp;gt; arrived=     0
  written= 16390 bytes -&amp;gt; arrived= 16384
  written= 32760 bytes -&amp;gt; arrived= 16384
  written= 32770 bytes -&amp;gt; arrived= 32768
  written= 50000 bytes -&amp;gt; arrived= 49152

### written to a PLAIN FILE, then SIGKILL
  written=  4090 bytes -&amp;gt; in file=     0
  written=  4100 bytes -&amp;gt; in file=  4096
  written=  8190 bytes -&amp;gt; in file=  4096
  written=  8200 bytes -&amp;gt; in file=  8192
  written= 16380 bytes -&amp;gt; in file= 12288
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are those four bytes. Writing 16,380 sent zero bytes into the pipe; writing 16,390&lt;br&gt;
sent 16,384. The difference is ten bytes, one single line. That is how fragile the&lt;br&gt;
relationship is between how much a program says and how much the reader hears.&lt;/p&gt;

&lt;p&gt;One note: &lt;code&gt;MAXBUFSIZE&lt;/code&gt; in the source is defined at two different values — 4 KiB if&lt;br&gt;
&lt;code&gt;FEATURE_SMALL_STDIOBUF&lt;/code&gt; is compiled in, 16 MiB otherwise. Since the buffer on a pipe came out at 16,384&lt;br&gt;
bytes, the small variant is not enabled in the build Apple ships. I did not read&lt;br&gt;
that from the source; I inferred it from the measurement, and I should say that it is an&lt;br&gt;
inference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtCeXRlcyB3cml0dGVuIHZpYSBwcmludGZdIC0tPiBCW2xpYmMgc3RkaW8gYnVmZmVyXQogIEIgLS0-IEN7SXMgZmQgYSBjaGFyYWN0ZXIgZGV2aWNlPGJyLz5BTkQgaXNhdHR5IHRydWU_fQogIEMgLS0-fFllc3wgRFtMaW5lIGJ1ZmZlcmVkPGJyLz5mbHVzaCBvbiBldmVyeSBuZXdsaW5lXQogIEMgLS0-fE5vfCBFW0Z1bGx5IGJ1ZmZlcmVkXQogIEUgLS0-IEZ7SXMgdGhlIGJ1ZmZlciBmdWxsPzxici8-c2l6ZSA9IHN0X2Jsa3NpemV9CiAgRiAtLT58Tm98IEdbV2FpdDxici8-TE9TVCBpZiBTSUdLSUxMIGFycml2ZXNdCiAgRiAtLT58WWVzfCBIW0ZsdXNoIHZpYSB3cml0ZV0KICBEIC0tPiBJW1JlYWRlciBzZWVzIGl0XQogIEggLS0-IEk%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtCeXRlcyB3cml0dGVuIHZpYSBwcmludGZdIC0tPiBCW2xpYmMgc3RkaW8gYnVmZmVyXQogIEIgLS0-IEN7SXMgZmQgYSBjaGFyYWN0ZXIgZGV2aWNlPGJyLz5BTkQgaXNhdHR5IHRydWU_fQogIEMgLS0-fFllc3wgRFtMaW5lIGJ1ZmZlcmVkPGJyLz5mbHVzaCBvbiBldmVyeSBuZXdsaW5lXQogIEMgLS0-fE5vfCBFW0Z1bGx5IGJ1ZmZlcmVkXQogIEUgLS0-IEZ7SXMgdGhlIGJ1ZmZlciBmdWxsPzxici8-c2l6ZSA9IHN0X2Jsa3NpemV9CiAgRiAtLT58Tm98IEdbV2FpdDxici8-TE9TVCBpZiBTSUdLSUxMIGFycml2ZXNdCiAgRiAtLT58WWVzfCBIW0ZsdXNoIHZpYSB3cml0ZV0KICBEIC0tPiBJW1JlYWRlciBzZWVzIGl0XQogIEggLS0-IEk%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="501" height="1116"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  stdbuf fixed two of seven runtimes
&lt;/h2&gt;

&lt;p&gt;So far this was C. But nobody writes their log-producing job in C. I ran the same test —&lt;br&gt;
16,380 bytes, to a pipe, no flush, then &lt;code&gt;SIGKILL&lt;/code&gt; — across seven different runtimes, and&lt;br&gt;
repeated each one with &lt;code&gt;stdbuf -o L&lt;/code&gt; added. You will see nine rows in the table, because&lt;br&gt;
Python was measured in two versions and Go through two different write paths.&lt;/p&gt;

&lt;p&gt;macOS has &lt;code&gt;/usr/bin/stdbuf&lt;/code&gt;, which surprised me; it is not GNU coreutils' &lt;code&gt;stdbuf&lt;/code&gt; but&lt;br&gt;
FreeBSD's version. The man page on my machine says so outright: the utility "first&lt;br&gt;
appeared in FreeBSD 8.4", the idea came from Padraig Brady in GNU coreutils, and Jeremie&lt;br&gt;
Le Hen did the FreeBSD implementation. GNU's own documentation says the tool is installed&lt;br&gt;
only "on platforms that use the Executable and Linkable Format (ELF) and support the&lt;br&gt;
&lt;code&gt;constructor&lt;/code&gt; attribute" — macOS, being Mach-O, does not fit that description, so Apple&lt;br&gt;
took the BSD equivalent in its &lt;code&gt;shell_cmds&lt;/code&gt; package.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;With &lt;code&gt;stdbuf -o L&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C (stdio)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,380&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;awk (BSD, macOS)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,380&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3 3.9.6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3 3.13.5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;perl 5&lt;/td&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ruby&lt;/td&gt;
&lt;td&gt;8,200&lt;/td&gt;
&lt;td&gt;8,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;go (&lt;code&gt;bufio.NewWriter&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12,288&lt;/td&gt;
&lt;td&gt;12,288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;go (&lt;code&gt;os.Stdout&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node 22.16&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There are a few things in that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First:&lt;/strong&gt; &lt;code&gt;stdbuf&lt;/code&gt; only worked for C and awk — it had no effect in four of the six buffered&lt;br&gt;
runtimes I added &lt;code&gt;stdbuf -o L&lt;/code&gt; to.&lt;/p&gt;

&lt;p&gt;To understand why, I looked at what the tool actually does, and did not find what I&lt;br&gt;
expected. &lt;a href="https://man.freebsd.org/cgi/man.cgi?query=stdbuf&amp;amp;sektion=1" rel="noopener noreferrer"&gt;FreeBSD's man page&lt;/a&gt;&lt;br&gt;
says it relies on a preload library called &lt;code&gt;libstdbuf(3)&lt;/code&gt;; on macOS there is no such&lt;br&gt;
library. Comparing the environment of a process launched under &lt;code&gt;stdbuf -o L&lt;/code&gt; turned up&lt;br&gt;
exactly one difference: &lt;code&gt;_STDBUF_O=L&lt;/code&gt;. No &lt;code&gt;DYLD_INSERT_LIBRARIES&lt;/code&gt;, no&lt;br&gt;
&lt;code&gt;/usr/lib/libstdbuf.dylib&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The thing that reads that variable is inside the very file quoted above: &lt;code&gt;__senvbuf()&lt;/code&gt; in&lt;br&gt;
&lt;code&gt;makebuf.c&lt;/code&gt; calls &lt;code&gt;getenv("_STDBUF_O")&lt;/code&gt;, and &lt;code&gt;__smakebuf()&lt;/code&gt; invokes it immediately after&lt;br&gt;
&lt;code&gt;__swhatbuf()&lt;/code&gt;. So on macOS &lt;code&gt;stdbuf&lt;/code&gt; injects nothing; it just leaves an environment&lt;br&gt;
variable behind, and &lt;strong&gt;libc itself&lt;/strong&gt; makes the decision.&lt;/p&gt;

&lt;p&gt;That also explains the limit in one sentence: the variable only changes the &lt;strong&gt;initial&lt;/strong&gt;&lt;br&gt;
decision libc makes for the stream. Python, Perl, Ruby and Go's &lt;code&gt;bufio&lt;/code&gt; do not get their&lt;br&gt;
buffer from libc in the first place — they build their own layer, so the variable never&lt;br&gt;
reaches them. The GNU coreutils documentation states the same limit from its side: &lt;code&gt;dd&lt;/code&gt; is&lt;br&gt;
unaffected because it does not use &lt;code&gt;FILE&lt;/code&gt; streams, and &lt;code&gt;tee&lt;/code&gt; because it adjusts its own&lt;br&gt;
streams' buffering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second:&lt;/strong&gt; there are five different answers for the same 16,380 bytes — 0, 8,192, 8,200,&lt;br&gt;
12,288, 16,380. One machine, one pipe, one byte count. Perl flushes at 8 KiB and Go's&lt;br&gt;
&lt;code&gt;bufio&lt;/code&gt; at 4 KiB (the Go source has &lt;code&gt;defaultBufSize = 4096&lt;/code&gt; and &lt;code&gt;NewWriter&lt;/code&gt; passes it&lt;br&gt;
directly — 12,288 = 3 × 4,096). None of them uses the 16,384 that libc picked for the pipe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third:&lt;/strong&gt; Go's &lt;code&gt;os.Stdout&lt;/code&gt; and Node are unbuffered. Both delivered everything. So "the&lt;br&gt;
buffering problem" is not universal; it is a property of your language choice.&lt;/p&gt;

&lt;p&gt;Let me also be open about the counting, because "two of seven" looks the way it does partly&lt;br&gt;
because the denominator is one of my own picking. Counted as languages there are seven (C, awk, Python, Perl,&lt;br&gt;
Ruby, Go, Node); the table has nine rows because Python appears in two versions and Go&lt;br&gt;
through two write paths. I added &lt;code&gt;stdbuf&lt;/code&gt; to the six that buffer, and it worked on two. But&lt;br&gt;
honesty requires the other half: &lt;code&gt;stdbuf&lt;/code&gt; was only ever designed for programs using libc&lt;br&gt;
stdio, and within that set it did not fail — in the third section it goes two for two on&lt;br&gt;
&lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;cut&lt;/code&gt;. So "two of seven" measures not the quality of the tool but the fact that&lt;br&gt;
most of a 2026 log pipeline is no longer C. Read the title's "often isn't enough" that way:&lt;br&gt;
the tool is not broken, its scope is narrow.&lt;/p&gt;

&lt;p&gt;Python has a fix, and that fix is not &lt;code&gt;stdbuf&lt;/code&gt;. The&lt;br&gt;
&lt;code&gt;-u&lt;/code&gt; flag or the &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt; environment variable settles it: the same test under&lt;br&gt;
&lt;code&gt;python3 -u&lt;/code&gt; delivered all 16,380 bytes. The Python documentation says &lt;code&gt;-u&lt;/code&gt; will "force the&lt;br&gt;
stdout and stderr streams to be unbuffered" and notes that since 3.7 &lt;strong&gt;the text layer&lt;/strong&gt; is&lt;br&gt;
unbuffered as well. That word "layer" is not idle: in Python &lt;code&gt;sys.stdout&lt;/code&gt; is a&lt;br&gt;
&lt;code&gt;TextIOWrapper&lt;/code&gt; with a &lt;code&gt;BufferedWriter&lt;/code&gt; underneath it. Two buffers stacked. That is why&lt;br&gt;
Python's flush boundary is not a clean power of two, and why it also shifts with the&lt;br&gt;
destination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### macOS, python3, to a PIPE — threshold found by bisection
  written= 16390 bytes -&amp;gt; arrived=     0
  written= 24570 bytes -&amp;gt; arrived=     0
  written= 24580 bytes -&amp;gt; arrived= 16380     &amp;lt;-- first delivery
  written= 65530 bytes -&amp;gt; arrived= 49140
### macOS, python3, to a PLAIN FILE
  written=  8190 bytes -&amp;gt; in file=     0
  written=  8200 bytes -&amp;gt; in file=  8190
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than guess the threshold I bisected for it: at 24,570 bytes into a pipe the output is&lt;br&gt;
still zero, and the first delivery lands at 24,580. That number should look familiar —&lt;br&gt;
24,576 is exactly 8 KiB + 16 KiB. Python's 8,192-byte text layer and the 16,384 libc chose&lt;br&gt;
for this pipe appear to be stacked on top of each other. I say "appear" because I did not&lt;br&gt;
trace the interaction of the two layers through the CPython source; what I have is a&lt;br&gt;
measurement and an addition that works out. That is not enough to assert the mechanism, but&lt;br&gt;
it is enough to say where the threshold sits.&lt;/p&gt;
&lt;h2&gt;
  
  
  The real culprit is the middle of the pipeline
&lt;/h2&gt;

&lt;p&gt;The above is the "what if the source misbehaves" scenario. The far more annoying case in&lt;br&gt;
real life is this one: the source is perfect and the filter in the middle is to blame.&lt;/p&gt;

&lt;p&gt;To measure that I wrote a producer that calls &lt;code&gt;fflush&lt;/code&gt; on every line and emits 20 lines per&lt;br&gt;
second. There is no flaw on the source side. Then I put a single filter in the middle, waited&lt;br&gt;
two seconds, and counted how many lines reached the &lt;strong&gt;end&lt;/strong&gt; of the pipeline. A healthy value&lt;br&gt;
is 38.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;Lines reaching the end in 2 s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no filter&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;grep satir`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;sed 's/x/y/'`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;cut -c1-12`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;tr a-z A-Z`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;awk '{print}'`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;jq -c .`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;cat`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;tee /dev/null`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;grep --line-buffered satir`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;sed -l 's/x/y/'`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;awk '{print; fflush()}'`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;jq --unbuffered -c .`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;stdbuf -o L grep satir`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`\&lt;/td&gt;
&lt;td&gt;stdbuf -o L cut -c1-12`&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six filters passed not a single line in two seconds. &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;cut&lt;/code&gt;, &lt;code&gt;tr&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;&lt;br&gt;
— that is every tool you would reach for to filter a log. The producer was flushing each&lt;br&gt;
line immediately; the filter poured them into its own 16 KiB container and waited. I tested&lt;br&gt;
&lt;code&gt;jq&lt;/code&gt; separately with a JSON-producing source, since that is the standard way to filter&lt;br&gt;
structured logs; the result was the same.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;tee&lt;/code&gt; pass things through is no coincidence. The GNU documentation already&lt;br&gt;
told us &lt;code&gt;tee&lt;/code&gt; adjusts its own buffering, and here we see it in the measurement too. If you&lt;br&gt;
reach for &lt;code&gt;tee&lt;/code&gt; to both watch a log on screen and write it to a file, you have picked the&lt;br&gt;
right tool without knowing it.&lt;/p&gt;

&lt;p&gt;The fix side is cheap, fortunately, and the bottom half of the table shows it. Most tools&lt;br&gt;
have their own flag: &lt;code&gt;grep --line-buffered&lt;/code&gt;, &lt;code&gt;sed -l&lt;/code&gt; (BSD), &lt;code&gt;fflush()&lt;/code&gt; inside &lt;code&gt;awk&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;jq --unbuffered&lt;/code&gt;. All four went through the same test and all four passed the 38 lines. I checked the GNU&lt;br&gt;
side separately in a Linux container: there the flag is &lt;code&gt;sed -u&lt;/code&gt;, and it works the same way&lt;br&gt;
— BSD's &lt;code&gt;-l&lt;/code&gt; and GNU's &lt;code&gt;-u&lt;/code&gt; do the same job with different letters, and you have to notice&lt;br&gt;
that when writing a portable script.&lt;/p&gt;

&lt;p&gt;For tools without their own flag, like &lt;code&gt;cut&lt;/code&gt;, &lt;code&gt;stdbuf -o L&lt;/code&gt; steps in — because those use&lt;br&gt;
plain stdio, which is exactly &lt;code&gt;stdbuf&lt;/code&gt;'s area of effect. The last two rows of the table&lt;br&gt;
confirm it: &lt;code&gt;stdbuf&lt;/code&gt; works here, while a section ago it did nothing for Python. A tool&lt;br&gt;
working in one place and failing in another is not random; the boundary is whether the&lt;br&gt;
program left its buffering to libc.&lt;/p&gt;

&lt;p&gt;Let me also record a mistake of my own here, because the correction was instructive. In my&lt;br&gt;
first attempt the wait was done with &lt;code&gt;system("sleep 30")&lt;/code&gt;, and awk delivered all 16,380&lt;br&gt;
bytes as though it used no buffer at all. My first thought was "so awk is clean"; then I&lt;br&gt;
realised my waiting method was doing the flushing — the awk on my machine empties &lt;code&gt;stdout&lt;/code&gt;&lt;br&gt;
before running &lt;code&gt;system()&lt;/code&gt;. I had assumed this was a standard requirement, but POSIX's&lt;br&gt;
&lt;a href="https://pubs.opengroup.org/onlinepubs/9799919799/utilities/awk.html" rel="noopener noreferrer"&gt;awk page&lt;/a&gt; contains no&lt;br&gt;
such rule: the only flushing requirement on that page belongs to the &lt;code&gt;fflush&lt;/code&gt; function, and&lt;br&gt;
the &lt;code&gt;system()&lt;/code&gt; entry says only that the command is executed. So this is the behaviour of the&lt;br&gt;
awk on this machine, not a mandate of the standard. Once I changed the waiting method, awk went&lt;br&gt;
to zero bytes like everyone else.&lt;/p&gt;

&lt;p&gt;The general lesson: your measuring tool can change the thing it measures. Ask your own test&lt;br&gt;
rig the same question.&lt;/p&gt;
&lt;h2&gt;
  
  
  "It worked on Linux" is not evidence
&lt;/h2&gt;

&lt;p&gt;I copied the same source files into Debian 13 inside Docker and compiled them with &lt;code&gt;gcc&lt;/code&gt;.&lt;br&gt;
glibc 2.41, aarch64. Not one line of code changed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;macOS / Darwin 25.6&lt;/th&gt;
&lt;th&gt;Linux / glibc 2.41&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pipe &lt;code&gt;st_blksize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;16,384&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,096&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C stdio, 16,380 bytes → arrived&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12,288&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C + &lt;code&gt;stdbuf -oL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3 3.13.5 → arrived&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8,200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3 + &lt;code&gt;stdbuf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;8,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;python3 &lt;code&gt;-u&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;td&gt;16,380&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;awk / gawk&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;12,288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain-file quantum&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same program, same byte count, same pipe: zero on macOS, 12,288 on Linux. The cause is a&lt;br&gt;
single number — Linux reports a pipe's &lt;code&gt;st_blksize&lt;/code&gt; as 4,096 and macOS as 16,384. libc&lt;br&gt;
applies the same rule on both; the rule is identical and the outcome still differs fourfold.&lt;/p&gt;

&lt;p&gt;I think the most practical conclusion from that table is this: if your CI runs on Linux and&lt;br&gt;
you develop on a Mac, "I can see the logs locally" and "the logs show up in CI" are two&lt;br&gt;
independent facts. The reverse holds too. Personally I would put running this test on both&lt;br&gt;
platforms on the acceptance checklist.&lt;/p&gt;
&lt;h2&gt;
  
  
  Node's separate trap: no buffer, but still a loss
&lt;/h2&gt;

&lt;p&gt;Node came out clean on the buffering side of this article. But it has a hole of its own, and&lt;br&gt;
it is documented. The &lt;a href="https://nodejs.org/api/process.html" rel="noopener noreferrer"&gt;process.stdout note&lt;/a&gt; says writes&lt;br&gt;
are synchronous or asynchronous depending on the destination: files are synchronous on both&lt;br&gt;
platforms, terminals are synchronous on POSIX, and &lt;strong&gt;pipes and sockets are asynchronous on&lt;br&gt;
POSIX.&lt;/strong&gt; The warning follows immediately — synchronous writes avoid problems such as output&lt;br&gt;
"not written at all if &lt;code&gt;process.exit()&lt;/code&gt; is called before an asynchronous write completes."&lt;/p&gt;

&lt;p&gt;I tried this, and my first attempt failed. Writing 10 MB line by line in a loop and then&lt;br&gt;
calling &lt;code&gt;process.exit(0)&lt;/code&gt; lost nothing at all; all 10,485,760 bytes arrived. I could not&lt;br&gt;
reproduce what the documentation warned about.&lt;/p&gt;

&lt;p&gt;To produce the loss I had to change the shape of the write. Giving the same 10 MB in a&lt;br&gt;
&lt;strong&gt;single&lt;/strong&gt; &lt;code&gt;write&lt;/code&gt; call and exiting immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1,048,576 small writes in a loop + exit  -&amp;gt; 10,485,760 bytes  COMPLETE
  one 10,485,760-byte write + exit         -&amp;gt;     65,536 bytes  LOST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 65,536 bytes that arrived are exactly 64 KiB: as much as the kernel accepted into the&lt;br&gt;
pipe buffer in one go. The rest was waiting in Node's queue, and &lt;code&gt;process.exit()&lt;/code&gt; did not&lt;br&gt;
wait for it. That is a 99.4% loss.&lt;/p&gt;

&lt;p&gt;The loop version survives because each small write can drain as the reader consumes it. So&lt;br&gt;
to produce this bug the write has to be too large to fit into the pipe at once. I am writing&lt;br&gt;
this down because you are likely to try the same thing, and if you fail to reproduce it on&lt;br&gt;
the first attempt I do not want you concluding there is no problem. The documentation is&lt;br&gt;
right; its condition is just narrow.&lt;/p&gt;
&lt;h2&gt;
  
  
  The state of my own infrastructure
&lt;/h2&gt;

&lt;p&gt;While writing this I asked my own blog repository the same question, because you should look&lt;br&gt;
at your own house before handing out advice. The results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I have 17 GitHub Actions workflows. In &lt;strong&gt;eleven of them, across 31 lines&lt;/strong&gt;, a &lt;code&gt;grep&lt;/code&gt;,
&lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;cut&lt;/code&gt; or &lt;code&gt;tr&lt;/code&gt; is fed from a pipe. But that first count of 31 was misleading;
what matters is &lt;strong&gt;where&lt;/strong&gt; the filter sits. The number of lines with another pipe after the
filter — genuinely in the middle of the pipeline — is &lt;strong&gt;10&lt;/strong&gt;: &lt;code&gt;cache-purge.yml&lt;/code&gt; 1,
&lt;code&gt;content-generate.yml&lt;/code&gt; 6, &lt;code&gt;deploy-only.yml&lt;/code&gt; 1, &lt;code&gt;translate-archive.yml&lt;/code&gt; 2. In the other 21
the filter is the last link in the chain.&lt;/li&gt;
&lt;li&gt;Searching &lt;code&gt;.github/&lt;/code&gt;, &lt;code&gt;scripts/&lt;/code&gt; and &lt;code&gt;deploy/&lt;/code&gt; for &lt;code&gt;stdbuf&lt;/code&gt;, &lt;code&gt;--line-buffered&lt;/code&gt;, &lt;code&gt;sed -u&lt;/code&gt;/&lt;code&gt;-l&lt;/code&gt;
and &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt;: &lt;strong&gt;zero&lt;/strong&gt; hits. No precaution anywhere the pipelines are actually built.
(The repository as a whole has exactly one &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt;, inside an example systemd unit
quoted in an older article — prose, not code.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The situation is not alarming, and honesty requires writing down why. The 21 last-link lines&lt;br&gt;
fall outside this article's subject: there is no reader waiting behind the filter, the output&lt;br&gt;
goes into a variable or a file, the stream closes when the process ends, and closing flushes.&lt;br&gt;
Buffering only turns into data loss when &lt;strong&gt;the job is cut off partway through&lt;/strong&gt; — and being&lt;br&gt;
cut off, as you will see below, is harsher than I assumed.&lt;/p&gt;

&lt;p&gt;The one place that caught my attention was &lt;code&gt;translate-archive.yml&lt;/code&gt;. There a job with a&lt;br&gt;
60-minute timeout captures the output of a long translation loop with &lt;code&gt;2&amp;gt;&amp;amp;1 | tee translate.log&lt;/code&gt;.&lt;br&gt;
If it times out, GitHub cancels the job. According to the table above, that pipeline is&lt;br&gt;
safe: &lt;code&gt;tee&lt;/code&gt; is one of the two tools that pass data through, and Node is unbuffered anyway.&lt;br&gt;
But had I written &lt;code&gt;grep&lt;/code&gt; there instead of &lt;code&gt;tee&lt;/code&gt;, the log file would have been empty the&lt;br&gt;
moment a 60-minute job was cut off — which is exactly the moment I would need to know why&lt;br&gt;
it was cut off.&lt;/p&gt;

&lt;p&gt;This is also a reminder that log loss is not a new subject for me. I previously measured&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/copytruncate-cok-kucuk-bir-an-33879-satir-etti/" rel="noopener noreferrer"&gt;losing 33,879 lines in the gap that copytruncate dismisses as "a very small moment"&lt;/a&gt;.&lt;br&gt;
The mechanism in that article was completely different — there the loss came from logrotate&lt;br&gt;
truncating the file, here it comes from libc's buffer. What they share is this: in both&lt;br&gt;
cases, the component causing the loss reports no error at all. I have also measured the pipe&lt;br&gt;
itself from another angle:&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/komsu-konteyner-borularimi-sekiz-kat-kuculttu/" rel="noopener noreferrer"&gt;a neighbouring container filling the quota, so my new pipes came back at 8 KiB instead of 64 KiB&lt;/a&gt;,&lt;br&gt;
happened just as silently. The 65,536 bytes below are that same 64 KiB.&lt;/p&gt;
&lt;h3&gt;
  
  
  "What if I stop it gently?" — no
&lt;/h3&gt;

&lt;p&gt;Here I had to test an assumption of my own. When a job is cancelled, &lt;code&gt;SIGTERM&lt;/code&gt; arrives before&lt;br&gt;
&lt;code&gt;SIGKILL&lt;/code&gt;, and it seems reasonable to think the polite signal would shut things down properly&lt;br&gt;
and flush the buffer. The measurement says otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16,380 bytes written to a pipe, then a signal:
  SIGTERM -&amp;gt; arrived=0        SIGINT -&amp;gt; arrived=0
  SIGKILL -&amp;gt; arrived=0        SIGHUP -&amp;gt; arrived=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four are zero. The reason is simple but easy to miss: a signal's &lt;strong&gt;default&lt;/strong&gt; disposition&lt;br&gt;
is to terminate the process, not to call &lt;code&gt;exit()&lt;/code&gt;. Because &lt;code&gt;exit()&lt;/code&gt; is never called, &lt;code&gt;atexit&lt;/code&gt;&lt;br&gt;
handlers do not run and stdio buffers are not flushed. So what saves the buffer is not the&lt;br&gt;
politeness of the signal but the program exiting properly under its own control. A process&lt;br&gt;
that catches &lt;code&gt;SIGTERM&lt;/code&gt; and leaves via &lt;code&gt;exit()&lt;/code&gt; keeps its data; one that does not catch it&lt;br&gt;
never does.&lt;/p&gt;

&lt;p&gt;That leaves my &lt;code&gt;translate-archive.yml&lt;/code&gt; concern standing: when GitHub cancels the job, I&lt;br&gt;
cannot expect a polite signal to rescue me.&lt;/p&gt;

&lt;h3&gt;
  
  
  In a container, stdout is already a pipe
&lt;/h3&gt;

&lt;p&gt;If you are reading this thinking "but I don't use pipes," look again. Inside a container&lt;br&gt;
&lt;code&gt;stdout&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; a pipe to the logging driver — what you see with &lt;code&gt;docker logs&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;kubectl logs&lt;/code&gt; is the far end of it. So the runtime table above applies as-is to every&lt;br&gt;
containerised application: a service written in Python goes silent in a container unless it&lt;br&gt;
is given &lt;code&gt;-u&lt;/code&gt;, and that is the classic case where hours get burned on "the logs aren't&lt;br&gt;
coming through." My own blog is served from a container too.&lt;/p&gt;

&lt;p&gt;There is also a free way out: &lt;code&gt;stderr&lt;/code&gt;. POSIX says &lt;code&gt;stderr&lt;/code&gt; shall not be fully buffered when&lt;br&gt;
opened, and the measurement agrees — writing the same program's output to &lt;code&gt;stderr&lt;/code&gt; delivered&lt;br&gt;
all 16,380 bytes immediately. Sending diagnostic lines to &lt;code&gt;stderr&lt;/code&gt; is the cheapest fix&lt;br&gt;
available, and it needs no flag at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision framework
&lt;/h2&gt;

&lt;p&gt;If output is delayed in a pipeline, check these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the destination.&lt;/strong&gt; Checking &lt;code&gt;st_blksize&lt;/code&gt; with &lt;code&gt;fstat&lt;/code&gt; is not overkill: a pipe
is 16 KiB (macOS) or 4 KiB (Linux), a plain file 4 KiB. The delay you should expect
comes from there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the culprit in the middle of the pipeline.&lt;/strong&gt; Before changing the source, pull
the filters out one at a time and retest. In my measurement the source was flawless and
&lt;code&gt;grep&lt;/code&gt; was the blockage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the tool has its own flag, use it.&lt;/strong&gt; The ones verified here: &lt;code&gt;grep --line-buffered&lt;/code&gt;,
&lt;code&gt;sed -u&lt;/code&gt; (GNU) / &lt;code&gt;sed -l&lt;/code&gt; (BSD), &lt;code&gt;fflush()&lt;/code&gt; inside &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;jq --unbuffered&lt;/code&gt;, &lt;code&gt;-u&lt;/code&gt; or
&lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt; in Python, &lt;code&gt;$| = 1&lt;/code&gt; in Perl. In Go, calling &lt;code&gt;Flush()&lt;/code&gt; on a
&lt;code&gt;bufio.Writer&lt;/code&gt; is your responsibility anyway. These are more reliable than &lt;code&gt;stdbuf&lt;/code&gt;
because they touch the tool's own buffer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust &lt;code&gt;stdbuf&lt;/code&gt; only for tools that use stdio.&lt;/strong&gt; It works on classic filters like &lt;code&gt;cut&lt;/code&gt;,
&lt;code&gt;tr&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;. It does not work for Python, Perl, Ruby, Go or &lt;code&gt;dd&lt;/code&gt;, and it is
unnecessary for &lt;code&gt;tee&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you cannot change the source and it has no flag, fake a terminal for it.&lt;/strong&gt; This
article's first measurement is already the proof: the same binary gave 18,018 bytes on a
pty and zero on a pipe. &lt;code&gt;script -q /dev/null &amp;lt;command&amp;gt;&lt;/code&gt; or expect's &lt;code&gt;unbuffer&lt;/code&gt; attaches a
pseudo-terminal, libc sees &lt;code&gt;isatty&lt;/code&gt; as true, and the stream switches to line buffering.
For third-party binaries you cannot open, this is usually the only lever you have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember that turning the buffer off has a price.&lt;/strong&gt; I wrote 10,485,760 bytes to
&lt;code&gt;/dev/null&lt;/code&gt; and timed it (five runs, median): 0.05 s with the default full buffering,
0.41 s with &lt;code&gt;stdbuf -o L&lt;/code&gt;, 0.42 s with &lt;code&gt;stdbuf -o 0&lt;/code&gt;. Line buffering is roughly eight
times more expensive here. Do not carry that number into production as-is: it was measured
against a &lt;code&gt;/dev/null&lt;/code&gt; with no reader, and the figures change behind a real consumer. What
does transfer is the conclusion — line buffering is not free, so apply it where you watch
output live, not everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not use &lt;code&gt;process.exit()&lt;/code&gt; in Node.&lt;/strong&gt; Set &lt;code&gt;process.exitCode&lt;/code&gt; and let the process exit
naturally. The loss recorded here was 99.4%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not count on the signal being gentle.&lt;/strong&gt; &lt;code&gt;SIGTERM&lt;/code&gt;, &lt;code&gt;SIGINT&lt;/code&gt;, &lt;code&gt;SIGHUP&lt;/code&gt;, &lt;code&gt;SIGKILL&lt;/code&gt; —
all four killed the process without flushing. The only thing that saves the data is the
program catching the signal and exiting under its own control.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One last diagnostic habit: when you see a process not talking, question the pipeline before&lt;br&gt;
you question the process. I got the same &lt;code&gt;prod2&lt;/code&gt; binary to produce 18,018 bytes and zero&lt;br&gt;
bytes within the same minute, without changing it. The program was innocent; it had done the&lt;br&gt;
same thing both times. The only thing that differed was who its words were going to.&lt;/p&gt;

&lt;p&gt;Buffering is a well-meant invention: a few large writes are cheaper than many small ones.&lt;br&gt;
The price of that good intention is not that good news gets held back — it is that bad news&lt;br&gt;
does. The last twenty lines explaining why a job crashed may still be sitting in a buffer at&lt;br&gt;
the exact moment of the crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/apple-oss-distributions/Libc/blob/main/stdio/FreeBSD/makebuf.c" rel="noopener noreferrer"&gt;Apple Libc — stdio/FreeBSD/makebuf.c&lt;/a&gt; — the buffer size coming from &lt;code&gt;st_blksize&lt;/code&gt;, the &lt;code&gt;BUFSIZ&lt;/code&gt; fallback, the &lt;code&gt;isatty&lt;/code&gt; line-buffering decision and the &lt;code&gt;TTYBUFSIZE&lt;/code&gt; cap are all in this file.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.python.org/3/using/cmdline.html" rel="noopener noreferrer"&gt;Python — Command line and environment (&lt;code&gt;-u&lt;/code&gt;, &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt;)&lt;/a&gt; — documents that &lt;code&gt;-u&lt;/code&gt; makes &lt;code&gt;stdout&lt;/code&gt;/&lt;code&gt;stderr&lt;/code&gt; unbuffered and that since 3.7 this covers the text layer too.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/api/process.html" rel="noopener noreferrer"&gt;Node.js — &lt;code&gt;process&lt;/code&gt; and the process I/O note&lt;/a&gt; — the table of synchronous/asynchronous writes per destination, and the &lt;code&gt;process.exit()&lt;/code&gt; warning.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/bufio" rel="noopener noreferrer"&gt;Go — the &lt;code&gt;bufio&lt;/code&gt; package&lt;/a&gt; — documents that &lt;code&gt;NewWriter&lt;/code&gt; returns a buffer of "the default size"; the numeric value (&lt;code&gt;defaultBufSize = 4096&lt;/code&gt;) is in the package source.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>observability</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>In August I Cited 328 Publishers, in October 78</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:08:00 +0000</pubDate>
      <link>https://dev.to/merbayerp/in-august-i-cited-328-publishers-in-october-78-4pln</link>
      <guid>https://dev.to/merbayerp/in-august-i-cited-328-publishers-in-october-78-4pln</guid>
      <description>&lt;p&gt;The least-read part of any post is the source list at the bottom. For a long&lt;br&gt;
time I looked at mine like a badge of conscience: if the links are still&lt;br&gt;
standing, the work is sound. On 12 October those links went through a&lt;br&gt;
liveness count:&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/career/yedi-kaynagim-404-dondu-yetmis-besi-adres-degistirdi/" rel="noopener noreferrer"&gt;seven returned 404 and seventy-five had moved to a different address&lt;/a&gt;.&lt;br&gt;
That measurement told me how healthy the list was. What it did not tell me&lt;br&gt;
was this: where does the list look?&lt;/p&gt;

&lt;p&gt;This time I counted that. I did not like the number — and in its first form&lt;br&gt;
it was wrong, and the wrong part was not only the number. My explanation of&lt;br&gt;
the cause did not hold either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I counted, and what I could not
&lt;/h2&gt;

&lt;p&gt;The repository holds 1,415 Turkish posts (measured on commit &lt;code&gt;67f09d8c&lt;/code&gt;, with&lt;br&gt;
this post left out of the count). &lt;strong&gt;325 of them&lt;/strong&gt; carry an official-sources&lt;br&gt;
section, and those sections contain &lt;strong&gt;2,189 external links&lt;/strong&gt; in total. The&lt;br&gt;
remaining 1,090 posts are not source-free, they are source-unrecorded: they&lt;br&gt;
were produced before that section settled into the writing as a habit.&lt;/p&gt;

&lt;p&gt;I am stating the denominator up front, because the denominator of a ratio is&lt;br&gt;
its most easily hidden part. 2,189 links belong to 325 posts. Nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first table came out in the wrong unit
&lt;/h2&gt;

&lt;p&gt;I ran the first query and the answer looked perfectly clear: &lt;strong&gt;30.2%&lt;/strong&gt; of the&lt;br&gt;
links — 661 of them — came from a single domain, &lt;code&gt;github.com&lt;/code&gt;. Exactly the&lt;br&gt;
kind of bad number that makes you want to write a post. I was about to put&lt;br&gt;
down the sentence "a third of my sources come from one place" when I noticed&lt;br&gt;
the unit error.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;github.com&lt;/code&gt; is not a publisher, it is a landlord. The URI standard draws&lt;br&gt;
this line from the start: of the authority part of an address it says&lt;br&gt;
"governance of the name space defined by the remainder of the URI is&lt;br&gt;
delegated to that authority," and the host is only one subcomponent of that&lt;br&gt;
authority. GitHub's own documentation says repositories are owned&lt;br&gt;
"individually" or shared within an organization. So the distance between&lt;br&gt;
&lt;code&gt;github.com/torvalds/linux&lt;/code&gt; and &lt;code&gt;github.com/systemd/systemd&lt;/code&gt; is no smaller&lt;br&gt;
than the distance between &lt;code&gt;nginx.org&lt;/code&gt; and &lt;code&gt;sqlite.org&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I split those 661 links by owner: &lt;strong&gt;157 separate owners, 188 separate&lt;br&gt;
repositories.&lt;/strong&gt; The top ones, in order: &lt;code&gt;torvalds/linux&lt;/code&gt; (202),&lt;br&gt;
&lt;code&gt;systemd/systemd&lt;/code&gt; (57), &lt;code&gt;git/git&lt;/code&gt; (42). There was no monopoly in the middle;&lt;br&gt;
there was a folder I had been counting wrong.&lt;/p&gt;

&lt;p&gt;I changed the unit and counted again: for hosting sites the owner, for&lt;br&gt;
everything else the domain, each treated as one "publisher."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsyLDE4OSBleHRlcm5hbCBsaW5rczxici8-ZnJvbSAzMjUgcG9zdHNdIC0tPiBCWzM4OCBkaXN0aW5jdCBob3N0czxici8-Z2l0aHViLmNvbSA2NjEgPSAzMC4yJV0KICBBIC0tPiBDWzU0NiBkaXN0aW5jdCBwdWJsaXNoZXJzPGJyLz5sYXJnZXN0IG9uZSA5LjIlXQogIEMgLS0-IERbTGludXggdXBzdHJlYW08YnIvPjQgcHVibGlzaGVycywgNDEyIGxpbmtzID0gMTguOCVdCiAgQiAtLi0-fHdyb25nIHVuaXR8IEM%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsyLDE4OSBleHRlcm5hbCBsaW5rczxici8-ZnJvbSAzMjUgcG9zdHNdIC0tPiBCWzM4OCBkaXN0aW5jdCBob3N0czxici8-Z2l0aHViLmNvbSA2NjEgPSAzMC4yJV0KICBBIC0tPiBDWzU0NiBkaXN0aW5jdCBwdWJsaXNoZXJzPGJyLz5sYXJnZXN0IG9uZSA5LjIlXQogIEMgLS0-IERbTGludXggdXBzdHJlYW08YnIvPjQgcHVibGlzaGVycywgNDEyIGxpbmtzID0gMTguOCVdCiAgQiAtLi0-fHdyb25nIHVuaXR8IEM%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="216" height="656"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the publisher level the table reads: &lt;strong&gt;546 distinct publishers&lt;/strong&gt;, the&lt;br&gt;
largest being &lt;code&gt;github.com/torvalds&lt;/code&gt; with a 9.2% share, and the top ten&lt;br&gt;
adding up to 34.9%. On top of that, 350 publishers appear exactly once across&lt;br&gt;
the whole corpus. That distribution is not bad; it even has a long tail worth&lt;br&gt;
being proud of.&lt;/p&gt;

&lt;p&gt;Then I split the same table by calendar month, and the pride ended there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quarter of the publishers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month&lt;/th&gt;
&lt;th&gt;Posts with sources&lt;/th&gt;
&lt;th&gt;Links&lt;/th&gt;
&lt;th&gt;Distinct publishers&lt;/th&gt;
&lt;th&gt;Linux share&lt;/th&gt;
&lt;th&gt;Median publishers per post&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14.3%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;td&gt;116&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;620&lt;/td&gt;
&lt;td&gt;328&lt;/td&gt;
&lt;td&gt;0.6%&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;751&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;32.1%&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;490&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;27.6%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-11 (first 13 days)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;170&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;18.2%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the first column's name: these are not all the posts published that&lt;br&gt;
month, only the ones carrying a source section. In July the gap is a chasm —&lt;br&gt;
200 posts went out that month and only 18 of them have a source section.&lt;br&gt;
After August the coverage is nearly complete: 95 of August's 97 posts, 95 of&lt;br&gt;
September's 96, 86 of October's 86. The single post on the 2026-05 row is&lt;br&gt;
there so the table does not lose it; a reader adding the columns should land&lt;br&gt;
on 325 and 2,189.&lt;/p&gt;

&lt;p&gt;In August I published 95 posts carrying a source section, and they rested on&lt;br&gt;
&lt;strong&gt;328 separate publishers&lt;/strong&gt;. In October I published 86 posts, and they rested&lt;br&gt;
on &lt;strong&gt;78 separate publishers&lt;/strong&gt;: a basin 4.2 times smaller.&lt;/p&gt;

&lt;p&gt;That last column matters too: the median number of publishers per post fell&lt;br&gt;
from seven to three, yet three is still a reasonable number. Look at any&lt;br&gt;
single post and you see work that was "verified from a few different places."&lt;br&gt;
The narrowing is invisible in one post; it shows up in the corpus. Audit a&lt;br&gt;
library book by book and you will not find a gap on any shelf; step back and&lt;br&gt;
you see that every shelf comes from the same press.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing August with October is apples to oranges
&lt;/h2&gt;

&lt;p&gt;When I first wrote this table the cause was ready at hand: in September the&lt;br&gt;
topic queue ran dry, the pipeline started picking topics on its own, and&lt;br&gt;
every topic it picked led to the kernel. A tidy story. What was missing: that&lt;br&gt;
wide 328 of August was not a product of my attention.&lt;/p&gt;

&lt;p&gt;I looked at the message of the first commit that added each post. Of&lt;br&gt;
August's 95 posts, &lt;strong&gt;72&lt;/strong&gt; arrived with &lt;code&gt;feat: auto-generated content&lt;/code&gt; — the&lt;br&gt;
old bulk pipeline, running over a topic calendar written in advance. In&lt;br&gt;
October the number of posts from that pipeline is &lt;strong&gt;zero&lt;/strong&gt;; 84 of the 86 come&lt;br&gt;
from today's agent pipeline. So August and October are not two months of the&lt;br&gt;
same author, they are months of two different production systems.&lt;/p&gt;

&lt;p&gt;The old pipeline's breadth was not curiosity either. &lt;strong&gt;48 of August's 95&lt;br&gt;
posts carry exactly eight sources&lt;/strong&gt; — median eight, a narrow spread. The&lt;br&gt;
pipeline applied a fixed quota per post, and the topics on that calendar were&lt;br&gt;
product write-ups; each product led to its own documentation, so each post&lt;br&gt;
brought a new publisher by construction. October's median is five, spread&lt;br&gt;
from three to thirteen.&lt;/p&gt;

&lt;p&gt;The honest comparison, then, is the one inside a single system:&lt;br&gt;
&lt;strong&gt;September 131 publishers, October 78.&lt;/strong&gt; That is still a narrowing — 1.7&lt;br&gt;
times — but not 4.2. The number in the title is not wrong, it is incomplete:&lt;br&gt;
part of the gap between August and October is explained by the authoring&lt;br&gt;
regime, the rest by topic selection.&lt;/p&gt;

&lt;p&gt;Separating the two did not suit me, which is why I am writing them out&lt;br&gt;
separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four separate publishers, one tree
&lt;/h2&gt;

&lt;p&gt;The publisher count is still too optimistic a measure. Look at the first four&lt;br&gt;
names on my list: &lt;code&gt;github.com/torvalds&lt;/code&gt; (202 links), &lt;code&gt;git.kernel.org&lt;/code&gt; (91),&lt;br&gt;
&lt;code&gt;docs.kernel.org&lt;/code&gt; (87), &lt;code&gt;www.kernel.org&lt;/code&gt; (32). They count as four separate&lt;br&gt;
publishers. Together they are &lt;strong&gt;412 links, that is 18.8% of all my sources.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All four sit around the same project. The kernel's patch-submission guide&lt;br&gt;
tells you to clone the mainline tree from&lt;br&gt;
&lt;code&gt;git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git&lt;/code&gt;: the same&lt;br&gt;
tree you see on GitHub under the name &lt;code&gt;torvalds/linux&lt;/code&gt;. There is one place&lt;br&gt;
here where I have to be careful — I could &lt;strong&gt;not find&lt;/strong&gt; an official sentence&lt;br&gt;
on kernel.org's pages saying "the GitHub repository is a mirror," so I am not&lt;br&gt;
writing that. The evidence I do have is more modest but sufficient: the&lt;br&gt;
official clone address and the GitHub repository name the same tree.&lt;/p&gt;

&lt;p&gt;My diversity counter was celebrating four doors into the same upstream as&lt;br&gt;
four separate sources. September's jump in the Linux share, from 0.6% to&lt;br&gt;
32.1%, sits exactly here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gate never complained
&lt;/h2&gt;

&lt;p&gt;This blog's source discipline is not an intention, it is code.&lt;br&gt;
&lt;code&gt;source-policy.mjs&lt;/code&gt; went live on 17 August 2026 and asks two things of every&lt;br&gt;
post: at least &lt;strong&gt;3&lt;/strong&gt; unique official sources, at least &lt;strong&gt;2&lt;/strong&gt; unique official&lt;br&gt;
hosts.&lt;/p&gt;

&lt;p&gt;The gate did not complain even once in October or November. And it was right&lt;br&gt;
not to: of the 116 posts published in those two months, &lt;strong&gt;45 sit at exactly 2&lt;br&gt;
hosts&lt;/strong&gt; and 51 at 3 — 82.8% living between the threshold and one step above&lt;br&gt;
it. The minimum became the norm over time; that is how gates usually work,&lt;br&gt;
nobody collects bonus points.&lt;/p&gt;

&lt;p&gt;I had already written about scale outstripping the gate on this blog: when&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/career/kirk-dort-yazi-ust-uste-tek-kategoriden/" rel="noopener noreferrer"&gt;forty-four posts in a row came from a single category&lt;/a&gt;&lt;br&gt;
no indicator turned red, and the lesson there was "no gate puts two posts&lt;br&gt;
side by side and asks whether they resemble each other." Six weeks later I am&lt;br&gt;
measuring the second face of the same gap: back then the composition had&lt;br&gt;
narrowed, this time the evidence base.&lt;/p&gt;

&lt;p&gt;There is a second trace in the same place: the allowlist of permitted sources&lt;br&gt;
has 71 entries, and &lt;strong&gt;62&lt;/strong&gt; of them have been used so far. The nine never used&lt;br&gt;
are: &lt;code&gt;canonical.com&lt;/code&gt;, &lt;code&gt;golang.org&lt;/code&gt;, &lt;code&gt;haproxy.com&lt;/code&gt;, &lt;code&gt;haproxy.org&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;mistral.ai&lt;/code&gt;, &lt;code&gt;traefik.io&lt;/code&gt;, &lt;code&gt;truenas.com&lt;/code&gt;, &lt;code&gt;typescriptlang.org&lt;/code&gt;, &lt;code&gt;vite.dev&lt;/code&gt;.&lt;br&gt;
My intention was broad when I wrote that list; my practice never got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not trust the "new publisher" count
&lt;/h2&gt;

&lt;p&gt;My first pass found a far more striking series: the number of publishers&lt;br&gt;
encountered for the first time each month. 115 in July, 298 in August, 95 in&lt;br&gt;
September, 24 in October, 12 in the first 13 days of November. A decline&lt;br&gt;
ready-made for a headline.&lt;/p&gt;

&lt;p&gt;I did not publish it, because that series falls on its own. After reading 546&lt;br&gt;
publishers, running into a new one gets mechanically harder; saturation draws&lt;br&gt;
the same curve as narrowing. A measurement agreeing with your thesis does not&lt;br&gt;
make it evidence.&lt;/p&gt;

&lt;p&gt;With the same suspicion I asked about monthly concentration by share rather&lt;br&gt;
than by count: the largest publisher's share in August is 3.1%, with&lt;br&gt;
&lt;code&gt;docs.github.com&lt;/code&gt;. In September it rises to 18.2% with &lt;code&gt;github.com/torvalds&lt;/code&gt;,&lt;br&gt;
in October 11.4% with &lt;code&gt;git.kernel.org&lt;/code&gt;, in November 10.0% with&lt;br&gt;
&lt;code&gt;www.rfc-editor.org&lt;/code&gt;. After August no month returned to that scatter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I tried to refute myself
&lt;/h2&gt;

&lt;p&gt;The weakest point of this measurement is that it only counts the source list.&lt;br&gt;
Yet the documents that are valuable but not on my allowlist get cited as&lt;br&gt;
inline links in the &lt;strong&gt;body&lt;/strong&gt; of the post. When I say "my basin narrowed," I&lt;br&gt;
may simply never have counted the part of the basin that lives in body text.&lt;/p&gt;

&lt;p&gt;I counted it. Across all body text outside the source sections there are&lt;br&gt;
&lt;strong&gt;306 external links&lt;/strong&gt; going to &lt;strong&gt;142 publishers&lt;/strong&gt;; 58 publishers appear only&lt;br&gt;
in body text and never made it into a source list. The body does carry an&lt;br&gt;
uncounted basin.&lt;/p&gt;

&lt;p&gt;But that basin is not widening. August's body text holds 78 links and 41&lt;br&gt;
distinct publishers; October and November's body text holds 97 links and 44.&lt;br&gt;
While the source list fell from 328 to 78, the body went from 41 to 44. The&lt;br&gt;
body does not compensate for the source section's narrowing; it sits in its&lt;br&gt;
own steady narrowness.&lt;/p&gt;

&lt;p&gt;Had I skipped this check the post would still stand, but a reader would&lt;br&gt;
rightly have asked "did you look at the body?"&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to repeat the measurement
&lt;/h2&gt;

&lt;p&gt;I am writing the method as it is, because a reader has to be able to&lt;br&gt;
recompute this table. I matched the source heading with &lt;strong&gt;the gate's own&lt;br&gt;
regular expression&lt;/strong&gt; — case-insensitive, accepting the English variants&lt;br&gt;
alongside &lt;code&gt;Resmî Kaynaklar&lt;/code&gt;. That matters: one post in the corpus has its&lt;br&gt;
heading written as &lt;code&gt;## Resmî kaynaklar&lt;/code&gt;, in lower case, and carries eight&lt;br&gt;
links. Counting case-sensitively would have dropped that post and shown&lt;br&gt;
August as 326 rather than 328 — I would have been measuring my own policy&lt;br&gt;
with a stricter definition than the policy's own. After that I extracted the&lt;br&gt;
Markdown links from the section under the heading, took each address's host,&lt;br&gt;
and where the host was &lt;code&gt;github.com&lt;/code&gt; or &lt;code&gt;gitlab.com&lt;/code&gt; appended the first path&lt;br&gt;
component to build the "publisher" unit, then split those by the&lt;br&gt;
&lt;code&gt;publishDate&lt;/code&gt; field into months.&lt;/p&gt;

&lt;p&gt;That landlord rule covers two names only, because this corpus has no other&lt;br&gt;
tenancy. If yours does — &lt;code&gt;sourceforge.net&lt;/code&gt;, &lt;code&gt;codeberg.org&lt;/code&gt;, &lt;code&gt;bitbucket.org&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;readthedocs.io&lt;/code&gt;, &lt;code&gt;npmjs.com&lt;/code&gt;, &lt;code&gt;*.github.io&lt;/code&gt; — the rule is the same: any&lt;br&gt;
address that rents out name space has to be split at the tenant level. Skip&lt;br&gt;
that and your counter shows you a wider world than you have.&lt;/p&gt;

&lt;p&gt;I stepped into three traps along the way. The first is language twins: every&lt;br&gt;
post has an English counterpart and the same sources sit in those files too,&lt;br&gt;
&lt;strong&gt;2,203 more links&lt;/strong&gt;. Counting both would have pushed the total to 4,392 and&lt;br&gt;
changed the diversity table not one bit — a twin brings no new publisher, it&lt;br&gt;
counts an existing one twice. The second is the case sensitivity above. The&lt;br&gt;
third: this post itself is not in the count; when you repeat it, its four&lt;br&gt;
sources will be in the table as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask your own setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is the unit you count genuinely independent?&lt;/strong&gt; Any counter that treats a
landlord as a publisher and a mirror as a second source inflates diversity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the two periods you compare the same system?&lt;/strong&gt; If the production
method changed, the gap in your hands may be a regime difference rather
than a behavioural one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the drop saturation or narrowing?&lt;/strong&gt; The cleanest way to separate them
is to put two periods of similar volume — and the same pipeline — side by
side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At what scale does your quality gate work?&lt;/strong&gt; A gate that works per item
cannot see anything that happens at portfolio level.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The real lesson I take from this is not the scale of gates — I learned that&lt;br&gt;
six weeks ago and still did not see this narrowing coming. The real lesson is&lt;br&gt;
the unit of the counter. What I took to be diversity got counted with a unit&lt;br&gt;
of my own making, and as long as that unit was wrong the count did nothing&lt;br&gt;
but reassure me. I counted a domain as a publisher; I counted four&lt;br&gt;
doors into the same tree as four sources; I counted months from two different&lt;br&gt;
production pipelines as two months of the same author. All three errors&lt;br&gt;
pushed the counter up — that is, in my own favour.&lt;/p&gt;

&lt;p&gt;The unit of measure is the definition of the thing you are trying to measure.&lt;br&gt;
A wrong unit does not hand you a wrong answer; it hands you a &lt;strong&gt;reassuring&lt;/strong&gt;&lt;br&gt;
one, which is far harder to notice.&lt;/p&gt;

&lt;p&gt;My next move is to turn this count into a check that runs once a month: the&lt;br&gt;
distinct publisher count of the last 30 posts, and the largest publisher's&lt;br&gt;
share. For a starting threshold I will take the September-October band —&lt;br&gt;
warn if 30 posts rest on fewer than 40 distinct publishers, or if a single&lt;br&gt;
publisher passes 20%. That may not be the right threshold, but starting&lt;br&gt;
somewhere beats not measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2" rel="noopener noreferrer"&gt;RFC 3986 — URI authority and host components (§3.2, §3.2.2)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/process/submitting-patches.html" rel="noopener noreferrer"&gt;Linux kernel — patch submission guide, clone address of the mainline tree&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/repositories/creating-and-managing-repositories/about-repositories" rel="noopener noreferrer"&gt;GitHub Docs — repository ownership: individual or shared within an organization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kernel.org/" rel="noopener noreferrer"&gt;kernel.org — source access protocols and locations&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>olcum</category>
      <category>process</category>
      <category>dersler</category>
    </item>
    <item>
      <title>I Typed grep, I Got ugrep: Four Authorities Pick the Program</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 13 Nov 2026 11:30:56 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-typed-grep-i-got-ugrep-four-authorities-pick-the-program-4i59</link>
      <guid>https://dev.to/merbayerp/i-typed-grep-i-got-ugrep-four-authorities-pick-the-program-4i59</guid>
      <description>&lt;p&gt;Yesterday I threw away an article before publishing it. The real reason was duplication:&lt;br&gt;
I had already written the same subject a month earlier (Unicode normalization, two methods&lt;br&gt;
giving two different counts in the same &lt;code&gt;~/Downloads&lt;/code&gt; folder), and my first scan had missed&lt;br&gt;
it because the list was cut short with &lt;code&gt;head&lt;/code&gt;. But that run produced a second lesson, and&lt;br&gt;
that is the one that actually stung.&lt;/p&gt;

&lt;p&gt;The article's surprise was this: a filename was found by &lt;code&gt;grep -F&lt;/code&gt; and not by &lt;code&gt;grep -E&lt;/code&gt;. I&lt;br&gt;
ran the same measurement with &lt;code&gt;/usr/bin/grep&lt;/code&gt; — it found the file in all three modes. The&lt;br&gt;
thing I was presenting as a surprise was not the filename's behaviour. It was my shell's.&lt;/p&gt;

&lt;p&gt;I wrote one line in my working notes: "Run every measurement about text or file behaviour&lt;br&gt;
with an &lt;strong&gt;absolute path&lt;/strong&gt; (&lt;code&gt;/usr/bin/grep&lt;/code&gt;, &lt;code&gt;/usr/bin/find&lt;/code&gt;, &lt;code&gt;/usr/bin/tar&lt;/code&gt;) and print its&lt;br&gt;
version in the article." Correct lesson, incomplete one. Because the real question is&lt;br&gt;
bigger: I type the &lt;strong&gt;name&lt;/strong&gt; of a command — who decides which &lt;strong&gt;program&lt;/strong&gt; runs? Today I&lt;br&gt;
counted. The answer sits in four authorities: aliases, the shell's function table, the&lt;br&gt;
order of PATH, and the first line of the script. I set up all four, each at a different time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The experiment: same command, same flags, three files against one
&lt;/h2&gt;

&lt;p&gt;I wanted to see the mechanism in the smallest possible rig first. An empty directory, a&lt;br&gt;
&lt;code&gt;.gitignore&lt;/code&gt;, and three files carrying the same string: one ordinary, one listed in&lt;br&gt;
&lt;code&gt;.gitignore&lt;/code&gt;, one under &lt;code&gt;node_modules/&lt;/code&gt;. No repository needed — I did not run &lt;code&gt;git init&lt;/code&gt;;&lt;br&gt;
the presence of the ignore file is enough.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ mkdir -p deney/node_modules &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; deney
$ &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'gizli.txt\nnode_modules/\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore
$ &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ARANAN_DIZGE burada'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; acik.txt
$ &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ARANAN_DIZGE burada'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; gizli.txt
$ &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ARANAN_DIZGE burada'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; node_modules/paket.txt

$ /usr/bin/grep --version
grep &lt;span class="o"&gt;(&lt;/span&gt;BSD grep, GNU compatible&lt;span class="o"&gt;)&lt;/span&gt; 2.6.0-FreeBSD

$ /usr/bin/grep -rl ARANAN_DIZGE .
./acik.txt
./node_modules/paket.txt
./gizli.txt

$ grep -rl ARANAN_DIZGE .
acik.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same flags, same directory, same minute. Three files when I call it by absolute path, one&lt;br&gt;
file when I type its name. Two thirds of the matches never appeared — and no warning was&lt;br&gt;
printed. In a counting exercise, that is more than enough silence to write a "clean" report.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;type&lt;/code&gt; tells you why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ &lt;span class="nb"&gt;type&lt;/span&gt; grep
grep is a shell &lt;span class="k"&gt;function&lt;/span&gt; from /Users/mustafaerbay/.claude/shell-snapshots/snapshot-zsh-...sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this shell &lt;code&gt;grep&lt;/code&gt; is not a binary, it is a function. Its body hands the command to a&lt;br&gt;
different executable under the identity &lt;code&gt;ugrep&lt;/code&gt; and inserts flags such as&lt;br&gt;
&lt;code&gt;--ignore-files --hidden --exclude-dir=.git&lt;/code&gt; along the way. &lt;code&gt;--ignore-files&lt;/code&gt; reads&lt;br&gt;
&lt;code&gt;.gitignore&lt;/code&gt; and skips whatever is listed there. So the function is not misbehaving; it is&lt;br&gt;
doing exactly the job it was designed for. My expectation was wrong: that is the behaviour&lt;br&gt;
I want while moving around a repository, not while counting an inventory.&lt;/p&gt;

&lt;p&gt;The most irritating part is this: the function has an escape hatch. When it sees certain&lt;br&gt;
flags it steps aside and calls the real &lt;code&gt;grep&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ grep -rlZ ARANAN_DIZGE . &lt;span class="o"&gt;|&lt;/span&gt; tr &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
./acik.txt
./node_modules/paket.txt
./gizli.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added one letter and the result went back to three. Same name, same directory, two&lt;br&gt;
different programs depending on a flag. There is no way to spot that by eye inside a&lt;br&gt;
measurement script.&lt;/p&gt;
&lt;h2&gt;
  
  
  The order is documented, and it is not in my favour
&lt;/h2&gt;

&lt;p&gt;This is not an accident, it is zsh's documented behaviour. When the shell resolves a command&lt;br&gt;
word it goes: "If there exists a shell function by that name, the function is invoked", then&lt;br&gt;
"If there exists a shell builtin by that name, the builtin is invoked", and only at the end&lt;br&gt;
"Otherwise, the shell searches each element of &lt;code&gt;$path&lt;/code&gt;". PATH is the last place it looks.&lt;br&gt;
If a function exists, PATH is never opened at all.&lt;/p&gt;

&lt;p&gt;There is one more layer above functions: aliases. An alias is expanded while the line is&lt;br&gt;
being parsed, before resolution starts. A two-line experiment is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ deney&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; FONKSIYON&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
$ &lt;span class="nb"&gt;alias&lt;/span&gt; &lt;span class="nv"&gt;deney&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'echo ALIAS'&lt;/span&gt;
$ deney
ALIAS
$ &lt;span class="nb"&gt;unalias&lt;/span&gt; deney&lt;span class="o"&gt;;&lt;/span&gt; deney
FONKSIYON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My own terminal has five aliases and none of them shares a name with a command on PATH — so&lt;br&gt;
this layer is empty on my machine. It is not empty for someone who writes&lt;br&gt;
&lt;code&gt;alias grep='grep --color=auto'&lt;/code&gt;, and most "useful dotfiles" repositories suggest exactly that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtJIHR5cGUgYSBjb21tYW5kIG5hbWVdIC0tPiBae2lzIHRoZXJlIGFuIGFsaWFzfQogIFogLS0-fHllc3wgWVt0aGUgYWxpYXMgZXhwYW5kc10KICBaIC0tPnxub3wgQntpcyB0aGVyZSBhIGZ1bmN0aW9ufQogIFkgLS0-IEIKICBCIC0tPnx5ZXN8IENbZnVuY3Rpb24gcnVuczxici8-Z3JlcCBpcyBjYXVnaHQgaGVyZV0KICBCIC0tPnxub3wgRHtpcyBpdCBhIGJ1aWx0aW59CiAgRCAtLT58eWVzfCBFW2J1aWx0aW4gcnVuc10KICBEIC0tPnxub3wgRltQQVRIIGlzIHNlYXJjaGVkPGJyLz5maXJzdCBtYXRjaCB3aW5zXQogIEYgLS0-IEd7aXMgaXQgYSBzY3JpcHR9CiAgRyAtLT58eWVzfCBIW3RoZSBpbnRlcnByZXRlciBpczxici8-Zm91bmQgdGhyb3VnaCBQQVRIIHRvb10KICBHIC0tPnxub3wgSVt0aGUgYmluYXJ5IHJ1bnMgZGlyZWN0bHld%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtJIHR5cGUgYSBjb21tYW5kIG5hbWVdIC0tPiBae2lzIHRoZXJlIGFuIGFsaWFzfQogIFogLS0-fHllc3wgWVt0aGUgYWxpYXMgZXhwYW5kc10KICBaIC0tPnxub3wgQntpcyB0aGVyZSBhIGZ1bmN0aW9ufQogIFkgLS0-IEIKICBCIC0tPnx5ZXN8IENbZnVuY3Rpb24gcnVuczxici8-Z3JlcCBpcyBjYXVnaHQgaGVyZV0KICBCIC0tPnxub3wgRHtpcyBpdCBhIGJ1aWx0aW59CiAgRCAtLT58eWVzfCBFW2J1aWx0aW4gcnVuc10KICBEIC0tPnxub3wgRltQQVRIIGlzIHNlYXJjaGVkPGJyLz5maXJzdCBtYXRjaCB3aW5zXQogIEYgLS0-IEd7aXMgaXQgYSBzY3JpcHR9CiAgRyAtLT58eWVzfCBIW3RoZSBpbnRlcnByZXRlciBpczxici8-Zm91bmQgdGhyb3VnaCBQQVRIIHRvb10KICBHIC0tPnxub3wgSVt0aGUgYmluYXJ5IHJ1bnMgZGlyZWN0bHld%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="563" height="1608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are two ways out, both in the documentation — but they do not do the same thing.&lt;br&gt;
&lt;code&gt;command grep ...&lt;/code&gt; runs "an external command instead of a function or builtin"; that is, it&lt;br&gt;
&lt;strong&gt;skips the function, not the ordering&lt;/strong&gt;. For someone whose PATH has &lt;code&gt;/opt/homebrew/bin/grep&lt;/code&gt;&lt;br&gt;
in front, &lt;code&gt;command grep&lt;/code&gt; still means GNU grep, not BSD grep. An absolute path cuts through&lt;br&gt;
both. And &lt;code&gt;whence -p grep&lt;/code&gt; does "a path search for name even if it is an alias, reserved&lt;br&gt;
word, shell function or builtin". My reflex for years has been to type &lt;code&gt;which&lt;/code&gt;; in this&lt;br&gt;
shell that prints the function body — it is answering correctly, but I read it as "a path"&lt;br&gt;
and move on.&lt;/p&gt;

&lt;p&gt;The shell also has a memory. The first time zsh runs a command it notes its location&lt;br&gt;
(&lt;code&gt;HASH_CMDS&lt;/code&gt;: "Note the location of each command the first time it is executed") and does not&lt;br&gt;
search PATH again on later calls. Add a directory to PATH mid-session and you keep getting&lt;br&gt;
the old binary; &lt;code&gt;rehash&lt;/code&gt; (that is, &lt;code&gt;hash -r&lt;/code&gt;) empties the table. If you changed PATH while&lt;br&gt;
measuring, this is the most common reason the winner disagrees with your arithmetic.&lt;/p&gt;

&lt;p&gt;This shell has 131 functions defined. Three of them share a name with a command on PATH:&lt;br&gt;
&lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt;, &lt;code&gt;pkill&lt;/code&gt;. Three. Hard to notice among a hundred and thirty-one, enough to&lt;br&gt;
break a measurement.&lt;/p&gt;
&lt;h2&gt;
  
  
  The PATH inventory: 26 entries, six of them gone
&lt;/h2&gt;

&lt;p&gt;Past the function layer comes PATH. Every number in this section comes from the shell my&lt;br&gt;
agent runs; the PATH in my own terminal is three directories shorter, and I will get to the&lt;br&gt;
difference at the end.&lt;/p&gt;

&lt;p&gt;Twenty-six directories. Six of them do &lt;strong&gt;not&lt;/strong&gt; exist on disk. Three are macOS cryptex&lt;br&gt;
bootstrap paths, one is &lt;code&gt;/pkg/env/global/bin&lt;/code&gt; — the &lt;code&gt;/pkg&lt;/code&gt; directory it points at is not on&lt;br&gt;
this machine at all. The remaining two are my own doing: &lt;code&gt;~/development/flutter/bin&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;~/.pub-cache/bin&lt;/code&gt;. I deleted Flutter months ago; the lines still sit in the first two lines&lt;br&gt;
of my &lt;code&gt;.zshrc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I also wondered where those directories come from. Twelve of them come not from my own files&lt;br&gt;
but from &lt;code&gt;path_helper&lt;/code&gt;. Apple's man page is clear about the job: first "default PATH and&lt;br&gt;
MANPATH values are obtained from the files &lt;code&gt;/etc/paths&lt;/code&gt;", then the contents of the files in&lt;br&gt;
&lt;code&gt;/etc/paths.d&lt;/code&gt; are appended. On my machine &lt;code&gt;/etc/paths&lt;/code&gt; has six lines and &lt;code&gt;/etc/paths.d&lt;/code&gt; has&lt;br&gt;
four files with six lines in total.&lt;/p&gt;

&lt;p&gt;I looked at those four files one by one, because I was about to wave them away as "leftovers&lt;br&gt;
of tools I installed" and that would not have been true. All four are owned by &lt;code&gt;root&lt;/code&gt;. Two&lt;br&gt;
(&lt;code&gt;10-cryptex&lt;/code&gt;, &lt;code&gt;100-rvictl&lt;/code&gt;) ship with macOS — &lt;code&gt;rvictl&lt;/code&gt; is still sitting in&lt;br&gt;
&lt;code&gt;/Library/Apple/usr/bin&lt;/code&gt;. &lt;code&gt;MacGPG2&lt;/code&gt; is dated 2020 but it is not a leftover either: there are&lt;br&gt;
twenty executables in that directory and &lt;code&gt;/usr/local/bin/gpg&lt;/code&gt; still links into it. Only&lt;br&gt;
&lt;code&gt;10-pmk-global&lt;/code&gt; is genuinely orphaned, and it carries the same creation minute as the system&lt;br&gt;
files; I do not know who put it there, and writing that I do not know beats guessing. A dead&lt;br&gt;
directory on PATH does no measurable harm — it is just enough to make you think something is&lt;br&gt;
installed.&lt;/p&gt;

&lt;p&gt;One more detail: &lt;code&gt;path_helper&lt;/code&gt; runs through &lt;code&gt;/etc/zprofile&lt;/code&gt; on every login shell and puts its&lt;br&gt;
own list &lt;strong&gt;in front&lt;/strong&gt;. That is why a PATH you export in &lt;code&gt;.zshenv&lt;/code&gt; ends up behind the system&lt;br&gt;
directories. On macOS this is where people trip most often.&lt;/p&gt;
&lt;h2&gt;
  
  
  85 commands with the same name: the winner is whoever is earlier
&lt;/h2&gt;

&lt;p&gt;Across the twenty directories that exist on this PATH there are 2,290 distinct command names.&lt;br&gt;
&lt;strong&gt;85 of them exist in more than one directory&lt;/strong&gt;; in 81 cases the copies really are separate&lt;br&gt;
files (in the other four, both paths lead to the same file). So there are eighty-one "which&lt;br&gt;
one?" questions on this machine, and the answer is given by directory order, not by me.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;In how many directories&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;python3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/Library/Frameworks/Python.framework/Versions/3.13/bin&lt;/code&gt; (3.13.3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/opt/homebrew/bin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;psql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/opt/homebrew/opt/postgresql@16/bin&lt;/code&gt; (16.11)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.nvm/versions/node/v22.16.0/bin&lt;/code&gt; (22.16.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/opt/homebrew/bin&lt;/code&gt; (2.48.1)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row stopped me. &lt;code&gt;/usr/bin/git&lt;/code&gt; on this machine is &lt;strong&gt;2.54.0 (Apple Git-157)&lt;/strong&gt;, while&lt;br&gt;
Homebrew's git is &lt;strong&gt;2.48.1&lt;/strong&gt;. For years I have assumed "the system tool is old, the package&lt;br&gt;
manager's is new" and put Homebrew at the front of my PATH. For this command the opposite&lt;br&gt;
happened: my ordering drops me six minor versions back. What is old here is not the system,&lt;br&gt;
it is the package I forgot to update. Three weeks ago I counted&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/188-paketim-var-brew-ikisini-okumayi-reddediyor/" rel="noopener noreferrer"&gt;the two packages my package manager refuses to read from its own list&lt;/a&gt;;&lt;br&gt;
there the problem was a package not showing up, here the package shows up fine — what is&lt;br&gt;
invisible is which copy runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which pg_dump? My client is 16, my server is 14
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;psql&lt;/code&gt; row is more serious. Line seven of my &lt;code&gt;.zshrc&lt;/code&gt; puts &lt;code&gt;postgresql@16/bin&lt;/code&gt; at the&lt;br&gt;
front of PATH, which is why &lt;code&gt;psql&lt;/code&gt; and &lt;code&gt;pg_dump&lt;/code&gt; come out as 16.11. But the PostgreSQL server&lt;br&gt;
&lt;strong&gt;running&lt;/strong&gt; on this Mac is &lt;code&gt;postgresql@14&lt;/code&gt;: in the process list it is&lt;br&gt;
&lt;code&gt;/opt/homebrew/opt/postgresql@14/bin/postgres&lt;/code&gt;, and that cluster's &lt;code&gt;PG_VERSION&lt;/code&gt; file holds a&lt;br&gt;
single line, &lt;code&gt;14&lt;/code&gt;. (There is also an idle &lt;code&gt;postgresql@16&lt;/code&gt; data directory on disk; that is&lt;br&gt;
what really sets the trap.)&lt;/p&gt;

&lt;p&gt;I connected the client and asked the server who it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ /opt/homebrew/opt/postgresql@16/bin/psql -tAc &lt;span class="s1"&gt;'select version()'&lt;/span&gt; postgres
PostgreSQL 14.18 &lt;span class="o"&gt;(&lt;/span&gt;Homebrew&lt;span class="o"&gt;)&lt;/span&gt; on aarch64-apple-darwin24.4.0, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. And that is no coincidence: PostgreSQL's own documentation says "pg_dump can also&lt;br&gt;
dump from PostgreSQL servers older than its own version", supported back to 9.2. A newer&lt;br&gt;
client can read an older server.&lt;/p&gt;

&lt;p&gt;For the other direction the same page is far blunter: "pg_dump cannot dump from PostgreSQL&lt;br&gt;
servers newer than its own major version; it will refuse to even try, rather than risk making&lt;br&gt;
an invalid dump." Today I am on the good side, but I won that by ordering, not by skill. If&lt;br&gt;
that line in &lt;code&gt;.zshrc&lt;/code&gt; were ever deleted — or if I had never written it — &lt;code&gt;pg_dump&lt;/code&gt; would&lt;br&gt;
quietly fall back to the &lt;strong&gt;14.18&lt;/strong&gt; in &lt;code&gt;/opt/homebrew/bin&lt;/code&gt;; and on the day I moved the cluster&lt;br&gt;
to 16, my backup command would not have written half a file, it would have politely refused.&lt;br&gt;
The good news is the politeness of that refusal; the bad news is that the only place the&lt;br&gt;
refusal shows up is the log of a scheduled job.&lt;/p&gt;
&lt;h2&gt;
  
  
  The third context: launchd's four-directory world
&lt;/h2&gt;

&lt;p&gt;After measuring my terminal and my agent's shell, I had not counted the third context at all:&lt;br&gt;
scheduled jobs. There are 27 plists under &lt;code&gt;~/Library/LaunchAgents&lt;/code&gt; — jobs that pull backups,&lt;br&gt;
fetch exchange rates, open tunnels, feed a panel.&lt;/p&gt;

&lt;p&gt;launchd states what it hands over, in its own report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ launchctl print gui/501/com.erbay.spark-bulut &lt;span class="o"&gt;|&lt;/span&gt; /usr/bin/grep -A3 &lt;span class="s1"&gt;'default environment'&lt;/span&gt;
    default environment &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        PATH &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/bin:/bin:/usr/sbin:/sbin
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four directories. They are not arbitrary; they are a constant in Apple's Libc:&lt;br&gt;
&lt;code&gt;#define _PATH_STDPATH "/usr/bin:/bin:/usr/sbin:/sbin"&lt;/code&gt;. I counted: 1,257 command names&lt;br&gt;
resolve on that PATH — 55% of the 2,290 I counted in my shell. There is no &lt;code&gt;node&lt;/code&gt; in it, no&lt;br&gt;
&lt;code&gt;npm&lt;/code&gt;, no &lt;code&gt;psql&lt;/code&gt;, no &lt;code&gt;gh&lt;/code&gt;, no &lt;code&gt;brew&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And there is a &lt;code&gt;python3&lt;/code&gt; — but which one? That job's plist invokes its program as&lt;br&gt;
&lt;code&gt;/usr/bin/env python3&lt;/code&gt;. I asked with those same four directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ env -i /usr/bin/env python3 --version
Python 3.9.6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my terminal &lt;code&gt;python3&lt;/code&gt; is 3.13.3. In the scheduler it is 3.9.6. In Python's own developer&lt;br&gt;
guide the status of 3.9 is "end-of-life", dated 2025-10-31 — an interpreter that has not&lt;br&gt;
received a patch in more than a year runs my script every morning at 07:15. Today's run wrote&lt;br&gt;
two lines to its log and both are ordinary; so this is not something broken, it is a place&lt;br&gt;
where the version I installed myself never comes into play.&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/launchd-21881-kez-denedi-ben-bir-kez-bakmadim/" rel="noopener noreferrer"&gt;The day I counted launchd's retries&lt;/a&gt;&lt;br&gt;
taught me something similar: launchd does what it is told, not what I think I told it.&lt;/p&gt;

&lt;p&gt;Three plists set PATH themselves. One wrote four directories, one wrote six; both are&lt;br&gt;
reasonable. The third froze an agent session's PATH exactly as it stood at that moment: 32&lt;br&gt;
entries, 26 unique, &lt;strong&gt;nine entries (seven distinct directories) that no longer exist&lt;/strong&gt;. One of&lt;br&gt;
them is &lt;code&gt;~/.codex/tmp/arg0/codex-arg0adUvOF&lt;/code&gt; — a temporary directory, deleted weeks ago. That&lt;br&gt;
plist keeps looking for it on every launch. Among the same files I also found a comment I had&lt;br&gt;
written myself: "the directory holding the claude CLI must be on PATH in the launchd&lt;br&gt;
environment." So I had already learned this lesson, noted it in a plist, and then forgotten it.&lt;/p&gt;
&lt;h3&gt;
  
  
  And a fourth: the job that walks back into the shell
&lt;/h3&gt;

&lt;p&gt;I was going to write "none of these jobs ever sees my shell"; before writing it I read all 27&lt;br&gt;
plists one by one, and I am glad I did. One of them — my daily worktree cleanup — invokes its&lt;br&gt;
program as &lt;code&gt;/bin/zsh -lc &amp;lt;script&amp;gt;&lt;/code&gt;. The &lt;code&gt;-l&lt;/code&gt; means a login shell: &lt;code&gt;/etc/zprofile&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;path_helper&lt;/code&gt; and &lt;code&gt;.zprofile&lt;/code&gt; all come into play. But &lt;code&gt;-c&lt;/code&gt; means non-interactive, so &lt;code&gt;.zshrc&lt;/code&gt;&lt;br&gt;
is &lt;strong&gt;not&lt;/strong&gt; read. I rebuilt its environment and asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ env -i &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt; &lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin \
    /bin/zsh -lc &lt;span class="s1"&gt;'echo $PATH; whence -p node'&lt;/span&gt;
&lt;span class="c"&gt;# 17 entries&lt;/span&gt;
/opt/homebrew/bin/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventeen entries: more than launchd's four, fewer than my terminal's twenty-three. And &lt;code&gt;node&lt;/code&gt;&lt;br&gt;
there is &lt;strong&gt;Homebrew's 23.11.0&lt;/strong&gt; — not the nvm 22.16.0 that wins in my terminal. Because nvm is&lt;br&gt;
set up by &lt;code&gt;.zshrc&lt;/code&gt;, and &lt;code&gt;.zshrc&lt;/code&gt; is never read in this job. That script only calls &lt;code&gt;git&lt;/code&gt;, so it&lt;br&gt;
does no harm today; but the question "what do I get when I type &lt;code&gt;node&lt;/code&gt; on this machine" has&lt;br&gt;
three answers here: 22.16.0, 23.11.0, and command not found.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shebang layer: 53 commands look up their interpreter on PATH
&lt;/h2&gt;

&lt;p&gt;PATH does not only resolve the name I type. When I run a script, that script's interpreter goes&lt;br&gt;
through the same mechanism.&lt;/p&gt;

&lt;p&gt;I looked at the first line of all 2,290 commands on my PATH: 1,705 are binaries (no shebang),&lt;br&gt;
583 are scripts, two could not be read. &lt;strong&gt;53&lt;/strong&gt; of those 583 scripts begin with &lt;code&gt;#!/usr/bin/env&lt;/code&gt;;&lt;br&gt;
that is, they look up their interpreter on PATH at run time. The four largest groups: 17 &lt;code&gt;node&lt;/code&gt;,&lt;br&gt;
11 &lt;code&gt;perl&lt;/code&gt;, 9 &lt;code&gt;bash&lt;/code&gt;, 8 &lt;code&gt;python3&lt;/code&gt;. The list includes &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;yarn&lt;/code&gt;, &lt;code&gt;wrangler&lt;/code&gt;, &lt;code&gt;firebase&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;netlify&lt;/code&gt; and &lt;code&gt;claude&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This small detail has a nice consequence. Homebrew's &lt;code&gt;npm&lt;/code&gt; does not use Homebrew's own node&lt;br&gt;
23.11.0, it uses the nvm node 22.16.0 at the front of PATH — because the first line of&lt;br&gt;
&lt;code&gt;npm-cli.js&lt;/code&gt; is &lt;code&gt;#!/usr/bin/env node&lt;/code&gt;. So the answer to "which node" depends not on the manager&lt;br&gt;
that installed the package but on whichever PATH is in force; the scheduled job in the previous&lt;br&gt;
section is the proof. The only way to measure the difference between being installed and being&lt;br&gt;
used is to ask for the path, not the name.&lt;/p&gt;

&lt;p&gt;The remaining 530 scripts name an absolute interpreter. Two of those point at a&lt;br&gt;
&lt;code&gt;/usr/local/bin/bash&lt;/code&gt; that does not exist (&lt;code&gt;test-yaml&lt;/code&gt; and &lt;code&gt;test-yaml5.34&lt;/code&gt;). Two commands,&lt;br&gt;
quietly unusable for months. Nobody noticed, because nobody looked for them.&lt;/p&gt;
&lt;h2&gt;
  
  
  I broke my own measurement twice
&lt;/h2&gt;

&lt;p&gt;The most instructive part of this article is the traps I fell into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first.&lt;/strong&gt; To compare my terminal with my agent's shell, I ran &lt;code&gt;zsh -l -c 'echo $PATH'&lt;/code&gt; from&lt;br&gt;
inside the agent. The output had 30 entries, four of them duplicated, and &lt;strong&gt;43 command names&lt;/strong&gt;&lt;br&gt;
resolved to different paths between the two contexts: &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt;, the whole &lt;code&gt;pg_*&lt;/code&gt;&lt;br&gt;
family. A perfect headline. I even started sorting out which ones were genuinely separate files&lt;br&gt;
— 35 really were. Then I looked at the rig: I had started &lt;code&gt;zsh -l&lt;/code&gt; from the agent's shell, that&lt;br&gt;
shell inherited its PATH, and &lt;code&gt;.zprofile&lt;/code&gt; and &lt;code&gt;.zshrc&lt;/code&gt; added their own entries on top. The&lt;br&gt;
difference I had found was a product of my own rig.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second, and the embarrassing one.&lt;/strong&gt; Next came a clean environment:&lt;br&gt;
&lt;code&gt;env -i HOME=... TERM=... /bin/zsh -lic 'echo $PATH'&lt;/code&gt;. The output had 24 entries and one of them&lt;br&gt;
was &lt;code&gt;/usr/ucb&lt;/code&gt;. That directory does not exist on this machine, appears in none of my dotfiles,&lt;br&gt;
and never enters my PATH in a Terminal window. Where did it come from? &lt;code&gt;env -i&lt;/code&gt; removes PATH&lt;br&gt;
&lt;strong&gt;entirely&lt;/strong&gt;, so zsh falls back to its compile-time default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ env -i /bin/zsh -fc &lt;span class="s1"&gt;'echo $PATH'&lt;/span&gt;
/bin:/usr/bin:/usr/ucb:/usr/local/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real login shell does not find PATH empty: launchd hands it &lt;code&gt;_PATH_STDPATH&lt;/code&gt; — the same four&lt;br&gt;
directories I printed two sections earlier. So the environment I thought was clean was&lt;br&gt;
committing the article's own error one floor further down. The correct rig:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ env -i &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt; &lt;span class="nv"&gt;SHELL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/bin/zsh &lt;span class="nv"&gt;TERM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xterm-256color &lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt; \
    &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh -lic &lt;span class="s1"&gt;'echo $PATH'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real answer: &lt;strong&gt;23&lt;/strong&gt; entries in my terminal (six of them missing from disk), 26 in the&lt;br&gt;
agent's shell, and the number of commands resolving to different paths is &lt;strong&gt;zero&lt;/strong&gt;. The&lt;br&gt;
directories present for the agent but not in my terminal are &lt;code&gt;~/go/bin&lt;/code&gt;, &lt;code&gt;~/Library/pnpm&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;~/.local/bin&lt;/code&gt;; of the 10 executables in those three directories, 9 reach me from nowhere else&lt;br&gt;
in my terminal (&lt;code&gt;govulncheck&lt;/code&gt; and &lt;code&gt;goimports&lt;/code&gt; are two of them — which means that if I call&lt;br&gt;
either in my own terminal, it is not found).&lt;/p&gt;

&lt;p&gt;So the only real difference between the two shells is those three functions. Three lines, enough&lt;br&gt;
to demolish an article's surprise; while all forty-three differences were an echo of the rig I&lt;br&gt;
had built. Within twenty-four hours I made three floors of the same mistake: first I failed to&lt;br&gt;
see that my shell had swapped my tool, then I contaminated the environment while comparing, then&lt;br&gt;
I over-cleaned it while cleaning.&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/her-yeni-komutum-bir-eskisini-siliyor/" rel="noopener noreferrer"&gt;The day I counted my shell history&lt;/a&gt; the&lt;br&gt;
lesson was close to this one — an assumption about a tool is not a measurement with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions for your own setup
&lt;/h2&gt;

&lt;p&gt;The numbers from this machine tell you nothing; the questions do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before writing a counting measurement, run &lt;code&gt;type &amp;lt;command&amp;gt;&lt;/code&gt;.&lt;/strong&gt; If the output says "shell
function" or "alias", your measurement is measuring your shell, not your disk. Print the
version in the report too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not confuse &lt;code&gt;command&lt;/code&gt; with an absolute path.&lt;/strong&gt; &lt;code&gt;command grep&lt;/code&gt; skips the function, not the
PATH ordering; the only thing that guarantees which &lt;code&gt;grep&lt;/code&gt; you get is &lt;code&gt;/usr/bin/grep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you are comparing, reset the environment — but do not over-reset it.&lt;/strong&gt; &lt;code&gt;env -i&lt;/code&gt; removes
PATH as well, and the shell falls back to its own default; to imitate a real login shell, pass
&lt;code&gt;PATH&lt;/code&gt; with your system's default value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you changed PATH mid-session, &lt;code&gt;rehash&lt;/code&gt;.&lt;/strong&gt; Otherwise the shell keeps using the location it
noted earlier and your measurement reports the wrong winner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your PATH end to end, once.&lt;/strong&gt; Which directories are missing, which line comes from which
file, whose leftovers are sitting in &lt;code&gt;/etc/paths.d&lt;/code&gt; — and are they really leftovers?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for binaries that share a name.&lt;/strong&gt; Database clients, &lt;code&gt;python3&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt;: if ordering
picks the version, make sure you set that ordering deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not trust PATH in scheduled jobs.&lt;/strong&gt; Write the program path absolutely; if you must set
PATH, write out the three directories you need instead of copying your current shell. A frozen
PATH becomes a list of non-existent directories six months later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wherever you see &lt;code&gt;#!/usr/bin/env&lt;/code&gt;, read it as "the interpreter will be chosen at run time."&lt;/strong&gt;
Fine if that is the behaviour you want in production; not fine if you meant to pin a version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The name of a command is an intention; the program that runs is a decision
&lt;/h2&gt;

&lt;p&gt;What I type on the keyboard all day is not a command, it is a command name. Four authorities&lt;br&gt;
decide which program runs: aliases, my shell's function table, the order of PATH, and the first&lt;br&gt;
line of the script. All four came from my own hands, but none of them is the work of a single&lt;br&gt;
sitting — one came from a tool's install script, one from the seventh line of my &lt;code&gt;.zshrc&lt;/code&gt;, one&lt;br&gt;
from a directory deleted months ago.&lt;/p&gt;

&lt;p&gt;That is why "it works on my machine" now feels incomplete to me. Not on which machine but in&lt;br&gt;
which &lt;strong&gt;context&lt;/strong&gt;: in my terminal (23 directories), in my agent's shell (26 directories, three&lt;br&gt;
functions), in the midnight job opened with &lt;code&gt;zsh -lc&lt;/code&gt; (17 directories, a different node), or at&lt;br&gt;
07:15 in launchd's four? Same machine, same disk, four different answers.&lt;/p&gt;

&lt;p&gt;What yesterday's discarded article gave me turned out to be a habit rather than a lesson: ask for&lt;br&gt;
the tool's identity before you measure with it. If I do not know who shows up when I say &lt;code&gt;grep&lt;/code&gt;,&lt;br&gt;
the number I counted is not a measurement, it is an impression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/zsh-users/zsh/blob/master/Doc/Zsh/exec.yo" rel="noopener noreferrer"&gt;zsh — Command Execution (&lt;code&gt;Doc/Zsh/exec.yo&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/zsh-users/zsh/blob/master/Doc/Zsh/builtins.yo" rel="noopener noreferrer"&gt;zsh — &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;whence&lt;/code&gt;, &lt;code&gt;hash&lt;/code&gt; builtins (&lt;code&gt;Doc/Zsh/builtins.yo&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/16/app-pgdump.html" rel="noopener noreferrer"&gt;PostgreSQL 16 — pg_dump, Notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/shell_cmds/blob/main/path_helper/path_helper.8" rel="noopener noreferrer"&gt;Apple shell_cmds — &lt;code&gt;path_helper(8)&lt;/code&gt; man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/Libc/blob/main/include/paths.h" rel="noopener noreferrer"&gt;Apple Libc — &lt;code&gt;include/paths.h&lt;/code&gt; (&lt;code&gt;_PATH_STDPATH&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://devguide.python.org/versions/" rel="noopener noreferrer"&gt;Python Developer's Guide — Status of Python versions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/en/about/previous-releases" rel="noopener noreferrer"&gt;Node.js — Previous Releases&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>zsh</category>
      <category>launchd</category>
      <category>olcum</category>
    </item>
    <item>
      <title>The Dripping Server Did Not Trip Most of My Timeouts</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 13 Nov 2026 06:35:02 +0000</pubDate>
      <link>https://dev.to/merbayerp/the-dripping-server-did-not-trip-most-of-my-timeouts-h91</link>
      <guid>https://dev.to/merbayerp/the-dripping-server-did-not-trip-most-of-my-timeouts-h91</guid>
      <description>&lt;p&gt;Back in April I wrote a piece on this blog about &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/retry-firtinasi-timeout-budget-ve-latency-amplification/" rel="noopener noreferrer"&gt;timeout budgets and retry storms&lt;/a&gt;. The arithmetic there went like this: give every layer a slice of time, keep the lower layer's budget smaller than the one above it, otherwise retries pile onto each other. The arithmetic is right. But that article quietly assumed that the moment you write a budget into a client, the budget is actually enforced.&lt;/p&gt;

&lt;p&gt;This week I tested that assumption and found it does not hold in most places.&lt;/p&gt;

&lt;p&gt;I wrote an HTTP server that returns an eight-byte body, but sends those bytes three seconds apart. It has no ambition to be the world's slowest web server; its only job is to stall. Then I told the HTTP client of five languages, plus &lt;code&gt;curl&lt;/code&gt;, the same thing: &lt;strong&gt;five seconds&lt;/strong&gt;. Ten of them got a number; one I left untouched as a control — eleven setups in all. Seven of them waited twenty-four seconds and returned &lt;code&gt;200&lt;/code&gt; as if nothing had happened.&lt;/p&gt;

&lt;p&gt;The cause is not a bug but a difference in definition. In most clients, what we call a "timeout" is not a deadline placed on the total duration of a request; it is &lt;strong&gt;an hourglass set between two I/O events&lt;/strong&gt;. Every byte that arrives turns it over again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Machine, server and method
&lt;/h2&gt;

&lt;p&gt;The measurements were taken on 13 November 2026 on my own machine: macOS (Darwin 25.6.0), Apple M4 Pro, everything over &lt;code&gt;127.0.0.1&lt;/code&gt;. Tool versions: &lt;code&gt;curl 8.7.1&lt;/code&gt;, Python 3.9.6 with &lt;code&gt;requests 2.32.3&lt;/code&gt;, Node.js 22.16.0, Go 1.26.1, OpenJDK 24, Ruby 2.6.10. The Python side ran through the absolute path &lt;code&gt;/usr/bin/python3&lt;/code&gt;; my PATH also carries 3.13, and the exception class &lt;strong&gt;name&lt;/strong&gt; differs between them — that is why the table below says &lt;code&gt;socket.timeout&lt;/code&gt;. There is no network variable here — no packet loss, no DNS, no TLS handshake. What is left is only how the client keeps time.&lt;/p&gt;

&lt;p&gt;The server is a seventy-line Python script. Its heart is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"HTTP/1.1 200 OK\r\n"&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"Content-Length: {BODY_BYTES}\r\n"&lt;/span&gt;
    &lt;span class="s2"&gt;"Content-Type: text/plain\r\n"&lt;/span&gt;
    &lt;span class="s2"&gt;"Connection: close\r\n\r\n"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# headers instantly&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BODY_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# body drop by drop&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;GAP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I set up two scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dripping&lt;/strong&gt;: headers go out immediately, then eight bytes, each three seconds apart. Total delivery time 24 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;silent&lt;/strong&gt;: the connection is accepted, not a single byte is sent, and the socket closes at the twentieth second. This is the control group for the question "does the timeout never work at all, or only fail against a drip?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote as honest a "five seconds" as I could into each client: whichever field the documentation points to for timeouts in that language. The one exception is Node's bare &lt;code&gt;fetch&lt;/code&gt;: I deliberately left it alone, as a control for what the default does. The goal is not to pick a winner; it is to see how many different things the same sentence means across five languages and &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One limitation up front: every measurement is plaintext HTTP/1.1 over loopback. A TLS handshake, HTTP/2 flow control or a reverse proxy in between would change the table; these numbers show the behaviour &lt;em&gt;underneath&lt;/em&gt; those layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scenes, the same five seconds
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client and setting&lt;/th&gt;
&lt;th&gt;silent server&lt;/th&gt;
&lt;th&gt;dripping server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl --connect-timeout 5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20.01 s · exit 52&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.03 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl --max-time 5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.01 s · exit 28&lt;/td&gt;
&lt;td&gt;5.00 s · exit 28 · 1 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl --speed-time 5 --speed-limit 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.00 s · exit 28&lt;/td&gt;
&lt;td&gt;5.01 s · exit 28 · 1 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;timeout=5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.01 s · ReadTimeout&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.04 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;urllib&lt;/code&gt;, &lt;code&gt;timeout=5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.01 s · &lt;code&gt;socket.timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.04 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node &lt;code&gt;fetch&lt;/code&gt; (no options)&lt;/td&gt;
&lt;td&gt;20.03 s · fetch failed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.05 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node &lt;code&gt;fetch&lt;/code&gt; + &lt;code&gt;AbortSignal.timeout(5000)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.00 s · TimeoutError&lt;/td&gt;
&lt;td&gt;5.01 s · TimeoutError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go &lt;code&gt;http.Client{Timeout: 5s}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.00 s&lt;/td&gt;
&lt;td&gt;5.00 s (while reading the body)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go &lt;code&gt;Transport{ResponseHeaderTimeout: 5s}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.00 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.04 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ruby &lt;code&gt;Net::HTTP&lt;/code&gt;, &lt;code&gt;read_timeout = 5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;10.0 s · ReadTimeout&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.03 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java &lt;code&gt;HttpClient&lt;/code&gt; + &lt;code&gt;.timeout(5s)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.09 s · HttpTimeoutException&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.19 s · 200 · 8 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The seven rows in bold are the ones that waited for all eight bytes and returned a successful result. Four rows cut the transfer at the fifth second. Against the silent server the table almost inverts: there, eight setups stand up at the fifth second, because there is no byte to reset the counter. Ruby wakes at the fifth second too, but pushes its total to ten for a reason we will come to shortly.&lt;/p&gt;

&lt;p&gt;I repeated the timings three times and report the medians. The spread is surprisingly small: &lt;code&gt;requests&lt;/code&gt; gave 24.04 · 24.04 · 24.03 across three runs; Java 24.19 · 24.17 · 24.19; Go 5.00 · 5.00 · 5.00. It should not be surprising, really — the server's delivery time is &lt;code&gt;8 × 3&lt;/code&gt; seconds, so the number is entirely in my hands. What came out of the lab is not noise; it is a definition.&lt;/p&gt;

&lt;p&gt;The most annoying row is the first. A &lt;code&gt;curl&lt;/code&gt; command carrying &lt;code&gt;--connect-timeout 5&lt;/code&gt; will wait indefinitely once the connection is up; in the documentation's own words, that option "only limits the connection phase". That flag has been sitting in my scripts for years with the intent of "give up after five seconds". It does not give up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it does not fire: who resets the counter
&lt;/h2&gt;

&lt;p&gt;All seven rows share the same mechanism. The documentation does not hide it; we just do not read it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python &lt;code&gt;requests&lt;/code&gt;&lt;/strong&gt; splits the value in two: connect and read. For the read side the &lt;a href="https://requests.readthedocs.io/en/latest/user/advanced/#timeouts" rel="noopener noreferrer"&gt;guide&lt;/a&gt; says the number is the seconds the client waits &lt;em&gt;between&lt;/em&gt; bytes sent by the server, and adds in parentheses that "in 99.9% of cases, this is the time before the server sends the first byte". My server is precisely that 0.1%. As long as the three-second gaps stay under five seconds, the counter never fills. The same page also states separately that these values are not wall-clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;urllib&lt;/code&gt;&lt;/strong&gt; and the &lt;code&gt;socket&lt;/code&gt; layer beneath it are even more bare: the &lt;code&gt;settimeout&lt;/code&gt; docs define the value for "blocking socket operations", meaning every &lt;code&gt;recv()&lt;/code&gt; gets its own five seconds. There is one interesting exception — &lt;code&gt;sendall()&lt;/code&gt; has measured total duration since Python 3.5, and the docs say so explicitly. So in the very same library the sending side counts totals while the receiving side counts gaps. Two different clocks inside one language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node's &lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt; is built on undici, and both relevant undici options default to 300 seconds: &lt;code&gt;headersTimeout&lt;/code&gt; and &lt;code&gt;bodyTimeout&lt;/code&gt;. The body side documents itself: it "monitors the time between consecutive body chunks". Per chunk, in other words. A server dripping once every three seconds can keep that alive indefinitely. Node's own &lt;code&gt;fetch&lt;/code&gt; documentation makes no mention of a default total limit; on the &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; side there is a single sentence: it returns a signal that will be aborted after the given milliseconds. In my measurements that signal was the one piece that actually worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; is the only language here that names the header and body phases separately and gives each its own knob — &lt;code&gt;requests&lt;/code&gt; documents a connect/read split too, but both of its halves count gaps. The comment on &lt;code&gt;Transport.ResponseHeaderTimeout&lt;/code&gt; states that the time "does not include the time to read the response body". Because my server sends headers instantly, that setting never engages; the body trickles for 24 seconds and nobody objects. I read that comment not from the remote docs but from &lt;code&gt;go doc net/http.Transport&lt;/code&gt; on my own machine — the same source as the Go version that ran the measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ruby's&lt;/strong&gt; &lt;code&gt;read_timeout&lt;/code&gt; is again per block read. But the 10 seconds it produced in the silent scenario gave away something else: &lt;a href="https://docs.ruby-lang.org/en/master/Net/HTTP.html" rel="noopener noreferrer"&gt;&lt;code&gt;Net::HTTP&lt;/code&gt;&lt;/a&gt; has a default &lt;code&gt;max_retries&lt;/code&gt; of &lt;strong&gt;1&lt;/strong&gt;, so it retries an idempotent request once when it hits &lt;code&gt;Net::ReadTimeout&lt;/code&gt;. The server log holds two separate &lt;code&gt;GET /silent&lt;/code&gt; lines, five seconds apart. Running the same script with &lt;code&gt;max_retries = 0&lt;/code&gt; brought the time down to 5.01 seconds. So where I wrote five seconds, the library quietly spent ten — and that is the smallest, least charming version of the multiplication effect I described in my &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/retry-firtinasi-timeout-budget-ve-latency-amplification/" rel="noopener noreferrer"&gt;retry budget piece&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java&lt;/strong&gt; is the interesting case, because here the measurement fills a gap in the documentation. The &lt;a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html" rel="noopener noreferrer"&gt;&lt;code&gt;HttpRequest.Builder.timeout()&lt;/code&gt;&lt;/a&gt; guide says an &lt;code&gt;HttpTimeoutException&lt;/code&gt; is thrown if the response is not "received" within the given time, and adds a very clear sentence: not setting a timeout is the same as setting an infinite duration, i.e. blocking forever. Not a word about whether the body is covered. The measurement answers it: it is not. A request made with &lt;code&gt;BodyHandlers.ofString()&lt;/code&gt; took 24.19 seconds and succeeded, despite its five-second timeout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsicmVxdWVzdCBzZW50Il0gLS0-IEJ7ImZpcnN0IGJ5dGUgYXJyaXZlZCJ9CiAgQiAtLSAibm8iIC0tPiBDWyJnYXAgY291bnRlciBmaWxsczxici8-ZXJyb3IgYXQgc2Vjb25kIDUiXQogIEIgLS0gInllcyIgLS0-IERbImdhcCBjb3VudGVyIFJFU0VUUyJdCiAgRCAtLT4gRXsiYW5vdGhlciBieXRlIGFycml2ZWQifQogIEUgLS0gInllcywgMyBzIGxhdGVyIiAtLT4gRAogIEUgLS0gIm5vLCBmb3IgNSBzIiAtLT4gQwogIEQgLS0-IEZ7ImlzIHRoZXJlIGEgdG90YWwgZGVhZGxpbmUifQogIEYgLS0gIm5vIiAtLT4gR1sid2FpdCB3aGlsZSBieXRlcyBmbG93PGJyLz5jdXJsIGNvbm5lY3QtdGltZW91dCw8YnIvPnJlcXVlc3RzLCB1cmxsaWIsIHVuZGljaSw8YnIvPlJlc3BvbnNlSGVhZGVyVGltZW91dCw8YnIvPlJ1YnksIEphdmEiXQogIEYgLS0gInllcyIgLS0-IEhbImN1dCBhdCBzZWNvbmQgNTxici8-Y3VybCBtYXgtdGltZSw8YnIvPkdvIENsaWVudCBUaW1lb3V0LDxici8-QWJvcnRTaWduYWwiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsicmVxdWVzdCBzZW50Il0gLS0-IEJ7ImZpcnN0IGJ5dGUgYXJyaXZlZCJ9CiAgQiAtLSAibm8iIC0tPiBDWyJnYXAgY291bnRlciBmaWxsczxici8-ZXJyb3IgYXQgc2Vjb25kIDUiXQogIEIgLS0gInllcyIgLS0-IERbImdhcCBjb3VudGVyIFJFU0VUUyJdCiAgRCAtLT4gRXsiYW5vdGhlciBieXRlIGFycml2ZWQifQogIEUgLS0gInllcywgMyBzIGxhdGVyIiAtLT4gRAogIEUgLS0gIm5vLCBmb3IgNSBzIiAtLT4gQwogIEQgLS0-IEZ7ImlzIHRoZXJlIGEgdG90YWwgZGVhZGxpbmUifQogIEYgLS0gIm5vIiAtLT4gR1sid2FpdCB3aGlsZSBieXRlcyBmbG93PGJyLz5jdXJsIGNvbm5lY3QtdGltZW91dCw8YnIvPnJlcXVlc3RzLCB1cmxsaWIsIHVuZGljaSw8YnIvPlJlc3BvbnNlSGVhZGVyVGltZW91dCw8YnIvPlJ1YnksIEphdmEiXQogIEYgLS0gInllcyIgLS0-IEhbImN1dCBhdCBzZWNvbmQgNTxici8-Y3VybCBtYXgtdGltZSw8YnIvPkdvIENsaWVudCBUaW1lb3V0LDxici8-QWJvcnRTaWduYWwiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="641" height="1250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Third scene: the dripping byte is in the headers
&lt;/h2&gt;

&lt;p&gt;Dripping the body is half the story. I played the same game with headers: a minimal 57-byte response header sent one byte per second, stretching header completion across 57 seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client and setting&lt;/th&gt;
&lt;th&gt;while headers drip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl --max-time 5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.00 s · exit 28 · no status code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go &lt;code&gt;Transport{ResponseHeaderTimeout: 5s}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.01 s · "timeout awaiting response headers"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java &lt;code&gt;HttpClient&lt;/code&gt; + &lt;code&gt;.timeout(5s)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5.13 s · HttpTimeoutException&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;timeout=5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;57.35 s · 200 · 8 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node &lt;code&gt;fetch&lt;/code&gt; (no options)&lt;/td&gt;
&lt;td&gt;57.39 s · 200 · 8 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here Java's picture is complete: &lt;code&gt;.timeout(5s)&lt;/code&gt; bounds the header phase as a &lt;strong&gt;total&lt;/strong&gt; and does not bound the body at all. When the docs say "if the response is not received", they really do mean the headers; the body is not their concern. Go, meanwhile, named the two phases separately and gave each its own knob — the only language in the table that did. &lt;code&gt;requests&lt;/code&gt; counts gaps here too: because the one-second gaps stayed under five, it never woke up for 57 seconds.&lt;/p&gt;

&lt;p&gt;On the Node side this scene &lt;em&gt;does not discriminate&lt;/em&gt;: undici's &lt;code&gt;headersTimeout&lt;/code&gt; defaults to 300 seconds and my header drip lasted 57, so the measurement only shows that it stayed under 300. What the measurement cannot say, the documentation does: &lt;code&gt;headersTimeout&lt;/code&gt; is the time the parser waits to receive the &lt;strong&gt;complete&lt;/strong&gt; HTTP headers. Its sibling on the body side counts the gap between chunks; this one is a total for the header phase. Two settings side by side in one library, two different clocks — the same split we have been following all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four that fired, and curl's small trap
&lt;/h2&gt;

&lt;p&gt;Three of the four that cut the transfer make the same statement: they apply the number to the &lt;strong&gt;whole&lt;/strong&gt; request. The fourth (&lt;code&gt;--speed-time&lt;/code&gt; / &lt;code&gt;--speed-limit&lt;/code&gt;) is a different mechanism altogether — not a deadline but a rate threshold; I take it apart in the next section.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl --max-time&lt;/code&gt; says in its manual that it sets the maximum time you allow each transfer to take. Go's &lt;code&gt;Client.Timeout&lt;/code&gt; comment is even clearer: the timeout includes connection time, any redirects, and reading the response body; the timer also keeps running after &lt;code&gt;Get&lt;/code&gt;/&lt;code&gt;Do&lt;/code&gt; return and will interrupt reading &lt;code&gt;Response.Body&lt;/code&gt;. That is exactly what happened in the measurement: the error arrived while the body was being read. &lt;code&gt;AbortSignal.timeout(5000)&lt;/code&gt; belongs to the same class — the signal cancels the request itself and does not care about the rhythm of the bytes.&lt;/p&gt;

&lt;p&gt;There is a small but harmful detail here. Yes, &lt;code&gt;curl --max-time 5&lt;/code&gt; cuts the dripping transfer at the fifth second; but the &lt;code&gt;%{http_code}&lt;/code&gt; output still reads &lt;strong&gt;200&lt;/strong&gt;, while &lt;code&gt;%{size_download}&lt;/code&gt; is only &lt;strong&gt;1 byte&lt;/strong&gt;. If the exit code were not 28, my script would have counted that transfer as a success. Which means every shell line that decides success by looking at the status code will take a half-downloaded body for a complete one. The right question is not "what HTTP code?" but "what exit code, and did I get as many bytes as I expected?".&lt;/p&gt;

&lt;p&gt;And there is an open-ended piece of arithmetic. One byte every three seconds means 1024 × 3 = 3072 seconds for a 1 KiB body, roughly 51 minutes. I did not measure that; I &lt;strong&gt;derived&lt;/strong&gt; it from the rate in the table. But the size of the number tells you the point: as long as the gap counter never fills, the upper bound on total time depends on the server's patience, not on your setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stall detector's threshold has to sit above the drip rate
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; also has a slow-transfer detector: &lt;code&gt;--speed-limit&lt;/code&gt; and &lt;code&gt;--speed-time&lt;/code&gt;. Per the manual, a transfer is aborted if it stays below &lt;code&gt;speed-limit&lt;/code&gt; for a &lt;code&gt;speed-time&lt;/code&gt; period. The defaults are defined in terms of each other: give only &lt;code&gt;--speed-time&lt;/code&gt; and the limit is 1 byte per second; give only &lt;code&gt;--speed-limit&lt;/code&gt; and the window is 30 seconds.&lt;/p&gt;

&lt;p&gt;To show that this is a threshold, I sped the server up: 40 bytes, half a second apart, i.e. roughly 2 bytes per second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--speed-time 5 --speed-limit 1  -&amp;gt; 20.14 s · 200 · 40 B · exit 0   (NO abort)
--speed-time 5 --speed-limit 5  -&amp;gt;  5.02 s · exit 28 ·  9 B        (abort)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real rate in the first line is 40 ÷ 20.14 = 1.99 bytes per second. Since the limit was 1, the transfer counted as "fast enough" and flowed to the end. (&lt;code&gt;curl&lt;/code&gt;'s own &lt;code&gt;%{speed_download}&lt;/code&gt; field rounds to an integer and prints &lt;code&gt;1B/s&lt;/code&gt; here; I computed the ratio from the raw numbers myself.) Raise the limit to 5 and the same server gets cut at the fifth second.&lt;/p&gt;

&lt;p&gt;The practical takeaway: this detector is not a free safety net, it is a choice of threshold. Unless you write a number above the lowest rate you consider reasonable, the dripping side will sit just above your threshold and stall you as long as it likes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting the total limit by hand: the patch that cuts at six seconds
&lt;/h2&gt;

&lt;p&gt;Go and &lt;code&gt;curl&lt;/code&gt; hand you a deadline out of the box. &lt;code&gt;requests&lt;/code&gt; does not, so the deadline has to be hand-rolled: read the body as a stream and check the clock on every chunk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# TOTAL = 5.0&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"total deadline exceeded"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three runs gave &lt;strong&gt;6.02 · 6.01 · 6.02 seconds&lt;/strong&gt;. Not five, six. The reason is a pleasant detail: the loop only wakes when a chunk arrives, and the bytes arrive at the third, sixth and ninth second. So a deadline at the fifth second is only noticed at the sixth.&lt;/p&gt;

&lt;p&gt;That overshoot looks harmless but it scales: the excess equals the gap between bytes. Six seconds on a three-second drip; two minutes on a sixty-second drip. A deadline held inside the read loop is not an upper bound but an &lt;em&gt;approximate&lt;/em&gt; upper bound. If you want a hard limit, it has to live outside the loop: either shrink the socket's own timeout to the remaining budget before every &lt;code&gt;recv&lt;/code&gt;, or set a watchdog that kills the process from the outside. A real deadline is about whose hand the clock is in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same question on the server side: my own nginx
&lt;/h2&gt;

&lt;p&gt;This article puts the client on the bench, but the question is symmetric: how long will my server hold a slow counterparty?&lt;/p&gt;

&lt;p&gt;In this blog's nginx configuration, &lt;code&gt;deploy/nginx-mustafaerbay-final.conf&lt;/code&gt; carries &lt;code&gt;proxy_read_timeout 60s;&lt;/code&gt; in two places — which is nginx's default anyway. The documentation warns in the same entry: the timeout is set only between two successive read operations, not for the transmission of the whole response. So as long as my backend sends one byte every 59 seconds, the nginx worker waits there. The same sentence and the same 60-second default apply to &lt;code&gt;proxy_send_timeout&lt;/code&gt;, &lt;code&gt;send_timeout&lt;/code&gt; and &lt;code&gt;client_body_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The exception sits one line away and it is valuable: &lt;code&gt;client_header_timeout&lt;/code&gt; terminates the request with a 408 if the client does not transmit the &lt;strong&gt;entire&lt;/strong&gt; request header within the given time. So nginx holds one example of each kind — one counts gaps, one counts the total. Saying "my timeouts are configured" without knowing which directive is which means putting the configured and the unconfigured into the same bag.&lt;/p&gt;

&lt;p&gt;One more thing: &lt;code&gt;client_header_timeout&lt;/code&gt; alone is not protection against a slow client. A thousand connections that each finish their header in 59 seconds stay inside the rule and can still fill the workers; the answer there is not a timeout but a cap on concurrent connections and request rate. My own file has one of the two: the &lt;code&gt;/api/&lt;/code&gt; location carries &lt;code&gt;limit_req zone=mb_api burst=20 nodelay&lt;/code&gt; (10 requests per second) — but only there, and there is no &lt;code&gt;limit_conn&lt;/code&gt; at all. So the root location that serves the pages is unprotected against the number of connections held open.&lt;/p&gt;

&lt;p&gt;In the first version of this article I was going to close this section with "and here is how I fixed it". I did not fix it: 60 seconds is a reasonable ceiling for this blog, and the real gap is a layer that adds a total limit next to the gap-counting directive. I will not change it before measuring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting in my own code: 75 call sites, 52 with no limit
&lt;/h2&gt;

&lt;p&gt;Measuring the outside world and skipping my own repository would have been the easy way out. I wrote a small script that scans the &lt;code&gt;fetch(&lt;/code&gt; calls in this blog's code: it extracts each call's balanced parentheses and checks whether &lt;code&gt;signal&lt;/code&gt; appears inside.&lt;/p&gt;

&lt;p&gt;The result: &lt;strong&gt;75 call sites, 23 with a signal, 52 without.&lt;/strong&gt; The distribution is instructive too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scripts/&lt;/code&gt; (the CI side, the code that talks to providers): 49 calls, 21 with a limit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/&lt;/code&gt; (the app and the parts that run in the browser): 26 calls, only 2 with a limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two groups do not carry the same risk. The ones under &lt;code&gt;scripts/&lt;/code&gt; go out to external providers and an agent waits on them during a CI run — one that hangs burns minutes. Most of the ones under &lt;code&gt;src/&lt;/code&gt; run in the browser against my own API; there, no deadline shows up as a spinner that never stops and the visitor reloads the page. The headline 52 sums both; the real bleeding is the 28 calls in the first group.&lt;/p&gt;

&lt;p&gt;I should state the script's own limitation: it only looks for &lt;code&gt;signal&lt;/code&gt; in the call text. If the codebase had a shared wrapper that added a deadline, this count would be wrong. There is none — every &lt;code&gt;AbortSignal.timeout&lt;/code&gt; call in the repo is written directly into the &lt;code&gt;fetch&lt;/code&gt; options, and there is no shared &lt;code&gt;fetchWithTimeout&lt;/code&gt; helper. So 52 really does mean "this call has no deadline of its own".&lt;/p&gt;

&lt;p&gt;Among the ones without a signal is this blog's own social-sharing step: the caption generation in &lt;code&gt;scripts/social-post.ts&lt;/code&gt; calls &lt;code&gt;fetch&lt;/code&gt; with no deadline for Gemini (line 467) and for the OpenAI-compatible providers (line 518). Elsewhere in the same file there are &lt;code&gt;AbortSignal.timeout(5000)&lt;/code&gt; and &lt;code&gt;AbortSignal.timeout(15000)&lt;/code&gt; calls — so the habit exists, the coverage does not. On top of that, &lt;code&gt;.github/workflows/social-post.yml&lt;/code&gt; has no &lt;code&gt;timeout-minutes&lt;/code&gt; line at all; for comparison, &lt;code&gt;deploy-only.yml&lt;/code&gt; carries three separate limits of 50, 40 and 5 minutes. A dripping provider could keep that run busy, unnoticed, up to GitHub's own job ceiling.&lt;/p&gt;

&lt;p&gt;This stays as open work, and I am writing it here so it does not get lost: the two calls on the caption path will get a deadline, and the workflow an outer limit. I did the counting for this article; I did not ship the fix with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting gaps is not always a defect
&lt;/h2&gt;

&lt;p&gt;So far I have treated gap-counting settings as a defect; to be fair, sometimes they are exactly what you want. Put a hard total deadline on a client that listens to a server-sent event stream, or collects a language model's answer token by token, and you will cut off a long but healthy stream with your own hand; the right question there is not "how many seconds in total" but "how much silence between two chunks is acceptable". So &lt;code&gt;bodyTimeout&lt;/code&gt; counting between chunks is not a flaw, it is the behaviour that job was designed for. The rule: if you expect a response with a &lt;strong&gt;known end&lt;/strong&gt; (JSON, a file, an API call), set a total deadline; if you are listening to an &lt;strong&gt;open-ended&lt;/strong&gt; stream, tighten the gap limit and bound the total from somewhere else.&lt;/p&gt;

&lt;p&gt;Since I did not measure it, let me be explicit about the alarm side too: the signal that catches these hangs is not an error counter but a &lt;strong&gt;duration distribution&lt;/strong&gt;. A request that takes 24 seconds and returns 200 shows up nowhere on an error graph; to see it you need a threshold on the request-duration histogram and on the bytes-per-second rate. My own pipeline has no such threshold; this measurement showed me that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist: six questions before you accept a client
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does this setting count a gap or a total — and which phase?&lt;/strong&gt; If the docs say "between", "successive" or "per chunk", it counts gaps. The phase is a separate question: Java's &lt;code&gt;.timeout()&lt;/code&gt; treats the header phase as a total and does not count the body at all. If there is no single total deadline, the client has no upper bound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the total limit come from?&lt;/strong&gt; &lt;code&gt;Client.Timeout&lt;/code&gt; in Go, &lt;code&gt;AbortSignal.timeout&lt;/code&gt; in Node, &lt;code&gt;--max-time&lt;/code&gt; in curl, a hand-built deadline in Python. If the library does not give you one, add it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the stall threshold above the lowest rate you expect?&lt;/strong&gt; You will not catch a dripper with &lt;code&gt;--speed-limit 1&lt;/code&gt;; its rate may well be 2 bytes per second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do retries multiply the budget by?&lt;/strong&gt; Ruby's default &lt;code&gt;max_retries = 1&lt;/code&gt; turned five seconds into ten. If you use &lt;code&gt;curl --retry&lt;/code&gt;, the &lt;code&gt;--max-time&lt;/code&gt; counter restarts on every attempt; &lt;code&gt;--retry-max-time&lt;/code&gt; is what caps the total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What are you measuring success by?&lt;/strong&gt; The status code can read 200 while the body is incomplete. Check the exit code and the expected byte count too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which directive counts gaps on the server side?&lt;/strong&gt; In nginx, &lt;code&gt;proxy_read_timeout&lt;/code&gt; counts gaps and &lt;code&gt;client_header_timeout&lt;/code&gt; counts the total. Trusting both equally is a mistake.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A timeout says who is allowed to be slow, and for how long
&lt;/h2&gt;

&lt;p&gt;What I took away from the measurement is not a setting value but a shape of question. When we ask "how many seconds is the timeout?", we are conflating two separate things: how long the other side may stay &lt;em&gt;completely quiet&lt;/em&gt;, and how long the &lt;em&gt;whole job&lt;/em&gt; may take. Most libraries answer the first and leave the second to us — and they say so honestly in their documentation.&lt;/p&gt;

&lt;p&gt;The gap between the two is invisible under normal conditions. The network either works or breaks; when it breaks, the counter fills, the error arrives, everyone feels safe. The gap only opens when the other side is &lt;em&gt;slow but alive&lt;/em&gt;: a swollen queue, or a deliberate dripper. A seventy-line script sending one byte every three seconds held seven setups across five languages and &lt;code&gt;curl&lt;/code&gt; for twenty-four seconds without sounding a single alarm. The same script could hold a CI run for hours — or take the slots in a connection pool one by one and close them to healthy requests.&lt;/p&gt;

&lt;p&gt;The question to ask of your own setup is this: in this request, as long as the other side keeps sending me one byte per second, who decides when the job is over? If the answer is "nobody", what is missing is not a number of seconds — it is the clock itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html" rel="noopener noreferrer"&gt;nginx — ngx_http_proxy_module (proxy_read_timeout)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html" rel="noopener noreferrer"&gt;nginx — ngx_http_core_module (client_header_timeout, send_timeout)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pkg.go.dev/net/http" rel="noopener noreferrer"&gt;Go — net/http (Client.Timeout, Transport.ResponseHeaderTimeout)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3/library/socket.html" rel="noopener noreferrer"&gt;Python — socket (settimeout, timeout mode)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/nodejs/undici/blob/main/docs/docs/api/Client.md" rel="noopener noreferrer"&gt;undici — Client API (headersTimeout, bodyTimeout)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/curl/curl/blob/master/docs/cmdline-opts/max-time.md" rel="noopener noreferrer"&gt;curl — the --max-time option&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org/api/globals.html" rel="noopener noreferrer"&gt;Node.js — globals (fetch, AbortSignal.timeout)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>network</category>
      <category>olcum</category>
      <category>software</category>
      <category>reliability</category>
    </item>
    <item>
      <title>pax Recovered the File tar Swallowed from the Same Archive</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Wed, 11 Nov 2026 15:06:32 +0000</pubDate>
      <link>https://dev.to/merbayerp/pax-recovered-the-file-tar-swallowed-from-the-same-archive-14k4</link>
      <guid>https://dev.to/merbayerp/pax-recovered-the-file-tar-swallowed-from-the-same-archive-14k4</guid>
      <description>&lt;p&gt;In &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/ayni-commiti-iki-kez-paketledim-200-bayt-tutmadi/" rel="noopener noreferrer"&gt;the post where the same commit got packed twice&lt;/a&gt;,&lt;br&gt;
published on 3 November, something turned up as an aside: macOS &lt;code&gt;tar&lt;/code&gt; writes&lt;br&gt;
members into the archive that I never put there, and then doesn't list them&lt;br&gt;
back to me. The direction there was outbound — the Mac packs, the other side&lt;br&gt;
finds extra files.&lt;/p&gt;

&lt;p&gt;This post is the &lt;strong&gt;inbound&lt;/strong&gt; face of the same mechanism. The question: if an&lt;br&gt;
archive arriving from the other side holds a &lt;strong&gt;real&lt;/strong&gt; file whose name begins&lt;br&gt;
with &lt;code&gt;._&lt;/code&gt;, what does macOS do?&lt;/p&gt;

&lt;p&gt;I tried six different modes. In all six the file never reached disk; in four&lt;br&gt;
&lt;code&gt;tar&lt;/code&gt; errored and exited 1, in two it said nothing at all and exited 0. And&lt;br&gt;
&lt;code&gt;pax&lt;/code&gt;, from the same machine's &lt;code&gt;/bin&lt;/code&gt;, pulled the same file out of the same&lt;br&gt;
archive with its contents intact.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Why would such a file even exist"
&lt;/h2&gt;

&lt;p&gt;That's the first objection, and it's a fair one. But the &lt;code&gt;._&lt;/code&gt; prefix is special&lt;br&gt;
only to macOS; on Linux and BSD it's an entirely ordinary name. An editor&lt;br&gt;
backup, a tool's output, a deliberately hidden helper file, or a twin that once&lt;br&gt;
came from a Mac and settled in on the other side — any of them can start with&lt;br&gt;
&lt;code&gt;._&lt;/code&gt;, and to the other side they are simply data.&lt;/p&gt;

&lt;p&gt;So the lab lives on Linux. Two files: one ordinary, one a real text file whose&lt;br&gt;
name starts with &lt;code&gt;._&lt;/code&gt;. (The Turkish line inside it, &lt;code&gt;BU BIR DUZ DOSYA&lt;/code&gt;, means&lt;br&gt;
"this is a plain file" — it matters later, because it is plainly not metadata.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run --rm -v &lt;span class="s2"&gt;"$PWD"&lt;/span&gt;:/ark alpine:3.21 sh -c &lt;span class="s1"&gt;'
  mkdir -p /ark/tersyon/ic &amp;amp;&amp;amp; cd /ark/tersyon
  echo gercek &amp;gt; ic/veri.txt
  printf "BU BIR DUZ DOSYA\n" &amp;gt; ic/._veri.txt
  tar czf /ark/tersyon.tgz ic'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are in the archive, and on Linux both land on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm -v "$PWD":/ark:ro alpine:3.21 tar tzf /ark/tersyon.tgz
ic/
ic/._veri.txt
ic/veri.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The environment: macOS 26.6.2, &lt;code&gt;bsdtar 3.5.3 - libarchive 3.7.4&lt;/code&gt;, and on the&lt;br&gt;
other end &lt;code&gt;busybox tar 1.37.0&lt;/code&gt; inside &lt;code&gt;alpine:3.21&lt;/code&gt; (Docker 27.4.0). I write the&lt;br&gt;
versions down because this whole post hangs on the question "which tool".&lt;/p&gt;
&lt;h2&gt;
  
  
  The listing is already short
&lt;/h2&gt;

&lt;p&gt;Before extracting anything I wanted to list the archive. Two tools, two&lt;br&gt;
answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tar tzf tersyon.tgz        $ pax -zf tersyon.tgz
ic/                          ic
ic/veri.txt                  ic/._veri.txt
    → 2 members              ic/veri.txt
                                 → 3 members
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This archive wasn't made on a Mac; it was packed on Linux, with nothing called&lt;br&gt;
AppleDouble anywhere in it. &lt;code&gt;tar&lt;/code&gt; drops &lt;code&gt;._veri.txt&lt;/code&gt; from the listing anyway.&lt;br&gt;
The loss doesn't begin at extraction — it begins while you're still looking at&lt;br&gt;
the table of contents: the first command you ask about the archive's contents&lt;br&gt;
gives you an incomplete answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Six modes, two of them silent
&lt;/h2&gt;

&lt;p&gt;I opened the archive on macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tar xzf tersyon.tgz -C chk
ic/veri.txt: Failed to restore metadata: File exists
tar: Error exit delayed from previous errors.
$ echo $?
1
$ find chk -type f
chk/ic/veri.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;veri.txt&lt;/code&gt; is there, &lt;code&gt;._veri.txt&lt;/code&gt; is not. &lt;code&gt;tar&lt;/code&gt; didn't see it as a file; it&lt;br&gt;
took it for its neighbour's metadata and tried to apply it. I tried every flag&lt;br&gt;
I could think of:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th&gt;exit code&lt;/th&gt;
&lt;th&gt;files written&lt;/th&gt;
&lt;th&gt;message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Failed to restore metadata&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--mac-metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;same message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-xattrs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;same message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;same message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-mac-metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-same-permissions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six modes, one outcome: in none of them does the file reach disk. I could not&lt;br&gt;
find a flag that persuades &lt;code&gt;tar&lt;/code&gt; to write that member out as a plain file.&lt;/p&gt;

&lt;p&gt;The last two rows are the ones that bother me. With those flags the command&lt;br&gt;
finishes successfully, you get not a single warning, and the file still isn't&lt;br&gt;
there. The modes that error are being honest; the modes that report success&lt;br&gt;
make the loss invisible. In a script running under &lt;code&gt;set -e&lt;/code&gt;, the first four&lt;br&gt;
rows stop you and the last two don't.&lt;/p&gt;

&lt;p&gt;A small addendum: the &lt;code&gt;tar(1)&lt;/code&gt; man page on this same machine says&lt;br&gt;
&lt;code&gt;--no-mac-metadata&lt;/code&gt; is &lt;em&gt;"the default behavior if tar is run as non-root in x&lt;br&gt;
mode"&lt;/em&gt;. I ran the measurement as uid 501, and the default run tried to restore&lt;br&gt;
metadata and failed — it did not behave like the fifth row. That sentence&lt;br&gt;
doesn't hold for the version I have.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision is made on the name, not the contents
&lt;/h2&gt;

&lt;p&gt;What's inside &lt;code&gt;._veri.txt&lt;/code&gt;? Plain text. The magic number of the AppleDouble&lt;br&gt;
format — &lt;code&gt;0x00051607&lt;/code&gt;, defined by RFC 1740 — is nowhere in it. &lt;code&gt;tar&lt;/code&gt; took it&lt;br&gt;
for metadata anyway. It decides not by looking at the contents but at &lt;strong&gt;the&lt;br&gt;
first two characters of the name&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The prefix itself is one line in Apple's &lt;code&gt;copyfile&lt;/code&gt; source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define ATTR_FILE_PREFIX   "._"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I isolated the name-based rule with a second experiment: this time I put a lone&lt;br&gt;
&lt;code&gt;._abc.txt&lt;/code&gt; into the archive, with no sibling (its contents a 13-byte line).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tar xzf yalniz.tgz
ic/._abc.txt: Truncated tar archive: Unknown error: -1
tar: Error exit delayed from previous errors.
$ ls -l ic/._abc.txt
-rw-r--r--  1 mustafaerbay  wheel  13 Nov 11 17:19 ic/._abc.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is on disk, with its name and its size. But look inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ xxd ic/._abc.txt
00000000: 0000 0000 0000 0000 0000 0000 00         .............
$ tr -d '\000' &amp;lt; ic/._abc.txt | wc -c
       0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All thirteen bytes are zero. &lt;code&gt;tar&lt;/code&gt; &lt;strong&gt;tried to parse&lt;/strong&gt; the file as AppleDouble,&lt;br&gt;
decided the contents were a truncated archive, gave up halfway, and left behind&lt;br&gt;
a shell with the right size and nothing in it. That's worse than the first&lt;br&gt;
experiment: there the loss was at least visible in a file count, here both the&lt;br&gt;
file count and the size look correct.&lt;/p&gt;

&lt;p&gt;Two experiments, two outcomes, one rule: if the name starts with &lt;code&gt;._&lt;/code&gt;, nobody&lt;br&gt;
looks inside.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW21lbWJlciBuYW1lZCAuX3ZlcmkudHh0XSAtLT4gQntkb2VzIHRoZSBuYW1lIHN0YXJ0IHdpdGggLl99CiAgICBCIC0tIG5vIC0tPiBDW3dyaXR0ZW4gYXMgYSBmaWxlXQogICAgQiAtLSB5ZXMgLS0-IER7aXMgdmVyaS50eHQgbmV4dCB0byBpdH0KICAgIEQgLS0geWVzIC0tPiBFW3RyZWF0ZWQgYXMgbWV0YWRhdGE8YnIvPmZpbGUgTk9UIHdyaXR0ZW5dCiAgICBEIC0tIG5vIC0tPiBGW3BhcnNlZCBhcyBBcHBsZURvdWJsZTxici8-ZXJyb3IsIGZpbGUgbGVmdCBmdWxsIG9mIE5VTHNd%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW21lbWJlciBuYW1lZCAuX3ZlcmkudHh0XSAtLT4gQntkb2VzIHRoZSBuYW1lIHN0YXJ0IHdpdGggLl99CiAgICBCIC0tIG5vIC0tPiBDW3dyaXR0ZW4gYXMgYSBmaWxlXQogICAgQiAtLSB5ZXMgLS0-IER7aXMgdmVyaS50eHQgbmV4dCB0byBpdH0KICAgIEQgLS0geWVzIC0tPiBFW3RyZWF0ZWQgYXMgbWV0YWRhdGE8YnIvPmZpbGUgTk9UIHdyaXR0ZW5dCiAgICBEIC0tIG5vIC0tPiBGW3BhcnNlZCBhcyBBcHBsZURvdWJsZTxici8-ZXJyb3IsIGZpbGUgbGVmdCBmdWxsIG9mIE5VTHNd%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="479" height="852"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Three tools in the same box: two lose it, one recovers it
&lt;/h2&gt;

&lt;p&gt;"Open it on Linux" is an easy thing to say, but the framing is wrong. The&lt;br&gt;
problem isn't the operating system, it's the tool's code — and I can show that&lt;br&gt;
inside macOS alone. I had three built-in tools extract the same archive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tool&lt;/th&gt;
&lt;th&gt;written to disk&lt;/th&gt;
&lt;th&gt;&lt;code&gt;._veri.txt&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tar xzf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 file&lt;/td&gt;
&lt;td&gt;missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cpio -id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 file&lt;/td&gt;
&lt;td&gt;missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pax -rzf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 files&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;with its contents&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is what &lt;code&gt;pax&lt;/code&gt; prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cd paxcik &amp;amp;&amp;amp; pax -rzf ../tersyon.tgz
pax: Unable to set metadata on ic/veri.txt &amp;lt;File exists&amp;gt;
$ find . -type f
./ic/._veri.txt
./ic/veri.txt
$ cat ic/._veri.txt
BU BIR DUZ DOSYA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same machine, same archive, a message from the same "File exists" family — but&lt;br&gt;
the file is on disk with its contents intact. &lt;code&gt;pax&lt;/code&gt; tries to apply the metadata&lt;br&gt;
and, when it can't, writes the member out as a plain file. &lt;code&gt;tar&lt;/code&gt; and &lt;code&gt;cpio&lt;/code&gt;&lt;br&gt;
give up. All three ship in macOS's &lt;code&gt;/bin&lt;/code&gt; or &lt;code&gt;/usr/bin&lt;/code&gt;; no Docker, no &lt;code&gt;brew&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The man page contradicts itself
&lt;/h2&gt;

&lt;p&gt;While hunting for the flags in this post I tripped over the local&lt;br&gt;
documentation twice.&lt;/p&gt;

&lt;p&gt;First: the same &lt;code&gt;tar(1)&lt;/code&gt; page introduces the same pair of flags with two&lt;br&gt;
different mode lists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ man tar | col -b | grep -A1 -- '^     --\(no-\)\?mac-metadata$'
     --mac-metadata
             (c, r, u and x mode only) Mac OS X specific.  Archive or extract
--
     --no-mac-metadata
             (x mode only) Mac OS X specific.  Do not archive or extract ACLs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One says "create, append, update and extract", the other says extract only —&lt;br&gt;
even though they're defined as each other's reverse. I tried which one is&lt;br&gt;
right: &lt;code&gt;tar --no-mac-metadata -czf&lt;/code&gt; works fine in create mode and produces a&lt;br&gt;
twin-free archive. The line upstream libarchive writes for the same flag is&lt;br&gt;
already "(c, r, u and x mode only)". The second entry's mode list is incomplete&lt;br&gt;
in the version I have.&lt;/p&gt;

&lt;p&gt;Second, and more interesting: the &lt;code&gt;COPYFILE_DISABLE&lt;/code&gt; variable. The page's&lt;br&gt;
ENVIRONMENT section has five entries (&lt;code&gt;TAR_READER_OPTIONS&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;TAR_WRITER_OPTIONS&lt;/code&gt;, &lt;code&gt;LANG&lt;/code&gt;, &lt;code&gt;TAPE&lt;/code&gt;, &lt;code&gt;TZ&lt;/code&gt;) and this isn't one of them; it&lt;br&gt;
isn't in the 591-line &lt;code&gt;copyfile(3)&lt;/code&gt; page either, which has no ENVIRONMENT&lt;br&gt;
section at all. The name is defined in &lt;code&gt;copyfile.h&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define COPYFILE_DISABLE_VAR    "COPYFILE_DISABLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the only code in &lt;code&gt;copyfile.c&lt;/code&gt; that reads that name is switched off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if 0&lt;/span&gt;
    &lt;span class="cm"&gt;/* Temporarily disabled */&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;COPYFILE_DISABLE_VAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the library isn't the thing listening to the variable. Instead of guessing&lt;br&gt;
where it is, I asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ strings /usr/bin/tar &lt;span class="o"&gt;|&lt;/span&gt; grep -i &lt;span class="s1"&gt;'copyfile_disable\|disable-copyfile'&lt;/span&gt;
COPYFILE_DISABLE
disable-copyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both strings live inside the &lt;code&gt;tar&lt;/code&gt; binary. It's &lt;code&gt;tar&lt;/code&gt; itself that reads the&lt;br&gt;
variable; the definition in the source only supplies the name, and the check&lt;br&gt;
moved out of the library. One more detail: the check looks at &lt;strong&gt;presence, not&lt;br&gt;
value&lt;/strong&gt; — setting &lt;code&gt;COPYFILE_DISABLE=0&lt;/code&gt; also suppressed the twins, taking the&lt;br&gt;
archive from twelve members down to six. A reader who writes zero to turn it&lt;br&gt;
off gets exactly the opposite.&lt;/p&gt;
&lt;h2&gt;
  
  
  A "clean" archive still carries your attributes
&lt;/h2&gt;

&lt;p&gt;A side measurement, because the common advice stops short here. An archive&lt;br&gt;
built with &lt;code&gt;COPYFILE_DISABLE=1&lt;/code&gt; or &lt;code&gt;--no-mac-metadata&lt;/code&gt; has no &lt;code&gt;._&lt;/code&gt; members —&lt;br&gt;
but that doesn't mean the attributes are gone. I scanned all three archives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ for a in varsayilan kapali nomac; do echo -n "$a: "; gzcat $a.tgz | strings | grep -c 'LIBARCHIVE.xattr'; done
varsayilan: 9
kapali: 9
nomac: 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine headers in each, belonging to three distinct attribute names (six&lt;br&gt;
&lt;code&gt;com.apple.provenance&lt;/code&gt;, two of my own &lt;code&gt;com.ornek.not&lt;/code&gt;, one&lt;br&gt;
&lt;code&gt;com.apple.quarantine&lt;/code&gt;). So the archive I called clean carries the quarantine&lt;br&gt;
tag of a downloaded file to the other side; only the AppleDouble members are&lt;br&gt;
gone.&lt;/p&gt;

&lt;p&gt;Do those headers do anything? I extracted two archives with two tools and&lt;br&gt;
compared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;archive&lt;/th&gt;
&lt;th&gt;extracted by&lt;/th&gt;
&lt;th&gt;did &lt;code&gt;com.ornek.not&lt;/code&gt; come back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default (AppleDouble present)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;default (AppleDouble present)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pax&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--no-mac-metadata&lt;/code&gt; (pax header only)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--no-mac-metadata&lt;/code&gt; (pax header only)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pax&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the macOS side what restores attributes is the AppleDouble member; the&lt;br&gt;
archive format's extended headers (&lt;code&gt;LIBARCHIVE.xattr.*&lt;/code&gt; — these belong to the&lt;br&gt;
pax &lt;em&gt;format&lt;/em&gt;, not to the &lt;code&gt;/bin/pax&lt;/code&gt; tool) travel but are never applied.&lt;/p&gt;

&lt;p&gt;Linux only reads those headers if you ask explicitly. A plain &lt;code&gt;tar -xzf&lt;/code&gt; says&lt;br&gt;
"unknown" to each of them; with &lt;code&gt;--xattrs --xattrs-include='*'&lt;/code&gt; it tries, and&lt;br&gt;
runs into a different wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tar: setxattrat: Cannot set 'com.apple.quarantine' extended attribute for
file 'kaynak/indirilen.txt': Operation not supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first reading was "this filesystem doesn't support extended attributes".&lt;br&gt;
Trying three names on the same file showed otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user.test              → OK
com.apple.quarantine   → Operation not supported
security.foo           → Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filesystem takes attributes fine; what refuses is Linux's namespace rule.&lt;br&gt;
A user attribute's name has to start with &lt;code&gt;user.&lt;/code&gt;, so &lt;code&gt;com.apple.*&lt;/code&gt; can't be&lt;br&gt;
written on any Linux filesystem. The names and values travel to the other side,&lt;br&gt;
but they aren't reborn there as Mac attributes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cleaning up on the receiving side: &lt;code&gt;dot_clean&lt;/code&gt; and its three modes
&lt;/h2&gt;

&lt;p&gt;If you've ended up with a tree full of &lt;code&gt;._&lt;/code&gt; files, macOS has a tool for it and&lt;br&gt;
its man page describes it in one line: &lt;em&gt;"Merge ._&lt;/em&gt; files with corresponding&lt;br&gt;
native files."* I ran all three modes over the same tree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;command&lt;/th&gt;
&lt;th&gt;twin files&lt;/th&gt;
&lt;th&gt;did &lt;code&gt;com.ornek.not&lt;/code&gt; land on the file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dot_clean &amp;lt;dir&amp;gt;&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;deleted&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dot_clean --keep=dotbar &amp;lt;dir&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deleted&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dot_clean -m &amp;lt;dir&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deleted&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No surprise for &lt;code&gt;-m&lt;/code&gt;: the man page says &lt;em&gt;"Always delete dot underbar files"&lt;/em&gt; —&lt;br&gt;
a delete mode. The one to watch is the default. Its verbose output claims it&lt;br&gt;
did the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dot_clean -v n2
Merging directory altdizin
merging ._ic.txt &amp;amp; ic.txt
Using the most recent stored info
Deleting: ._ic.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yet afterwards &lt;code&gt;xattr -l&lt;/code&gt; shows no &lt;code&gt;com.ornek.not&lt;/code&gt; on the file. By the&lt;br&gt;
documentation it should be there: DESCRIPTION says the most recent copy wins&lt;br&gt;
for an attribute that is &lt;strong&gt;also present&lt;/strong&gt; on the native file, and the&lt;br&gt;
&lt;code&gt;--keep=mostrecent&lt;/code&gt; entry says that otherwise the information stored in the&lt;br&gt;
AppleDouble file is used. This attribute wasn't on the native file, so by the&lt;br&gt;
rule it should have landed. With &lt;code&gt;--keep=dotbar&lt;/code&gt; the output changes&lt;br&gt;
(&lt;em&gt;"Replacing ic.txt's EAs with ._ic.txt's EAs"&lt;/em&gt;) and the attribute arrives. The&lt;br&gt;
default mode doesn't do what its own documentation describes; cleaning up and&lt;br&gt;
restoring are not the same mode of the same command.&lt;/p&gt;
&lt;h2&gt;
  
  
  They pass extension-based filters
&lt;/h2&gt;

&lt;p&gt;The sneaky part about the twins isn't their count, it's that their names look&lt;br&gt;
legitimate. A &lt;code&gt;._report.mdx&lt;/code&gt; matches the &lt;code&gt;*.mdx&lt;/code&gt; pattern. I tested this on my&lt;br&gt;
own publishing pipeline; the script that moves the blog's MDX files into the&lt;br&gt;
database finds them like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"find src/content/blog -name '*.mdx'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern catches the twins — not a guess, a measurement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ find . -name &lt;span class="s1"&gt;'*.mdx'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort
./technology/._gercek.mdx
./technology/gercek.mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a pipeline that moves content through an archive, these files get taken for&lt;br&gt;
posts. Mine takes content from git, so the scenario is theoretical for now —&lt;br&gt;
and the measurement came back clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;$ find ~/IdeaProjects -name &lt;span class="s1"&gt;'._*'&lt;/span&gt; -type f &lt;span class="o"&gt;|&lt;/span&gt; wc -l
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. It scanned in two and a half seconds and found none, because I don't&lt;br&gt;
extract Mac-made archives into my own tree. This mechanism doesn't accumulate&lt;br&gt;
locally; the damage happens exactly at the boundary, where one machine packs&lt;br&gt;
and another unpacks. That's the good half of the bad news: the number of places&lt;br&gt;
to check is small and well defined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist — for the receiving side
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't let &lt;code&gt;tar&lt;/code&gt; do the counting.&lt;/strong&gt; The gap between two listings shows the
loss in one line: if &lt;code&gt;pax -zf a.tgz | wc -l&lt;/code&gt; and &lt;code&gt;tar tzf a.tgz | wc -l&lt;/code&gt;
disagree, the archive holds &lt;code&gt;._&lt;/code&gt; members. In a script, a gate comparing those
two numbers catches the pipeline that loses files quietly under
&lt;code&gt;--no-mac-metadata&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exit code isn't enough.&lt;/strong&gt; &lt;code&gt;--no-mac-metadata&lt;/code&gt; and
&lt;code&gt;--no-same-permissions&lt;/code&gt; return 0 and don't write the file; trusting &lt;code&gt;set -e&lt;/code&gt;
alone hides the loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;pax&lt;/code&gt; to recover.&lt;/strong&gt; If you have an extraction that genuinely needs
members named &lt;code&gt;._something&lt;/code&gt; on disk, use &lt;code&gt;pax -rzf&lt;/code&gt;; &lt;code&gt;tar&lt;/code&gt; has no flag that
does it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you clean up, choose the mode.&lt;/strong&gt; Just getting rid of the twins:
&lt;code&gt;dot_clean &amp;lt;dir&amp;gt;&lt;/code&gt;. Also wanting the attributes inside them back:
&lt;code&gt;dot_clean --keep=dotbar &amp;lt;dir&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember the "clean" archive carries the quarantine tag.&lt;/strong&gt; Even an archive
with no &lt;code&gt;._&lt;/code&gt; members takes attribute names and values along as pax headers;
to really strip them you have to clean the source before packing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch out for extension-based filters.&lt;/strong&gt; &lt;code&gt;._report.mdx&lt;/code&gt; matches &lt;code&gt;*.mdx&lt;/code&gt;
too. Add &lt;code&gt;-not -name '._*'&lt;/code&gt; to the &lt;code&gt;find&lt;/code&gt;/&lt;code&gt;glob&lt;/code&gt; patterns on the receiving
side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;What this measurement left me with isn't the lost file itself; it's that a tool&lt;br&gt;
decided &lt;strong&gt;what a file is by looking at its name&lt;/strong&gt;. Had it looked at the&lt;br&gt;
contents, the plain-text line would have been obvious in the first bytes —&lt;br&gt;
AppleDouble's magic number doesn't match. A name-based rule is fast and simple;&lt;br&gt;
in exchange it's blind to anything that imitates the name.&lt;/p&gt;

&lt;p&gt;And there's this: I wanted to write the sentence "macOS behaves this way", and&lt;br&gt;
I couldn't. Of three tools in the same box, two lose the file and one recovers&lt;br&gt;
it. What decides is not the operating system but that binary. Before I write my&lt;br&gt;
next "platform X does it this way", I'll try asking the second tool on the same&lt;br&gt;
platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc1740.txt" rel="noopener noreferrer"&gt;RFC 1740 — MIME Encapsulation of Macintosh Files (AppleDouble magic number 0x00051607)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/copyfile/blob/main/copyfile.c" rel="noopener noreferrer"&gt;Apple copyfile source — &lt;code&gt;ATTR_FILE_PREFIX "._"&lt;/code&gt; and the disabled &lt;code&gt;COPYFILE_DISABLE&lt;/code&gt; check (copyfile.c)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/copyfile/blob/main/copyfile.h" rel="noopener noreferrer"&gt;Apple copyfile source — the definition of the &lt;code&gt;COPYFILE_DISABLE&lt;/code&gt; name (copyfile.h)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/libarchive/libarchive/blob/master/tar/bsdtar.1" rel="noopener noreferrer"&gt;libarchive bsdtar man page — &lt;code&gt;--mac-metadata&lt;/code&gt; / &lt;code&gt;--no-mac-metadata&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>olcum</category>
      <category>guide</category>
      <category>sistemyonetimi</category>
    </item>
    <item>
      <title>One of the Three Doors I Opened for Security Reports Leads Nowhere</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Wed, 11 Nov 2026 11:21:38 +0000</pubDate>
      <link>https://dev.to/merbayerp/one-of-the-three-doors-i-opened-for-security-reports-leads-nowhere-5m7</link>
      <guid>https://dev.to/merbayerp/one-of-the-three-doors-i-opened-for-security-reports-leads-nowhere-5m7</guid>
      <description>&lt;p&gt;The projects page on this site lists fourteen projects, and seven of them carry a clickable address. So I publish seven open doors: one is this blog, six are applications a stranger can walk into.&lt;/p&gt;

&lt;p&gt;Say somebody landed on one of those addresses and found something — a form accepting more than it should. That person wants to tell me. Where do they write?&lt;/p&gt;

&lt;p&gt;I asked my own infrastructure that question today. Instead of guessing the answer, I went and checked: the same request sent to each of the seven addresses, the files that came back read line by line, and the contact addresses inside them tested at the DNS level for whether they can actually receive mail.&lt;/p&gt;

&lt;p&gt;Three addresses had a door. One of those three has no delivery path behind it. And the standard I had followed says, in its own words, that this case may be worse than having nothing at all.&lt;/p&gt;

&lt;p&gt;The bad part is that the audit did not stop there. I found the same pattern — the sign is up, the thing it points at does not work — in four more layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven addresses, three doors
&lt;/h2&gt;

&lt;p&gt;There is an agreed-upon place on the internet for vulnerability reports: &lt;code&gt;/.well-known/security.txt&lt;/code&gt; at the root of a domain. RFC 9116 defines it. I requested that path from all seven addresses, and here is what came back around midday on 11 November 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Address I publish&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/.well-known/security.txt&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;burncpu.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spamkalkani.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hrmarge.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hesapciyiz.com&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;islistesi.com&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gercekveri.com&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mustafaerbay.com.tr&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three out of seven. The part of this table that bothers me is not the number of 404s but the bottom row. &lt;code&gt;mustafaerbay.com.tr&lt;/code&gt; is the site that lists the other six. If somebody gets curious about any of these projects and tries to reach the person behind them, this is the first place they land, and there is no reporting door here.&lt;/p&gt;

&lt;p&gt;The gap between the three and the four is not ignorance either. I got it right three times. I know how it is done, I have done it on three separate domains, and on four I have not. When those two sentences sit next to each other, the question stops being "how do you do this" and becomes "why did it not spread."&lt;/p&gt;

&lt;h2&gt;
  
  
  What is behind the door
&lt;/h2&gt;

&lt;p&gt;I read all three files that came back. At first glance they were better than I expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# burncpu.com
Contact: mailto:mustafa@mustafaerbay.com.tr
Expires: 2027-07-14T00:00:00Z
Canonical: https://burncpu.com/.well-known/security.txt
Policy: https://github.com/merbay-erp/burncpu/security/policy
Preferred-Languages: tr, en
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RFC 9116 makes two fields mandatory. For &lt;code&gt;Contact&lt;/code&gt; the text is unambiguous: this field must always be present in a &lt;code&gt;security.txt&lt;/code&gt; file. For &lt;code&gt;Expires&lt;/code&gt; the requirement is just as firm — it must always be present and must not appear more than once. The standard also recommends that the value sit less than a year in the future so the file does not go stale.&lt;/p&gt;

&lt;p&gt;All three files have both mandatory fields in place. The expiry dates are 2027-07-14 for burncpu, 2027-05-05 for spamkalkani and 2027-07-11 for hrmarge — roughly six to eight months out from today, so all valid and all inside the one-year window the standard recommends. All three are served over HTTPS as &lt;code&gt;text/plain&lt;/code&gt;, both of which the RFC requires.&lt;/p&gt;

&lt;p&gt;I could have stopped here and written "three projects have a reporting channel." It would have been wrong, because what I had checked was only the two mandatory fields. I had not looked at the fields the standard merely &lt;em&gt;recommends&lt;/em&gt;. When I did, all three turned out to have no &lt;code&gt;Encryption&lt;/code&gt; field — although the RFC recommends encryption when &lt;code&gt;Contact&lt;/code&gt; is an email address. None of the three files is signed either; the standard separately recommends an OpenPGP signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  A valid file, an undeliverable address
&lt;/h2&gt;

&lt;p&gt;The three &lt;code&gt;Contact&lt;/code&gt; values belong to three different domains. For an email address to receive anything, the domain needs an MX record — or, failing that, its address record has to speak SMTP on port 25. That fallback is called implicit MX and it comes from RFC 5321.&lt;/p&gt;

&lt;p&gt;I checked all three in turn:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;Contact&lt;/code&gt; address&lt;/th&gt;
&lt;th&gt;MX record&lt;/th&gt;
&lt;th&gt;Delivery path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:mustafa@mustafaerbay.com.tr"&gt;mustafa@mustafaerbay.com.tr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.sysroot.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:destek@spamkalkani.com"&gt;destek@spamkalkani.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.sysroot.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="mailto:destek@hrmarge.com"&gt;destek@hrmarge.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;not found&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;hrmarge.com&lt;/code&gt; publishes no MX record at all. I asked three separate resolvers (1.1.1.1, 8.8.8.8, 9.9.9.9) and all three came back empty; the domain is not NXDOMAIN, it resolves cleanly on Cloudflare nameservers. So I fell through to implicit MX: the A records point at two Cloudflare addresses (104.21.95.108 and 172.67.144.162), and port 25 is closed on both from here.&lt;/p&gt;

&lt;p&gt;That measurement needs a control, because plenty of ISPs block outbound port 25 — if mine did, every address would look "closed" and the test would say nothing. So I connected from the same machine to my own mail server, 137.74.47.57 behind &lt;code&gt;mail.sysroot.app&lt;/code&gt;: port 25 open. The closure is not on my side.&lt;/p&gt;

&lt;p&gt;The domain does carry an SPF record, &lt;code&gt;v=spf1 a:mail.sysroot.app -all&lt;/code&gt;. That misled me for a moment, because my mail server's name appears in it. But SPF governs the sending side: it says who may send mail on behalf of that domain. It has nothing to do with where incoming mail is delivered.&lt;/p&gt;

&lt;p&gt;Let me state the limit of the measurement too. I did not &lt;em&gt;send&lt;/em&gt; a test message to that address. What this check covers is the delivery path at the DNS level; I derived the existence of the mailbox from that. But a stranger's report would travel the same path, and if the path is closed, what is at the end of it does not matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict the RFC gives this case
&lt;/h2&gt;

&lt;p&gt;Before interpreting the finding I went back to the standard, because RFC 9116's security considerations have a section on exactly this: incorrect or stale information. The text says that if the information in the file is not kept up to date, security reports either never arrive or go to the wrong contacts. Then comes this: not having a &lt;code&gt;security.txt&lt;/code&gt; file may be preferable to having stale information in it.&lt;/p&gt;

&lt;p&gt;It follows that with four instructions to organizations: the information in this file and the resources it references — web pages, email addresses, telephone numbers — should be kept current, be &lt;em&gt;accessible&lt;/em&gt;, be &lt;em&gt;controlled by the organization&lt;/em&gt;, and be kept secure.&lt;/p&gt;

&lt;p&gt;After reading that I re-read my own table and the number changed. I have three addresses that publish a &lt;code&gt;security.txt&lt;/code&gt;, and two that can &lt;em&gt;receive&lt;/em&gt; a report. The third is a picture of a door: standards-compliant, unexpired, correct in form, empty behind. For a researcher that is worse than a 404. A 404 says "there is no channel here, look for another way." &lt;code&gt;destek@hrmarge.com&lt;/code&gt; says write the message, send it, consider the job done. And I, having received nothing, assume there is nothing wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtSZXNlYXJjaGVyIGZpbmRzIHNvbWV0aGluZ10gLS0-IEJ7Ii8ud2VsbC1rbm93bi9zZWN1cml0eS50eHQifQogIEIgLS0-fCI0MDQgwrcgNCBvZiBteSBhZGRyZXNzZXMifCBDWyJObyBjaGFubmVsPGJyLz5sb29rcyBmb3IgYW5vdGhlciB3YXkiXQogIEIgLS0-fCIyMDAgwrcgMyBvZiBteSBhZGRyZXNzZXMifCBEWyJSZWFkcyB0aGUgQ29udGFjdCBhZGRyZXNzIl0KICBEIC0tPiBFeyJEb2VzIHRoZSBkb21haW4gaGF2ZTxici8-YSBkZWxpdmVyeSBwYXRoPyJ9CiAgRSAtLT58Ik1YIHByZXNlbnQgwrcgMiBvZiBtaW5lInwgRltSZXBvcnQgcmVhY2hlcyBtZV0KICBFIC0tPnwiTm8gTVggwrcgaHJtYXJnZS5jb20ifCBHWyJSZXBvcnQgZ29lcyBub3doZXJlPGJyLz5uZWl0aGVyIG9mIHVzIGZpbmRzIG91dCJdCiAgQyAtLT4gSFtJIGZpbmQgb3V0IGxhdGVdCiAgRyAtLT4gSVtJIG5ldmVyIGZpbmQgb3V0XQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtSZXNlYXJjaGVyIGZpbmRzIHNvbWV0aGluZ10gLS0-IEJ7Ii8ud2VsbC1rbm93bi9zZWN1cml0eS50eHQifQogIEIgLS0-fCI0MDQgwrcgNCBvZiBteSBhZGRyZXNzZXMifCBDWyJObyBjaGFubmVsPGJyLz5sb29rcyBmb3IgYW5vdGhlciB3YXkiXQogIEIgLS0-fCIyMDAgwrcgMyBvZiBteSBhZGRyZXNzZXMifCBEWyJSZWFkcyB0aGUgQ29udGFjdCBhZGRyZXNzIl0KICBEIC0tPiBFeyJEb2VzIHRoZSBkb21haW4gaGF2ZTxici8-YSBkZWxpdmVyeSBwYXRoPyJ9CiAgRSAtLT58Ik1YIHByZXNlbnQgwrcgMiBvZiBtaW5lInwgRltSZXBvcnQgcmVhY2hlcyBtZV0KICBFIC0tPnwiTm8gTVggwrcgaHJtYXJnZS5jb20ifCBHWyJSZXBvcnQgZ29lcyBub3doZXJlPGJyLz5uZWl0aGVyIG9mIHVzIGZpbmRzIG91dCJdCiAgQyAtLT4gSFtJIGZpbmQgb3V0IGxhdGVdCiAgRyAtLT4gSVtJIG5ldmVyIGZpbmQgb3V0XQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="502" height="1106"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The worst box in that diagram is not the one at the bottom right. The worst part is that the path leading to it returns a 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is at the end of the Policy line
&lt;/h2&gt;

&lt;p&gt;I was about to stop here, pleased with the lesson, when I noticed I had not asked the same question of the file's other lines. &lt;code&gt;Policy&lt;/code&gt; points at an address too, and I had only checked whether it opened. All three opened.&lt;/p&gt;

&lt;p&gt;Reading what opened broke the table again. The &lt;code&gt;Policy&lt;/code&gt; line on &lt;code&gt;spamkalkani.com&lt;/code&gt; goes to &lt;code&gt;/privacy.html&lt;/code&gt; and from there to the &lt;code&gt;/privacy&lt;/code&gt; page. That page is titled "Gizlilik Politikası — Spam Kalkanı" — a privacy policy — and contains no section on vulnerability reporting at all; searches for "vulnerability" and "disclosure" come back empty. So a researcher who follows the &lt;code&gt;Policy&lt;/code&gt; line finds a privacy notice, not a reporting policy. HTTP 200, irrelevant content.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Policy&lt;/code&gt; line on &lt;code&gt;burncpu.com&lt;/code&gt; goes to the security policy on GitHub, and that file is genuinely good: a scope list, response-time targets, a safe-harbour clause, a clear email address. It also contains this instruction — if possible, encrypt the report with the maintainer's key, available at &lt;code&gt;keys.openpgp.org&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I went looking for that key. &lt;code&gt;keys.openpgp.org&lt;/code&gt; returns 404 for the address in question, and for all three of my address variants. To make sure I was not testing it wrong, I asked for a key I knew existed: &lt;code&gt;security@torproject.org&lt;/code&gt; returns 200 from the same endpoint. The query method works. The key is not there.&lt;/p&gt;

&lt;p&gt;That makes three instances of the pattern. File present, address dead. File present, policy irrelevant. Policy present, key missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The name on the address is a contract too
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Contact&lt;/code&gt; values in my three files begin with &lt;code&gt;mustafa@&lt;/code&gt;, &lt;code&gt;destek@&lt;/code&gt; and &lt;code&gt;destek@&lt;/code&gt;. None of them is &lt;code&gt;security@&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When RFC 9116 defines the &lt;code&gt;Contact&lt;/code&gt; field it says security email addresses should follow the conventions in section 4 of RFC 2142. That section is a short table: &lt;code&gt;ABUSE&lt;/code&gt; for customer relations, &lt;code&gt;NOC&lt;/code&gt; for network infrastructure, and &lt;code&gt;SECURITY&lt;/code&gt; for network security — security bulletins or queries. A convention that has been sitting there since 1997.&lt;/p&gt;

&lt;p&gt;This is a recommendation, not a requirement. But here is why it earns its keep: a &lt;code&gt;security@&lt;/code&gt; box is bound to a role rather than a person. All three of my addresses go either to me or to a general support box. When the first goes on holiday, or the second becomes a queue full of password-reset requests, a security report gets lost in the same pile. A role address does not fix that by itself, but it separates it — and you can write a filter against a queue that has been separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern on the repository side
&lt;/h2&gt;

&lt;p&gt;If a stranger cannot get through the site, they look at where the code lives. My &lt;code&gt;merbay-erp&lt;/code&gt; account has eight public repositories. I asked each one whether it has a &lt;code&gt;SECURITY.md&lt;/code&gt; — in the root, under &lt;code&gt;.github/&lt;/code&gt; and under &lt;code&gt;docs/&lt;/code&gt;, since GitHub reads all three. Three have one: &lt;code&gt;burncpu&lt;/code&gt;, &lt;code&gt;CORTEX-Private-Intelligence-Framework&lt;/code&gt; and &lt;code&gt;zee&lt;/code&gt;. Five do not.&lt;/p&gt;

&lt;p&gt;This morning I would have written "three out of eight" and moved on. But since the whole point of this piece is not settling for the presence of a file, I opened all three and got caught by my own thesis.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;burncpu/SECURITY.md&lt;/code&gt; gives an address: &lt;code&gt;mustafa@mustafaerbay.com.tr&lt;/code&gt;. An address with a delivery path. That file does its job.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CORTEX-Private-Intelligence-Framework/SECURITY.md&lt;/code&gt; directs the researcher to GitHub's "Report a vulnerability" button, and if that is unavailable, to contact the maintainer through their GitHub profile. Both routes are shut: that button depends on private vulnerability reporting, which is disabled on all eight of my repositories, and my GitHub profile publishes no email address.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;zee/SECURITY.md&lt;/code&gt; opens with "Depo özeldir" — the repository is private — except the repository is public; the API reports &lt;code&gt;visibility: public&lt;/code&gt;. It offers the security advisory form as the reporting route, which somebody without repository access cannot use while private vulnerability reporting is off. And the file's own sentence closes the matter: no new contact address is allocated by this document.&lt;/p&gt;

&lt;p&gt;So on the repository side there are three files, and one of them gives a stranger a working route.&lt;/p&gt;

&lt;h2&gt;
  
  
  As for the front door
&lt;/h2&gt;

&lt;p&gt;On this site itself there is no &lt;code&gt;SECURITY.md&lt;/code&gt; in the repository, no &lt;code&gt;public/.well-known/&lt;/code&gt; directory, and no contact page — neither &lt;code&gt;iletisim&lt;/code&gt; nor &lt;code&gt;contact&lt;/code&gt; under &lt;code&gt;src/pages/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is a gap in the structured data too. The file I audited in &lt;a href="https://mustafaerbay.com.tr/en/blog/career/kimligimi-dort-deftere-yazdim-dordu-ayri-liste-veriyor/" rel="noopener noreferrer"&gt;the piece where I wrote my identity into four registries&lt;/a&gt;, &lt;code&gt;src/lib/profile.ts&lt;/code&gt;, is the source of everything this site says about me. Line twenty-one reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field is declared and has no value. I pulled the &lt;code&gt;schema.org&lt;/code&gt; person record the homepage emits and checked it live: there really is no &lt;code&gt;email&lt;/code&gt; key. I publish no machine-readable contact information at all.&lt;/p&gt;

&lt;p&gt;On the human side there are addresses, and rather too many. The privacy page carries &lt;code&gt;hello@mustafaerbay.com.tr&lt;/code&gt; as a clickable &lt;code&gt;mailto:&lt;/code&gt; link. The subscription result pages carry &lt;code&gt;mustafa@mustafaerbay.com&lt;/code&gt;. &lt;code&gt;burncpu.com&lt;/code&gt;'s &lt;code&gt;security.txt&lt;/code&gt; carries &lt;code&gt;mustafa@mustafaerbay.com.tr&lt;/code&gt;. Three variants across two domains; both domains have MX records pointing at the same server of mine, so all three have an open delivery path.&lt;/p&gt;

&lt;p&gt;There is also an easter egg I print to the console, and in Turkish it reads: "If you are poking around here, write to me, let's have a coffee." I wrote a line that says exactly the right thing to somebody poking around, back in May. The trouble is that none of these addresses sits where a researcher would &lt;em&gt;look&lt;/em&gt;: nobody hunts for a reporting channel inside a privacy notice or in the browser console. They look at &lt;code&gt;/.well-known/security.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six signs, three of them working
&lt;/h2&gt;

&lt;p&gt;At the end of the audit I counted. I publish six documents that tell a stranger where to write: three &lt;code&gt;security.txt&lt;/code&gt; files and three &lt;code&gt;SECURITY.md&lt;/code&gt; files. The test is simple — can a stranger who reads that document, with no access to the repository, reach me?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Gives a stranger a working route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;burncpu.com &lt;code&gt;security.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spamkalkani.com &lt;code&gt;security.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hrmarge.com &lt;code&gt;security.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no — the address has no delivery path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;burncpu &lt;code&gt;SECURITY.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CORTEX &lt;code&gt;SECURITY.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no — a disabled button and an empty profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zee &lt;code&gt;SECURITY.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no — allocates no address&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three out of six. Exactly half.&lt;/p&gt;

&lt;p&gt;What bothers me about that number is that all six would pass a formal check. They all exist, they are all in the right place, they all return 200. If I wrote a scanner, it would mark all six green. The wall a stranger hits starts one layer past the existence of the file — and until today I had never looked at that layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it did not spread
&lt;/h2&gt;

&lt;p&gt;There is no position here from which I can defend myself, but there is one from which I can understand it. I wrote a &lt;code&gt;security.txt&lt;/code&gt; for three projects because the subject was on my mind while setting them up, and not for four because it was not. There was nothing in between: no checklist, no template I run when opening a new project, no audit asking whether this domain's reporting channel works. If you apply a practice project by project, it is not a habit; it is a coincidence.&lt;/p&gt;

&lt;p&gt;The irritating part is learning afterwards that the propagation mechanism was within reach the whole time. If an account has a &lt;strong&gt;public&lt;/strong&gt; repository named &lt;code&gt;.github&lt;/code&gt;, GitHub uses the &lt;code&gt;SECURITY.md&lt;/code&gt; inside it as the default for every repository that lacks its own — whatever the target repository's own visibility. The file can sit in the root, in the &lt;code&gt;.github&lt;/code&gt; folder or in the &lt;code&gt;docs&lt;/code&gt; folder; a repository's own file wins. So I do not need five files for five repositories, I need one. That repository does not exist yet either: &lt;code&gt;merbay-erp/.github&lt;/code&gt; returns 404.&lt;/p&gt;

&lt;p&gt;That convenience does not cover this blog, though, because the blog's repository lives under a different account — &lt;code&gt;itwise-bilisim/mustafaerbay&lt;/code&gt;. The default file works per account, so that is a separate repository and a separate commit. On the site side there is no shortcut at all: every domain has to serve its own &lt;code&gt;security.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is instructive to look at how this gets institutionalised in the public sector. The binding operational directive CISA issued in 2020 requires federal agencies to develop and publish a vulnerability disclosure policy — and in a specific place: at the &lt;code&gt;/vulnerability-disclosure-policy&lt;/code&gt; path of the agency's primary &lt;code&gt;.gov&lt;/code&gt; website, in plain text or HTML. It mentions &lt;code&gt;security.txt&lt;/code&gt; too, noting that its use is not required under the directive but that it can help some people work out who to share findings with. Even there the mechanism is not "everyone remember": fixed path, fixed format, deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  From here
&lt;/h2&gt;

&lt;p&gt;What I take from this audit is not a checklist but a change of habit: after setting up a channel, walk it once &lt;em&gt;from the other side&lt;/em&gt;. Requesting the file is not enough — you have to ask whether the address inside it has a delivery path. A 200 on the &lt;code&gt;Policy&lt;/code&gt; line is not enough — the page that opens has to actually be a reporting policy. Writing "encrypt it with my key" is not enough — the key has to be published. All three were steps I skipped, and one question catches all three: what does the stranger reading this line do next, and will what they do work?&lt;/p&gt;

&lt;p&gt;This site has a history of leaving open items for its readers; in &lt;a href="https://mustafaerbay.com.tr/en/blog/career/32-soz-verdim-denetleyebildigim-13unun-11i-acik/" rel="noopener noreferrer"&gt;the piece where I audited my own promises&lt;/a&gt;, eleven of the thirteen commitments I could check were still open. So I will keep the list short. Three items, all measurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;code&gt;hrmarge.com&lt;/code&gt; I will either build the delivery path or change the address in its &lt;code&gt;security.txt&lt;/code&gt; to one that has a delivery path; if neither happens, the RFC's own verdict says the right move is to remove the file. Whichever it is, I will not call it done before a real test message reaches that address.&lt;/li&gt;
&lt;li&gt;I will add a public &lt;code&gt;.github&lt;/code&gt; repository to the &lt;code&gt;merbay-erp&lt;/code&gt; account with a single &lt;code&gt;SECURITY.md&lt;/code&gt; inside it; that closes the gap for five repositories. &lt;code&gt;CORTEX&lt;/code&gt; and &lt;code&gt;zee&lt;/code&gt; need separate fixes, since their current files point at a disabled button: either I enable private vulnerability reporting or I put a working address in the files.&lt;/li&gt;
&lt;li&gt;I will put a &lt;code&gt;security.txt&lt;/code&gt; at the root of this site with &lt;code&gt;security@mustafaerbay.com.tr&lt;/code&gt; as the &lt;code&gt;Contact&lt;/code&gt;. Before writing the file I will create the mailbox and test it with a real message, and I will set &lt;code&gt;Expires&lt;/code&gt; under a year with a renewal reminder — otherwise today's file becomes next year's dead door.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://mustafaerbay.com.tr/en/blog/career/17-is-akisim-var-hicbiri-pr-acilinca-calismiyor/" rel="noopener noreferrer"&gt;The piece where I found that none of my seventeen workflows run when a PR opens&lt;/a&gt; arrived somewhere similar: there the missing piece was a trigger, here it is a mailbox. What they share is me looking at a configuration and calling it done. Configuration shows what you intended; whether it works is something you only learn by walking it from the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9116.html" rel="noopener noreferrer"&gt;RFC 9116 — A File Format to Aid in Security Vulnerability Disclosure&lt;/a&gt; — the format of &lt;code&gt;security.txt&lt;/code&gt;; the mandatory &lt;code&gt;Contact&lt;/code&gt; and &lt;code&gt;Expires&lt;/code&gt; fields (2.5.3, 2.5.5), the signature and &lt;code&gt;Encryption&lt;/code&gt; recommendations (2.3, 2.5.4), and the ruling that stale information may be worse than no file (5.3).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc2142.html" rel="noopener noreferrer"&gt;RFC 2142 — Mailbox Names for Common Services, Roles and Functions&lt;/a&gt; — section 4, which defines the &lt;code&gt;SECURITY&lt;/code&gt; role mailbox.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc5321.html" rel="noopener noreferrer"&gt;RFC 5321 — Simple Mail Transfer Protocol&lt;/a&gt; — section 5.1: with no MX record, the address record is treated as an implicit MX.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configure-for-a-repository" rel="noopener noreferrer"&gt;GitHub Docs — Configuring private vulnerability reporting for a repository&lt;/a&gt; — enabling the structured reporting form on public repositories.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file" rel="noopener noreferrer"&gt;GitHub Docs — Creating a default community health file&lt;/a&gt; — how a &lt;code&gt;SECURITY.md&lt;/code&gt; in the &lt;code&gt;.github&lt;/code&gt; repository becomes the default for other repositories.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cisa.gov/news-events/directives/bod-20-01-develop-and-publish-vulnerability-disclosure-policy" rel="noopener noreferrer"&gt;CISA — BOD 20-01: Develop and Publish a Vulnerability Disclosure Policy&lt;/a&gt; — the fixed path and format required of federal agencies, and where &lt;code&gt;security.txt&lt;/code&gt; sits in the directive.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>security</category>
      <category>measurement</category>
      <category>process</category>
    </item>
    <item>
      <title>The Process Blew a Budget That Wasn't Its Own</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Wed, 11 Nov 2026 06:36:20 +0000</pubDate>
      <link>https://dev.to/merbayerp/the-process-blew-a-budget-that-wasnt-its-own-a7</link>
      <guid>https://dev.to/merbayerp/the-process-blew-a-budget-that-wasnt-its-own-a7</guid>
      <description>&lt;p&gt;This machine was set up in January 2025 — the stamp on &lt;code&gt;/var/db/.AppleSetupDone&lt;/code&gt;&lt;br&gt;
says the 20th. My expectations were low on opening&lt;br&gt;
&lt;code&gt;/Library/Logs/DiagnosticReports&lt;/code&gt;: the name suggests crash dumps, and crashes are&lt;br&gt;
rare on this machine. The folder had 58 files in it. Application crash reports: zero.&lt;/p&gt;

&lt;p&gt;What was inside was something else: the records of two resource budgets my machine&lt;br&gt;
keeps on every process running on it. Fifty-five of them in seven days. Every single&lt;br&gt;
one carried the same line — &lt;code&gt;Action taken: none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In a moment I'll get to the thing I missed on my first read: there is a process&lt;br&gt;
name at the top of each report, and the budget does not belong to that process. But&lt;br&gt;
let's count what is in the folder first.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is in the folder
&lt;/h2&gt;

&lt;p&gt;The measurement is from the morning of 11 November 2026. The machine is a MacBook&lt;br&gt;
Pro &lt;code&gt;Mac16,7&lt;/code&gt; with 24 GB of memory, running macOS 26.6.2 (build 25G83) on kernel&lt;br&gt;
&lt;code&gt;xnu-12377.161.14&lt;/code&gt;. The whole folder is 10 MB. The reports span eight separate&lt;br&gt;
calendar days but only 6 days 23.8 hours end to end: the earliest violation is&lt;br&gt;
4 November 08:34:06, the latest 11 November 08:21:38. "Seven days" throughout this&lt;br&gt;
piece means that span.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls /Library/Logs/DiagnosticReports | sed 's/.*\.//' | sort | uniq -c
   1 DiagnosticLogs
  56 diag
   1 ips
   1 panic
   1 Retired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of those entries are empty directories (&lt;code&gt;Retired&lt;/code&gt;, &lt;code&gt;DiagnosticLogs&lt;/code&gt;). The single&lt;br&gt;
&lt;code&gt;.ips&lt;/code&gt; file is not a crash report either: its first line reads &lt;code&gt;"bug_type":"298"&lt;/code&gt;,&lt;br&gt;
making it a jetsam record — a process killed under memory pressure. It is stamped&lt;br&gt;
8 November 06:30, names &lt;code&gt;com.apple.Virtualization.Virtual&lt;/code&gt; as the largest process,&lt;br&gt;
and lists all 699 processes alive at that moment. Of the 56 &lt;code&gt;.diag&lt;/code&gt; files, 55 are&lt;br&gt;
likewise neither crashes nor panics: they are resource budget records.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.panic&lt;/code&gt; file is its own story and goes in here as a margin note, because I had&lt;br&gt;
no idea it existed. Its first line reads &lt;code&gt;"bug_type":"210"&lt;/code&gt;, stamped 5 November at&lt;br&gt;
08:57:01. A &lt;code&gt;ResetCounter&lt;/code&gt; file written three seconds later says "Reset count: 1". I&lt;br&gt;
asked an independent source when the machine had come up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sysctl -n kern.boottime
{ sec = 1793858196, usec = 877287 } Thu Nov  5 08:56:36 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the panic report was written 25 seconds after boot. That is the expected order,&lt;br&gt;
because macOS writes the panic record on the &lt;em&gt;next&lt;/em&gt; boot rather than from the kernel&lt;br&gt;
that died. Counting backwards from the &lt;code&gt;Time Since Boot: 516200s&lt;/code&gt; field in one of&lt;br&gt;
the reports from the morning I took the measurement — 11 November 08:19 — lands on&lt;br&gt;
08:56:27, ten seconds off what &lt;code&gt;kern.boottime&lt;/code&gt; says and close enough for two&lt;br&gt;
independent methods.&lt;/p&gt;

&lt;p&gt;The file's &lt;code&gt;panicString&lt;/code&gt; says what happened: &lt;em&gt;Sleep transition timed out after 180&lt;br&gt;
seconds while creating hibernation file&lt;/em&gt;. So the machine did not crash under me at&lt;br&gt;
the keyboard; it hung on the way into sleep. My applications not crashing was true&lt;br&gt;
enough — what failed was the machine's own sleep transition. On the morning of&lt;br&gt;
5 November it restarted, filed the paperwork, and I found out six days later while&lt;br&gt;
looking for something else. This article is not about that panic,&lt;br&gt;
but it is a decent reminder of why I opened the folder: records you don't read keep&lt;br&gt;
counting things on your behalf.&lt;/p&gt;

&lt;p&gt;Two kinds of event turned up, and the split surprised me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cpu usage    22
disk writes  33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first move here was to blame my own command, because my own command was guilty.&lt;br&gt;
The &lt;code&gt;sed 's/.*\.//'&lt;/code&gt; above takes everything after the last dot, so it counted all 56&lt;br&gt;
files as &lt;code&gt;diag&lt;/code&gt; — but the filenames already separate the two kinds. Twenty-two&lt;br&gt;
reports end in &lt;code&gt;…cpu_resource.diag&lt;/code&gt; and every one of them is &lt;code&gt;cpu usage&lt;/code&gt;; the other&lt;br&gt;
33 are plain &lt;code&gt;.diag&lt;/code&gt; and every one is &lt;code&gt;disk writes&lt;/code&gt;. No crossover at all. The folder&lt;br&gt;
didn't mislead me; my one-liner trimming the extension did — and this is only the&lt;br&gt;
first of two places in this piece where my own scanning command produced the&lt;br&gt;
finding.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two budgets, two opposite policies
&lt;/h2&gt;

&lt;p&gt;Reading the reports turned up two separate limits. On the CPU side what is identical&lt;br&gt;
across all 22 reports is the limit lines; the measurement line changes in every one.&lt;br&gt;
Here is one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event:            cpu usage
CPU:              90 seconds cpu time over 111 seconds (81% cpu average),
                  exceeding limit of 50% cpu over 180 seconds
CPU limit:        90s
Limit duration:   180s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers on the left move across the 22 reports: the CPU time spent is always&lt;br&gt;
exactly 90 seconds, but the window it fits into runs from 94 to 170 seconds and the&lt;br&gt;
average from 53% to 96%. There are 18 distinct &lt;code&gt;CPU:&lt;/code&gt; lines across 22 reports, and&lt;br&gt;
the one above appears in exactly one of them. What never changes is the right-hand&lt;br&gt;
side: &lt;code&gt;exceeding limit of 50% cpu over 180 seconds&lt;/code&gt;, &lt;code&gt;CPU limit: 90s&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;Limit duration: 180s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both numbers sit in the kernel source. Lines 261-262 of &lt;code&gt;osfmk/kern/task_policy.c&lt;/code&gt;&lt;br&gt;
read &lt;code&gt;DEFAULT_CPUMON_PERCENTAGE 50&lt;/code&gt; and &lt;code&gt;DEFAULT_CPUMON_INTERVAL (3 * 60)&lt;/code&gt;. Fifty&lt;br&gt;
per cent, three minutes. Every process that produced a report on my machine sits at&lt;br&gt;
that default, so none of them had raised its ceiling. A process that raised its&lt;br&gt;
ceiling and never crossed it wouldn't appear in this list at all, and this data&lt;br&gt;
can't tell me about those. Where the right to raise it stops is written in the same&lt;br&gt;
file: a process asking for a percentage or an interval above the global cap is&lt;br&gt;
pulled back to the default unless it is &lt;code&gt;cpumon_entitled&lt;/code&gt; — and the kernel doesn't&lt;br&gt;
do that quietly, it &lt;code&gt;printf&lt;/code&gt;s a "denied attempt to escape CPU monitor" line. One&lt;br&gt;
wanting to switch the monitor off entirely has to present the&lt;br&gt;
&lt;code&gt;com.apple.private.kernel.override-cpumon&lt;/code&gt; entitlement. The word &lt;code&gt;private&lt;/code&gt; in that&lt;br&gt;
name tells you plainly enough who gets to use it.&lt;/p&gt;

&lt;p&gt;The write side is not fixed. Across the 33 reports I found four different limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit as printed&lt;/th&gt;
&lt;th&gt;Actual value&lt;/th&gt;
&lt;th&gt;Reports&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2147.48 MB&lt;/td&gt;
&lt;td&gt;2 GiB / 24 hours&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8589.93 MB&lt;/td&gt;
&lt;td&gt;8 GiB / 24 hours&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;34.36 GB&lt;/td&gt;
&lt;td&gt;32 GiB / 24 hours&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;137.44 GB&lt;/td&gt;
&lt;td&gt;128 GiB / 24 hours&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each is four times the one above it. That is not a coincidence; it is a rule written&lt;br&gt;
down in the source. In &lt;code&gt;osfmk/kern/coalition.c&lt;/code&gt;, the part of &lt;code&gt;coalition_io_rate_exceeded()&lt;/code&gt; that runs&lt;br&gt;
after the report has been sent looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * Continue to monitor the coalition after it hits the initital limit, but increase
 * the limit exponentially so that we don't spam the listener.
 */&lt;/span&gt;
&lt;span class="n"&gt;new_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lei&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lei_limit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;coalition_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_limit&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;COALITION_MAX_LOGICAL_WRITES_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;coalition_io_monitor_ctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;COALITION_IO_LEDGER_DISABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;coalition_io_monitor_ctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;COALITION_IO_LEDGER_ENABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment states the intent honestly: the point is not to punish, it is to avoid&lt;br&gt;
flooding the listener with notifications. But the consequence is this — whatever&lt;br&gt;
writes heavily to disk gets a budget four times wider after every violation. The&lt;br&gt;
ceiling lives in &lt;code&gt;osfmk/kern/coalition.h&lt;/code&gt; as &lt;code&gt;1ULL &amp;lt;&amp;lt; 24&lt;/code&gt; megabytes, which is 16 TiB.&lt;br&gt;
Start at 2 GiB and quadruple: by the seventh violation the limit in force has&lt;br&gt;
reached 8 TiB, the new limit that violation asks for comes to 32 TiB, the ceiling is&lt;br&gt;
overshot and monitoring is switched off altogether. Make enough noise and an app&lt;br&gt;
group earns silence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiV3JpdGUgbGVkZ2VyOiBsaW1pdCAyIEdpQiBvdmVyIGEgMjQtaG91ciBwZXJpb2QiXSAtLT4gQnsiTGltaXQgZXhjZWVkZWQ_In0KICBCIC0tICJubyIgLS0-IEEKICBCIC0tICJ5ZXMiIC0tPiBDWyJLZXJuZWwgd3JpdGVzIGFuIG9zX2xvZyBsaW5lIl0KICBDIC0tPiBEWyJWaW9sYXRpb24gbm90aWNlIGdvZXMgdG8gdXNlciBzcGFjZSJdCiAgRCAtLT4gRVsiTmV3IGxpbWl0ID0gb2xkIGxpbWl0IHggNCJdCiAgRSAtLT4gRnsiTmV3IGxpbWl0IGFib3ZlIDE2IFRpQj8ifQogIEYgLS0gIm5vIiAtLT4gQQogIEYgLS0gInllcyIgLS0-IEdbIk1vbml0b3JpbmcgaXMgZGlzYWJsZWQsIHRoaXMgZ3JvdXAgaXMgbmV2ZXIgcmVwb3J0ZWQgYWdhaW4iXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiV3JpdGUgbGVkZ2VyOiBsaW1pdCAyIEdpQiBvdmVyIGEgMjQtaG91ciBwZXJpb2QiXSAtLT4gQnsiTGltaXQgZXhjZWVkZWQ_In0KICBCIC0tICJubyIgLS0-IEEKICBCIC0tICJ5ZXMiIC0tPiBDWyJLZXJuZWwgd3JpdGVzIGFuIG9zX2xvZyBsaW5lIl0KICBDIC0tPiBEWyJWaW9sYXRpb24gbm90aWNlIGdvZXMgdG8gdXNlciBzcGFjZSJdCiAgRCAtLT4gRVsiTmV3IGxpbWl0ID0gb2xkIGxpbWl0IHggNCJdCiAgRSAtLT4gRnsiTmV3IGxpbWl0IGFib3ZlIDE2IFRpQj8ifQogIEYgLS0gIm5vIiAtLT4gQQogIEYgLS0gInllcyIgLS0-IEdbIk1vbml0b3JpbmcgaXMgZGlzYWJsZWQsIHRoaXMgZ3JvdXAgaXMgbmV2ZXIgcmVwb3J0ZWQgYWdhaW4iXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="256" height="1296"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What this budget actually counts
&lt;/h2&gt;

&lt;p&gt;I had to understand the number before writing the sentence "it wrote 2 GiB," because&lt;br&gt;
the report does not say &lt;em&gt;written&lt;/em&gt;. It says &lt;em&gt;file backed memory dirtied&lt;/em&gt;. That is not&lt;br&gt;
bytes landing on the SSD.&lt;/p&gt;

&lt;p&gt;The function that feeds the ledger is in &lt;code&gt;osfmk/kern/task.c&lt;/code&gt; and it handles four&lt;br&gt;
separate kinds: &lt;code&gt;TASK_WRITE_IMMEDIATE&lt;/code&gt;, &lt;code&gt;TASK_WRITE_DEFERRED&lt;/code&gt;, &lt;code&gt;TASK_WRITE_METADATA&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;TASK_WRITE_INVALIDATED&lt;/code&gt;. The first three are added to the account; the fourth —&lt;br&gt;
pages dirtied and then invalidated before reaching disk — is &lt;strong&gt;subtracted&lt;/strong&gt; from it.&lt;br&gt;
So this is not a total, it is a running account that goes up and down. A process that&lt;br&gt;
dirties memory and then abandons the work gets part of its budget back.&lt;/p&gt;

&lt;p&gt;The second detail is more practical. The call that updates the group ledger only runs&lt;br&gt;
for writes to internal storage; writes to external storage go to a separate ledger&lt;br&gt;
called &lt;code&gt;logical_writes_to_external&lt;/code&gt; and never touch the group budget. Move a heavy job&lt;br&gt;
to an external disk and you become invisible to this budget. That is not a suggested&lt;br&gt;
workaround; it is the reason reading these reports as "here is what my machine writes&lt;br&gt;
most" will always come up short. Work that writes to an external disk never shows up&lt;br&gt;
in this list at all.&lt;/p&gt;

&lt;p&gt;The third shows up in the numbers themselves. In 20 of the 33 reports, &lt;code&gt;Writes&lt;br&gt;
caused&lt;/code&gt; sits at most 0.02 MB above the limit, and in 26 of them the overshoot stays&lt;br&gt;
under 1 MB: the notice fires the instant the ledger touches the line, so the spill&lt;br&gt;
is close to nothing. Exactly one report makes that pattern unrecognisable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Command:          Autoupdate
Path:             /Applications/ChatGPT.app/Contents/Frameworks/Sparkle.framework/…
Writes:           2379.91 MB of file backed memory dirtied over 11 seconds
                  (216.27 MB per second average), exceeding limit of
                  24.86 KB per second over 86400 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It spent an entire day's budget in eleven seconds. The ledger caught it 232 MB past&lt;br&gt;
the line; the largest overshoot among the other 32 reports is 6.40 MB. It also took&lt;br&gt;
me a moment to see that the "24.86 KB per second" in that report is the same 2 GiB&lt;br&gt;
budget divided by 86400 seconds, written two lines down in a second unit.&lt;/p&gt;
&lt;h2&gt;
  
  
  I never once broke the CPU budget
&lt;/h2&gt;

&lt;p&gt;Not one of the 22 CPU reports names an application I installed. The distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7  knowledgeconstructiond
6  spotlightknowledged
3  FPCKService
2  duetexpertd
2  com.apple.Virtualization.VirtualMachine
1  mds_stores
1  apfsd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nineteen of the paths start under &lt;code&gt;/System/&lt;/code&gt; and three under &lt;code&gt;/usr/libexec&lt;/code&gt;. All of&lt;br&gt;
them are macOS's own binaries. The top two — &lt;code&gt;knowledgeconstructiond&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;spotlightknowledged&lt;/code&gt; — are the background workers of Spotlight and the on-device&lt;br&gt;
knowledge index. Thirteen times in seven days they filled half a core across a&lt;br&gt;
three-minute window and had a report written about it. Because the CPU limit does not&lt;br&gt;
rise after a violation, the same process gets reported again every time. For&lt;br&gt;
&lt;code&gt;knowledgeconstructiond&lt;/code&gt; that comes to seven filings in seven days — and the last one&lt;br&gt;
is stamped 08:21 on the morning I took the measurement.&lt;/p&gt;

&lt;p&gt;Even the apparent exception, &lt;code&gt;com.apple.Virtualization.VirtualMachine&lt;/code&gt;, is Apple's&lt;br&gt;
binary — it just happens to be hosting my virtual machine. So even in the CPU ledger&lt;br&gt;
the binary being named belongs to Apple while the work belongs to me.&lt;/p&gt;

&lt;p&gt;The other side inverts. Of the 33 write reports, 12 belong to a named third-party app&lt;br&gt;
group, 6 to a &lt;code&gt;com.apple.*&lt;/code&gt; group, and 15 to a group carrying only a number. My&lt;br&gt;
machine tires itself out on CPU; I'm the one tiring out the disk.&lt;/p&gt;
&lt;h2&gt;
  
  
  The finding: the process on the report doesn't own the budget
&lt;/h2&gt;

&lt;p&gt;On my first read I took the report to mean: a process crossed a limit, the system&lt;br&gt;
noted it. I had it wrong. There are two separate fields at the top, and I had skipped&lt;br&gt;
the second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Command:            git
Path:               /opt/homebrew/*/git
Event:              disk writes
Writes limit:       2147.48 MB
Resource Coalition: "com.anthropic.claudefordesktop"(17488)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The budget is not &lt;code&gt;git&lt;/code&gt;'s. It belongs to the &lt;em&gt;coalition&lt;/em&gt; &lt;code&gt;git&lt;/code&gt; was running inside —&lt;br&gt;
macOS's grouping of an application, its helper processes and its children under a&lt;br&gt;
single resource account. &lt;code&gt;git&lt;/code&gt; was only the last straw. The kernel draws that distinction far more clearly in its own log line than the&lt;br&gt;
report does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kernel: Coalition [64708] caught causing excessive I/O (flavor: 2).
        Task I/O: 8192 MB. [Limit : 8192 MB per 86400 secs].
        Triggered by process [89137]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Coalition … caught" and "triggered by process …" sit in the same sentence: the&lt;br&gt;
group is what gets caught, the process is what pulls the trigger. Because the report&lt;br&gt;
file doesn't draw that line in its heading, anyone who reads &lt;code&gt;Command&lt;/code&gt; and stops&lt;br&gt;
there knocks on the wrong door.&lt;/p&gt;

&lt;p&gt;My data showed this most clearly on a single day. On 5 November, group number 795&lt;br&gt;
produced four reports:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Violation time&lt;/th&gt;
&lt;th&gt;Process on the report&lt;/th&gt;
&lt;th&gt;Limit at the time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09:02:31&lt;/td&gt;
&lt;td&gt;&lt;code&gt;find&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:36:41&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qemu-system-x86_64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:10:06&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qemu-system-x86_64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18:04:43&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Python&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four different binaries, one budget, quadrupling each time. Only the last of the four&lt;br&gt;
resolved the group's name: &lt;code&gt;"com.openai.codex"(795)&lt;/code&gt;. The other three carry just the&lt;br&gt;
number — I'm inferring they belong to the same group because the number matches; the&lt;br&gt;
reports don't say so themselves. So that day four violations were charged to a coding&lt;br&gt;
agent's account, and three of the reports never mention the agent at all.&lt;/p&gt;

&lt;p&gt;Among the named groups, the two heaviest writers are the two AI tools I use every&lt;br&gt;
day: the name &lt;code&gt;com.anthropic.claudefordesktop&lt;/code&gt; appears on four reports,&lt;br&gt;
&lt;code&gt;com.openai.codex&lt;/code&gt; on two. Count the whole 795 chain above as Codex and the second&lt;br&gt;
figure becomes five — but that is an inference from a number, not something the&lt;br&gt;
reports state. On the Claude side, three of the four reports name something the tool&lt;br&gt;
runs (&lt;code&gt;git&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;ditto&lt;/code&gt;) and the fourth names the tool itself (&lt;code&gt;Claude&lt;/code&gt;). I had&lt;br&gt;
been asking which thing&lt;br&gt;
wears my disk out most in the setup running on this machine; the answer came back as&lt;br&gt;
"the things working on my behalf." I should have expected that, and I didn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  What these files say about me
&lt;/h2&gt;

&lt;p&gt;I was curious about the privacy side, because the folder's name makes you cautious.&lt;br&gt;
All 55 reports carry the executable's path, the hardware model and a stack dump;&lt;br&gt;
43 carry a &lt;code&gt;Codesigning ID&lt;/code&gt; and 32 a &lt;code&gt;Team ID&lt;/code&gt; — seven distinct developer identities&lt;br&gt;
in total. The stack dumps are not small: the 56 &lt;code&gt;.diag&lt;/code&gt; files come to 50,447 lines&lt;br&gt;
together.&lt;/p&gt;

&lt;p&gt;My first attempt reassured me, and then I saw that what reassured me was my own&lt;br&gt;
command. I searched for my home directory and got zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ grep -l "/Users/mustafaerbay" /Library/Logs/DiagnosticReports/*.diag | wc -l
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching for the folder rather than the username changed the picture: seven reports&lt;br&gt;
contain &lt;code&gt;/Users/&lt;/code&gt;. The forms they take are &lt;code&gt;/Users/USER/*/cargo&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;/Users/USER/*/rustc&lt;/code&gt;, &lt;code&gt;/Users/USER/*/node&lt;/code&gt; and &lt;code&gt;/Users/USER/Library/Application&lt;/code&gt;.&lt;br&gt;
macOS rewrites the username to a literal &lt;code&gt;USER&lt;/code&gt; and intermediate directories to &lt;code&gt;*&lt;/code&gt; —&lt;br&gt;
the &lt;code&gt;/opt/homebrew/*/git&lt;/code&gt; you saw in the &lt;code&gt;git&lt;/code&gt; report above is the same redaction. So&lt;br&gt;
the string I searched for could never have matched; my command produced the "nothing&lt;br&gt;
here" result. The second time in this piece that the same mistake caught me.&lt;/p&gt;

&lt;p&gt;What survives the redaction is not nothing. That I run &lt;code&gt;cargo&lt;/code&gt;, &lt;code&gt;rustc&lt;/code&gt; and &lt;code&gt;node&lt;/code&gt; is&lt;br&gt;
readable straight off these files. The panic report carries a stable per-machine&lt;br&gt;
&lt;code&gt;crashReporterKey&lt;/code&gt;; the resource records don't. Which document I opened isn't written&lt;br&gt;
here — but 50,000 lines of stack dumps say plenty about which software I run and&lt;br&gt;
when.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two records don't agree
&lt;/h2&gt;

&lt;p&gt;Having counted the report files, I wanted to test the count with a second method. That&lt;br&gt;
&lt;code&gt;os_log&lt;/code&gt; line the kernel writes at the moment of a violation was exactly right for it:&lt;br&gt;
independent of the report file, written straight off the ledger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/bin/log show --style compact \
  --predicate &lt;span class="s1"&gt;'eventMessage CONTAINS "caught causing excessive I/O"'&lt;/span&gt; \
  --start &lt;span class="s2"&gt;"2026-10-27 00:00:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Although I started the query on 27 October, the oldest kernel line I could find in&lt;br&gt;
the unified log was from 9 November 08:18 — I&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/macim-on-bes-gunu-hatirliyor-belgelenen-kismi-3-7-gun/" rel="noopener noreferrer"&gt;measured why that window is shorter than I assumed&lt;/a&gt;&lt;br&gt;
a while ago. Within the overlapping range the kernel wrote five violation lines. Over&lt;br&gt;
the same range there are four write reports on disk. Three of them pair up.&lt;/p&gt;

&lt;p&gt;So two violations exist in the kernel log with no report file, and one report file&lt;br&gt;
exists with no kernel line. Neither record is complete on its own. I'm writing this&lt;br&gt;
down because I have already paid for reading a number as "not on the list, therefore&lt;br&gt;
it didn't happen" on this blog: those 55 reports are not every violation that&lt;br&gt;
occurred, only the ones that became files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places I tripped while measuring
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The report's date is not the event's date.&lt;/strong&gt; Every report carries both a &lt;code&gt;Date/Time&lt;/code&gt;&lt;br&gt;
and an &lt;code&gt;End time&lt;/code&gt;. &lt;code&gt;Date/Time&lt;/code&gt; is the &lt;em&gt;start&lt;/em&gt; of the measurement window; the violation&lt;br&gt;
happens at &lt;code&gt;End time&lt;/code&gt;. Because the window runs for hours in write reports, the gap&lt;br&gt;
between them reaches 23 hours. I grouped by &lt;code&gt;Date/Time&lt;/code&gt; first and the events landed on&lt;br&gt;
the wrong days; worse, I paired a Claude report with the wrong kernel line and produced&lt;br&gt;
a "finding" that the two records disagree by a factor of four at the same instant. No&lt;br&gt;
such finding exists. Matching on &lt;code&gt;End time&lt;/code&gt; — 01:47:44 plus 18008 seconds, exactly&lt;br&gt;
06:47:52 — made the two records agree precisely. The stamp in the filename is the&lt;br&gt;
moment the file was written, not the moment of the event either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My scanning command corrupted the result three times.&lt;/strong&gt; One of the six lines the&lt;br&gt;
log query returned was not a violation but the record of my own &lt;code&gt;log show&lt;/code&gt; command:&lt;br&gt;
the command line carried the text I was searching for as an argument, so it matched&lt;br&gt;
my own search. Together with the two traps above — the &lt;code&gt;sed&lt;/code&gt; trimming the extension&lt;br&gt;
and the &lt;code&gt;grep&lt;/code&gt; defeated by the &lt;code&gt;USER&lt;/code&gt; redaction — that makes three. In all three the&lt;br&gt;
finding came not from the data but from how I looked at it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The source for my kernel version isn't published.&lt;/strong&gt; The running kernel is&lt;br&gt;
&lt;code&gt;xnu-12377.161.14&lt;/code&gt;; the newest tag in Apple's open source repository is&lt;br&gt;
&lt;code&gt;xnu-12377.121.6&lt;/code&gt;. The lines I quoted above come from the published tag — same branch,&lt;br&gt;
but not literally my kernel. The numbers matching the reports on my machine closes that&lt;br&gt;
gap, but I would rather write it down than assume it closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I couldn't establish
&lt;/h2&gt;

&lt;p&gt;I could not find where the initial 2 GiB limit comes from in published source. The&lt;br&gt;
kernel takes the group budget in from outside: the&lt;br&gt;
&lt;code&gt;COALITION_LEDGER_SET_LOGICAL_WRITES_LIMIT&lt;/code&gt; operation in &lt;code&gt;bsd/kern/sys_coalition.c&lt;/code&gt;&lt;br&gt;
lands on a &lt;code&gt;coalition_ledger_set_logical_writes_limit(coal, limit)&lt;/code&gt; call. So a&lt;br&gt;
user-space component supplies the starting value, and that component isn't open&lt;br&gt;
source. One thing&lt;br&gt;
worth keeping separate: the same repository records a per-process I/O monitor default&lt;br&gt;
of 20480 MB as &lt;code&gt;IOMON_DEFAULT_LIMIT&lt;/code&gt; — but that is a different ledger. The numbers in&lt;br&gt;
my reports come from the group ledger, and both the report files and the kernel log say&lt;br&gt;
2048 MB for the starting value. I use the number because two sources agree on it; I am&lt;br&gt;
not claiming it is "the default."&lt;/p&gt;

&lt;p&gt;One more thing stayed open. I assumed none of these records leave the machine, and the&lt;br&gt;
settings file both agrees and doesn't.&lt;br&gt;
&lt;code&gt;/Library/Application Support/CrashReporter/DiagnosticMessagesHistory.plist&lt;/code&gt; has&lt;br&gt;
&lt;code&gt;AutoSubmit&lt;/code&gt; and &lt;code&gt;ThirdPartyDataSubmit&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt; — sharing is off. The same&lt;br&gt;
file also carries &lt;code&gt;LastFullSubmissionCalled = 2026-11-10 07:14:43 +0000&lt;/code&gt; and, one&lt;br&gt;
second later, &lt;code&gt;LastFullSubmissionSuccess = 2026-11-10 07:14:44 +0000&lt;/code&gt;. I could not establish&lt;br&gt;
from primary sources what that submission carries, so I am not claiming anything went&lt;br&gt;
anywhere. I am only reporting that the file writes both lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to look at your own machine
&lt;/h2&gt;

&lt;p&gt;These files are readable by the &lt;code&gt;_analyticsusers&lt;/code&gt; group, which means your own user can&lt;br&gt;
read them. Four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ls /Library/Logs/DiagnosticReports&lt;/code&gt; — how many files, how many are genuine crashes
(&lt;code&gt;.ips&lt;/code&gt;), how many are resource records (&lt;code&gt;.diag&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;In one &lt;code&gt;.diag&lt;/code&gt; file, read the &lt;code&gt;Event&lt;/code&gt;, &lt;code&gt;Resource Coalition&lt;/code&gt; and &lt;code&gt;Action taken&lt;/code&gt; lines.
Don't stop at &lt;code&gt;Command&lt;/code&gt;; the budget belongs to &lt;code&gt;Resource Coalition&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Note the &lt;code&gt;Writes limit&lt;/code&gt;. If it is larger than 2147.48 MB, that group has violated
before and had its limit multiplied; divide by four repeatedly to count how often —
assuming the start was 2 GiB. The count belongs to that coalition &lt;strong&gt;instance&lt;/strong&gt;, not
to the application's lifetime: on my machine &lt;code&gt;com.anthropic.claudefordesktop&lt;/code&gt; shows
up under four separate coalition numbers (773, 11637, 17488, 71862), each starting
again at 2147.48 MB. Quitting and relaunching the app resets the counter, and
&lt;code&gt;coalition.c&lt;/code&gt; restarts the 24-hour period on every re-arm too.&lt;/li&gt;
&lt;li&gt;When grouping by day, use &lt;code&gt;End time&lt;/code&gt;, not &lt;code&gt;Date/Time&lt;/code&gt; and not the filename.&lt;/li&gt;
&lt;li&gt;If the group shows only a number — 15 of my 33 write reports do — the one practical
route is matching the kernel log's &lt;code&gt;Triggered by process [pid]&lt;/code&gt; against &lt;code&gt;ps&lt;/code&gt; at the
moment it happens. Look later and that pid belongs to somebody else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The conclusion I drew isn't a settings change. All 55 of my reports say&lt;br&gt;
&lt;code&gt;Action taken: none&lt;/code&gt; — but the field being called &lt;code&gt;Action taken&lt;/code&gt; says other actions&lt;br&gt;
are possible; &lt;code&gt;task_policy.c&lt;/code&gt; calls &lt;code&gt;proc_apply_resource_actions&lt;/code&gt; on a CPU violation,&lt;br&gt;
and per-thread limits can raise an exception. None of that fired on my machine. What&lt;br&gt;
changed isn't a switch, it's how I read these files.&lt;/p&gt;

&lt;p&gt;There is nothing to blame on the CPU side: my machine is doing its own background work&lt;br&gt;
and measuring it against a half-a-core line over a three-minute window. On the write&lt;br&gt;
side, ownership matters more than the number. A report doesn't say "&lt;code&gt;git&lt;/code&gt; wrote too&lt;br&gt;
much," it says "the daily budget of the app group &lt;code&gt;git&lt;/code&gt; was running in is spent" — and&lt;br&gt;
that group exists because I started it. I learned that the things wearing my disk out&lt;br&gt;
most are the tools working on my behalf, and I learned it from a record my own machine&lt;br&gt;
keeps. And that record is tuned to grow a little more generous after every&lt;br&gt;
violation, so whatever makes enough noise gradually stops being seen.&lt;/p&gt;

&lt;p&gt;I have looked into a limit ledger twice more on this blog in the past month, and&lt;br&gt;
honestly both started somewhere close to here: once on&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/commitlimiti-24-gib-ile-astim-kimse-durdurmadi/" rel="noopener noreferrer"&gt;blowing past CommitLimit with nobody stopping me&lt;/a&gt;,&lt;br&gt;
once on&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/bes-sureci-tek-limite-koydum-cekirdek-yalniz-birini-oldurdu/" rel="noopener noreferrer"&gt;only one of five processes under a single limit getting killed&lt;/a&gt;.&lt;br&gt;
The "the limit warns and then does nothing" part was in those pieces too; that isn't&lt;br&gt;
what's new here. Two things are: the owner of the budget is not the process named on&lt;br&gt;
the report, and the budget doesn't shrink when it is broken — it grows. Without the&lt;br&gt;
first you read &lt;code&gt;Command&lt;/code&gt; and chase the wrong process; without the second you take a&lt;br&gt;
137 GB limit for a choice Apple made, when that number was written by that group's&lt;br&gt;
own history.&lt;/p&gt;

&lt;p&gt;Reading a number without asking whose account it was charged to is from the same&lt;br&gt;
family as &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/cpu-kotasi-ortalamaya-bakmaz/" rel="noopener noreferrer"&gt;looking at an average and missing the stall&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/osfmk/kern/coalition.c" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;osfmk/kern/coalition.c&lt;/code&gt;, group I/O violation and the quadrupling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/osfmk/kern/coalition.h" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;osfmk/kern/coalition.h&lt;/code&gt;, &lt;code&gt;COALITION_MAX_LOGICAL_WRITES_LIMIT&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/osfmk/kern/task_policy.c" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;osfmk/kern/task_policy.c&lt;/code&gt;, &lt;code&gt;DEFAULT_CPUMON_PERCENTAGE&lt;/code&gt; and &lt;code&gt;DEFAULT_CPUMON_INTERVAL&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/osfmk/kern/task.c" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;osfmk/kern/task.c&lt;/code&gt;, &lt;code&gt;task_update_logical_writes&lt;/code&gt; and &lt;code&gt;IOMON_DEFAULT_LIMIT&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/bsd/kern/sys_coalition.c" rel="noopener noreferrer"&gt;xnu — &lt;code&gt;bsd/kern/sys_coalition.c&lt;/code&gt;, the &lt;code&gt;COALITION_LEDGER_SET_LOGICAL_WRITES_LIMIT&lt;/code&gt; call path&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/share-analytics-information-mac-apple-mh27990/mac" rel="noopener noreferrer"&gt;Apple Support — share analytics information with Apple on Mac&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>measurement</category>
      <category>observability</category>
      <category>resourcemanagement</category>
    </item>
    <item>
      <title>Not Even Root Can Move a Managed Interrupt</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Tue, 10 Nov 2026 11:35:36 +0000</pubDate>
      <link>https://dev.to/merbayerp/not-even-root-can-move-a-managed-interrupt-12e5</link>
      <guid>https://dev.to/merbayerp/not-even-root-can-move-a-managed-interrupt-12e5</guid>
      <description>&lt;p&gt;Back in April I wrote a &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/linux-softirq-saturasyonu-ve-irq-affinity-runbooku/" rel="noopener noreferrer"&gt;SoftIRQ saturation and IRQ affinity runbook&lt;/a&gt; on this blog. Its third section covers what to do when interrupts pile onto one core: choose cores according to the card's queue count, map the IRQs onto those cores, verify the distribution with &lt;code&gt;/proc/interrupts&lt;/code&gt;. There's also a sentence saying deliberate pinning is the safer option on critical production nodes.&lt;/p&gt;

&lt;p&gt;Seven months later I ran that step end to end for the first time on my own machine, the server that hosts this blog. On twenty of the forty interrupt lines &lt;strong&gt;the mapping step isn't possible at all&lt;/strong&gt;: the mask I write as root is refused, and the error that comes back says "I/O error" — as though something were wrong with a cable. On the other twenty, pinning works, and it works instantly. What doesn't work is the move people reach for first: widening the mask.&lt;/p&gt;

&lt;p&gt;This article is the measurement of those two halves. In an October piece about &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/rq-affinity-0-ile-1-ayni-cikti-tamamlamayi-yalniz-2-tasidi/" rel="noopener noreferrer"&gt;&lt;code&gt;rq_affinity&lt;/code&gt;&lt;/a&gt; I dropped a side observation that "all the interrupts land on one core" — that experiment ran on a different VM with 14 cores and a single hardware queue, and the subject there was which ledger completions get written into. I never looked at why the interrupt itself wouldn't move. I'm looking now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine and the method
&lt;/h2&gt;

&lt;p&gt;The measurements were taken on 10 November 2026 on the server hosting this blog: Ubuntu 24.04.4 LTS, kernel &lt;code&gt;6.8.0-147-generic&lt;/code&gt;, 18 vCPUs, QEMU/KVM (i440FX), storage on &lt;code&gt;virtio-scsi&lt;/code&gt; (PCI 00:05.0), network on &lt;code&gt;virtio-net&lt;/code&gt; (PCI 00:12.0), up for three weeks and one day. The &lt;code&gt;irqbalance&lt;/code&gt; service is &lt;strong&gt;inactive&lt;/strong&gt; — so there's no background process quietly balancing interrupts.&lt;/p&gt;

&lt;p&gt;Since I didn't want to play with routing on a live server, the work started with a side-effect-free probe: write &lt;strong&gt;each line's own current mask&lt;/strong&gt; back into itself. The value is identical, so nothing changes; if the write is refused, the &lt;code&gt;errno&lt;/code&gt; comes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; d &lt;span class="k"&gt;in&lt;/span&gt; /proc/irq/&lt;span class="o"&gt;[&lt;/span&gt;0-9&lt;span class="o"&gt;]&lt;/span&gt;*&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;cur&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;cat &lt;span class="s2"&gt;"$d/smp_affinity"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;err&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"$cur"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"$d/smp_affinity"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-4s WRITE_OK\n'&lt;/span&gt; &lt;span class="s2"&gt;"${d##*/}"&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-4s WRITE_FAIL %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"${d##*/}"&lt;/span&gt; &lt;span class="s2"&gt;"$err"&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distributions were counted by hand too; subtracting the columns of a &lt;code&gt;/proc/interrupts&lt;/code&gt; line between two snapshots is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;grep -E &lt;span class="s1"&gt;'^ *46:'&lt;/span&gt; /proc/interrupts&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; sleep 20&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;grep -E &lt;span class="s1"&gt;'^ *46:'&lt;/span&gt; /proc/interrupts&lt;span class="o"&gt;)&lt;/span&gt;
awk -v &lt;span class="nv"&gt;A&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$a"&lt;/span&gt; -v &lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$b"&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN{ split(A,x," "); split(B,y," ");
  for(i=2;i&amp;lt;=19;i++){ d=y[i]-x[i]; if(d&amp;gt;0) printf "CPU%d=%d ", i-2, d } print "" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two side effects, stated up front. First: on four old ISA lines with no driver attached (3, 5, 7, 13), &lt;code&gt;effective_affinity_list&lt;/code&gt; read &lt;code&gt;0&lt;/code&gt; before the probe and &lt;code&gt;0-17&lt;/code&gt; after it. The reason sits in &lt;code&gt;kernel/irq/manage.c&lt;/code&gt;, two lines above the &lt;code&gt;if&lt;/code&gt; block quoted later on: &lt;code&gt;irq_set_affinity_deactivated()&lt;/code&gt; copies the mask and updates the effective mask with it, without calling the chip driver at all, for an interrupt that isn't activated yet. Second: during the migration experiment further down, the NIC's receive interrupt spent a while on CPU3; when the experiment finished I put it back where it was, on CPU9, and verified the final state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty of forty refused the write
&lt;/h2&gt;

&lt;p&gt;The result came out surprisingly sharp:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Which ones&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WRITE_FAIL&lt;/code&gt; (I/O error)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0, 2, and the eighteen &lt;code&gt;virtio1-request&lt;/code&gt; lines from 27 to 44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WRITE_OK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1, 3-15, 24, 25, 26, 45, 46, 47&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eighteen of the twenty refusals belong to a single device: &lt;code&gt;virtio1-request&lt;/code&gt;, the request queues of the virtio-scsi controller. Each one's mask is a single CPU — IRQ 27 only CPU0, IRQ 28 only CPU1, and so on to IRQ 44 on CPU17.&lt;/p&gt;

&lt;p&gt;My first instinct was that the refusal might be because the value was identical. It isn't: I wrote &lt;code&gt;3&lt;/code&gt; (CPU0 and CPU1) to IRQ 27 instead of its current mask, got the same error, and &lt;code&gt;smp_affinity_list&lt;/code&gt; still read &lt;code&gt;0&lt;/code&gt;. The refusal is about the line's identity, not the value.&lt;/p&gt;

&lt;p&gt;The remaining two lines are 0 and 2, the timer and the cascade. The gate in the code actually checks three things at once — that the line can be balanced, that its affinity isn't kernel-managed, and that &lt;code&gt;noirqaffinity&lt;/code&gt; wasn't passed at boot — and I could not determine by measurement which of them these two tripped. Why I couldn't comes later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why eighteen lines refuse
&lt;/h2&gt;

&lt;p&gt;The answer is in the block layer. Under &lt;code&gt;sda&lt;/code&gt;'s &lt;code&gt;mq&lt;/code&gt; directory there are eighteen hardware queues, each with a &lt;code&gt;cpu_list&lt;/code&gt; of exactly one core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/sys/block/sda/mq/0/cpu_list  → 0    (nr_tags=256)
/sys/block/sda/mq/1/cpu_list  → 1    (nr_tags=256)
...
/sys/block/sda/mq/17/cpu_list → 17   (nr_tags=256)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every core has its own queue and every queue its own interrupt vector. The driver asked for that arrangement: when &lt;code&gt;drivers/scsi/virtio_scsi.c&lt;/code&gt; looks up its queues it hands the kernel a placement descriptor (&lt;code&gt;struct irq_affinity desc = { .pre_vectors = 2 }&lt;/code&gt;), and in response the virtio-pci layer adds the &lt;code&gt;PCI_IRQ_AFFINITY&lt;/code&gt; flag and calls &lt;code&gt;pci_alloc_irq_vectors_affinity()&lt;/code&gt;. The kernel's PCI documentation describes what that flag does in one sentence: it spreads the interrupts around the available CPUs.&lt;/p&gt;

&lt;p&gt;Interrupts distributed this way are what the kernel calls &lt;strong&gt;managed&lt;/strong&gt;, and it deliberately blocks user space from touching them. The write handler in &lt;code&gt;kernel/irq/proc.c&lt;/code&gt; closes the door on its first line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;irq_can_set_affinity_usr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;irq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;no_irq_affinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="no"&gt;EIO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;irq_can_set_affinity_usr()&lt;/code&gt; in &lt;code&gt;kernel/irq/manage.c&lt;/code&gt; combines two conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;irq_can_set_affinity_usr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;irq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;irq_desc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;irq_to_desc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;irq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;__irq_can_set_affinity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;irqd_affinity_is_managed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;irq_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also explains the most confusing part of the table: on the same machine the &lt;code&gt;virtio1&lt;/code&gt; lines refuse while the &lt;code&gt;virtio2&lt;/code&gt; lines accept, even though both are virtio. The difference is in the driver. The network driver, &lt;code&gt;drivers/net/virtio_net.c&lt;/code&gt;, hands over no such descriptor when it looks up its queues — the &lt;code&gt;irq_affinity&lt;/code&gt; struct doesn't appear anywhere in the file — and writes the placement itself through &lt;code&gt;virtnet_set_affinity()&lt;/code&gt;. With no descriptor there's no &lt;code&gt;PCI_IRQ_AFFINITY&lt;/code&gt;, the interrupt isn't considered managed, and the procfs door stays open. So there's no rule saying "modern device, therefore managed"; what decides is whether the driver handed over that descriptor.&lt;/p&gt;

&lt;p&gt;That has a further practical consequence: a mask you write by hand onto the network interrupts can be rewritten by the driver's own &lt;code&gt;virtnet_set_affinity()&lt;/code&gt; call when the queue count changes or a core goes offline. Your pinning isn't a lasting contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why managed interrupts exist, and how that reaches you
&lt;/h2&gt;

&lt;p&gt;I could have waved this away as "don't break the design," but the kernel's own rationale is more concrete. &lt;code&gt;managed_irq.rst&lt;/code&gt;, added to the documentation in April 2026, explains it: an NVMe device with 128 queues typically requests one interrupt per queue, two such devices make 256 interrupts, and on x86 there are only 256 vectors per CPU with some already reserved. Normally this isn't a problem, because the interrupts are spread across cores — but &lt;strong&gt;when the system suspends&lt;/strong&gt;, every secondary CPU is taken offline and all interrupts migrate to the one core still running. That's where the vector space runs out and the suspend fails. Managed interrupts exist precisely to prevent that migration.&lt;/p&gt;

&lt;p&gt;The operational face of this is in the same document: when a core in the mask goes offline the interrupt moves to the next core in the mask, and &lt;strong&gt;when the last core in the mask goes down the interrupt is shut down&lt;/strong&gt;, with the driver required to have quiesced the matching queue. On my machine each queue's mask is a single core, so that "last core" case is the same thing as taking that core offline.&lt;/p&gt;

&lt;p&gt;The same page also gives the supported way to keep managed interrupts away from particular cores: &lt;code&gt;isolcpus=managed_irq,&amp;lt;mask&amp;gt;&lt;/code&gt; at boot. It's best-effort — if the automatically computed mask has no online core outside the avoided set, the setting has no effect. So when a managed interrupt lands on a hot spot you aren't empty-handed; the lever just isn't in &lt;code&gt;/proc&lt;/code&gt;, it's on the boot line.&lt;/p&gt;

&lt;h2&gt;
  
  
  "I/O error" was the wrong word, and the kernel agreed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EIO&lt;/code&gt; — "Input/output error" — makes an administrator think of hardware trouble. What's actually happening is a permission matter. Others noticed before me.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;irqbalance&lt;/code&gt;. It's the software that actually writes these files, and it translates the error internally; in &lt;code&gt;activate.c&lt;/code&gt; the first line of the write-error branch reads, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="cm"&gt;/* Use EPERM as the explaination for EIO */&lt;/span&gt;
    &lt;span class="n"&gt;errsave&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errsave&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;EIO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="no"&gt;EPERM&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;errsave&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's more: if the error is in the permanent class, &lt;code&gt;irqbalance&lt;/code&gt; drops that interrupt to &lt;code&gt;BALANCE_NONE&lt;/code&gt;, logs "IRQ %i affinity is now unmanaged", and never bothers with it again. On my server it's off anyway; had it been running, it would have quietly given up on those twenty lines.&lt;/p&gt;

&lt;p&gt;Second, the kernel itself. Two patches landed a day apart in August 2024, and both shipped in &lt;code&gt;v6.12&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eb29369fa543&lt;/code&gt; changed the returned error from &lt;code&gt;EIO&lt;/code&gt; to &lt;code&gt;EPERM&lt;/code&gt;; the commit message states the rationale plainly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;c7718e5c76d4&lt;/code&gt; is the more radical one: the kernel already knows at allocation time whether an interrupt can be set from user space, so it should create the procfs files for the ones that can't as read-only (&lt;code&gt;0444&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The consequence of the second patch: on a newer kernel a single &lt;code&gt;ls -l&lt;/code&gt; shows which interrupts you can't move. On my 6.8 it doesn't, because &lt;strong&gt;all forty&lt;/strong&gt; files are &lt;code&gt;0644&lt;/code&gt;. The twenty files that refuse every write look exactly as writable as the rest.&lt;/p&gt;

&lt;p&gt;Rather than assume the version map, I scanned it. The value returned in &lt;code&gt;kernel/irq/proc.c&lt;/code&gt; is &lt;code&gt;EIO&lt;/code&gt; on &lt;code&gt;v6.9&lt;/code&gt;, &lt;code&gt;v6.10&lt;/code&gt; and &lt;code&gt;v6.11&lt;/code&gt;, and &lt;code&gt;EPERM&lt;/code&gt; from &lt;code&gt;v6.12&lt;/code&gt; through &lt;code&gt;v7.3&lt;/code&gt; and on &lt;code&gt;master&lt;/code&gt;. Then I saw the fixed behaviour in a lab: an Ubuntu 25.10 VM on my Mac running kernel &lt;code&gt;6.17.0-40-generic&lt;/code&gt;, where &lt;strong&gt;16 of its 36&lt;/strong&gt; interrupt lines are mode &lt;code&gt;0444&lt;/code&gt;, the block devices' request queues among them. Writing a line's own mask to one of those as root gets you &lt;code&gt;Operation not permitted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A small aside: that fix also made &lt;code&gt;irqbalance&lt;/code&gt;'s translation line redundant. The kernel now returns &lt;code&gt;EPERM&lt;/code&gt; directly, and since the file is read-only the write usually isn't even attempted.&lt;/p&gt;

&lt;p&gt;The diagnosis — the answer to "why won't this line move?" — is two years and one LTS generation away from my server's kernel. On the same machine &lt;code&gt;apt-cache policy linux-generic-hwe-24.04&lt;/code&gt; currently offers &lt;code&gt;7.0.0-42&lt;/code&gt; as the candidate. One package, but jumping a kernel generation on a production server isn't a decision you make for the convenience of &lt;code&gt;ls -l&lt;/code&gt;, and I didn't make it for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twenty that accept: a narrow mask moves them, a wide mask does nothing
&lt;/h2&gt;

&lt;p&gt;Now the other half. On all twenty lines that accept the write, &lt;code&gt;smp_affinity_list&lt;/code&gt; reads &lt;code&gt;0-17&lt;/code&gt; — on paper, "this interrupt may land on any of eighteen cores." But &lt;code&gt;effective_affinity_list&lt;/code&gt; on those lines shows a single core, and the counters back the effective mask. Three 20-second runs on the two busiest lines, the network card's receive and transmit queues:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;IRQ 46 (&lt;code&gt;virtio2-input.0&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;IRQ 47 (&lt;code&gt;virtio2-output.0&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5,152 interrupts → CPU9 (100%)&lt;/td&gt;
&lt;td&gt;4,895 interrupts → CPU10 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;7,133 interrupts → CPU9 (100%)&lt;/td&gt;
&lt;td&gt;7,677 interrupts → CPU10 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9,855 interrupts → CPU9 (100%)&lt;/td&gt;
&lt;td&gt;10,281 interrupts → CPU10 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;44,993 interrupts in total, with seventeen columns reading exactly zero for each line. In the same minute the local timer interrupt &lt;code&gt;LOC&lt;/code&gt; was spread across all eighteen cores, so the machine is running multi-core.&lt;/p&gt;

&lt;p&gt;So what happens if I narrow the mask? I didn't want to write this up without measuring it, because that's the real question here. I wrote &lt;code&gt;8&lt;/code&gt; (CPU3 only) to IRQ 46, then &lt;code&gt;200&lt;/code&gt; (CPU9 only), counting for ten seconds at each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;&lt;code&gt;smp_affinity_list&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;effective_affinity_list&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;interrupts in 10 s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A — wide mask&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0-17&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,093, all on CPU3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B — wrote &lt;code&gt;200&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,694, all on CPU9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C — back to &lt;code&gt;3ffff&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0-17&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,565, all on CPU9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rows, three separate statements. &lt;strong&gt;B&lt;/strong&gt;: a narrow mask works, and it works instantly — by the first read after writing the file, the effective core had already changed. On a line taking a few hundred interrupts a second there's nothing to wait for. &lt;strong&gt;C&lt;/strong&gt;: reopening the mask to eighteen cores does nothing at all; the interrupt stays on CPU9, neither spreading nor being re-picked. &lt;strong&gt;A&lt;/strong&gt;: it was already sitting on CPU3 with a wide mask, because I had put it there in the previous step and nobody moved it back — &lt;code&gt;irqbalance&lt;/code&gt; is off, and the kernel has no business redistributing on its own.&lt;/p&gt;

&lt;p&gt;That lets me rename the mask properly: &lt;code&gt;smp_affinity&lt;/code&gt; is a &lt;strong&gt;candidate set&lt;/strong&gt;, not a distribution order. An interrupt delivered over MSI-X goes to a single target; the kernel picks one core out of the set you gave it. Narrow the set and you force the choice — that works. Widen it and you've asked for nothing. April's runbook saying "deliberate pinning" turns out to have been right for exactly this reason; what's wrong is the instinct that opening the mask splits the load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exception the floppy drive taught me
&lt;/h2&gt;

&lt;p&gt;There is a case where the migration really doesn't happen, and hardware this machine doesn't have showed it to me. IRQ 6 belongs to &lt;code&gt;floppy&lt;/code&gt;: there's no floppy drive, the line is there, and since boot — three weeks and a day — it has counted &lt;strong&gt;three&lt;/strong&gt; interrupts, all on CPU16.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start:       smp_affinity_list=0-17   effective_affinity_list=16
echo f  → OK smp_affinity_list=0-3    effective_affinity_list=16
echo 2  → OK smp_affinity_list=1      effective_affinity_list=16
restore → OK smp_affinity_list=0-17   effective_affinity_list=16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three writes succeed, and the mask file reads back exactly what I wrote each time. The effective core doesn't move: CPU16, CPU16, CPU16. What happened instantly on IRQ 46 never happens here, because the difference isn't in the write, it's in the interrupt.&lt;/p&gt;

&lt;p&gt;The mechanism is written down in &lt;code&gt;kernel/irq/manage.c&lt;/code&gt;. The write request lands in &lt;code&gt;irq_set_affinity_locked()&lt;/code&gt;, and if the chip cannot move the interrupt in process context, the change isn't applied immediately, it's put pending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;irq_can_move_pcntxt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;irqd_is_setaffinity_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;irq_try_set_affinity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;irqd_set_move_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;irq_copy_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The place where a pending move is applied is the path the interrupt itself travels: &lt;code&gt;apic_ack_edge()&lt;/code&gt; in &lt;code&gt;arch/x86/kernel/apic/vector.c&lt;/code&gt; calls &lt;code&gt;irq_complete_move()&lt;/code&gt; and then goes on to &lt;code&gt;apic_ack_irq()&lt;/code&gt;; the &lt;code&gt;irq_move_irq()&lt;/code&gt; call lives there, and the pending mask finally takes effect in &lt;code&gt;irq_move_masked_irq()&lt;/code&gt; in &lt;code&gt;kernel/irq/migration.c&lt;/code&gt;. In other words, &lt;strong&gt;an interrupt migrates to its new core only the next time it fires.&lt;/strong&gt; On a network queue taking hundreds of interrupts a second that takes microseconds; on a line that fired three times in three weeks, when "next time" happens is anybody's guess.&lt;/p&gt;

&lt;p&gt;So why did the mask file show what I wrote? That's in &lt;code&gt;proc.c&lt;/code&gt;: if a move is pending, procfs prints the pending mask instead of the real one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;        &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;irq_common_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;affinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cp"&gt;#ifdef CONFIG_GENERIC_PENDING_IRQ&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;irqd_is_setaffinity_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;irq_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pending_mask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That display is still in today's &lt;code&gt;master&lt;/code&gt;, only the helper names have changed. The decision gate itself, though, has not stayed put: &lt;code&gt;v6.14&lt;/code&gt; introduced the &lt;code&gt;IRQCHIP_MOVE_DEFERRED&lt;/code&gt; flag (&lt;code&gt;a648eb3a3f79&lt;/code&gt;) and x86 was converted to that model (&lt;code&gt;7d04319a05ab&lt;/code&gt;). Deferral is now a property of the chip rather than a per-interrupt marking, and on &lt;code&gt;master&lt;/code&gt; the IO-APIC and MSI chips carry the flag. The "applied immediately if remapping is present" distinction that holds on my 6.8 doesn't hold in the same shape on newer kernels.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVt3cml0ZSB0byBzbXBfYWZmaW5pdHldIC0tPiBCe2lycV9jYW5fc2V0X2FmZmluaXR5X3Vzcj99CiAgQiAtLSBubzogbWFuYWdlZCw8YnIvPnVuYmFsYW5jZWFibGUgb3I8YnIvPm5vaXJxYWZmaW5pdHkgLS0-IENbRUlPIG9uIHY2LjggLyBFUEVSTSBvbiB2Ni4xMis8YnIvPm5vdGhpbmcgY2hhbmdlc10KICBCIC0tIHllcyAtLT4gRHtjYW4gdGhlIGNoaXAgbW92ZSBpdDxici8-aW4gcHJvY2VzcyBjb250ZXh0P30KICBEIC0tIHllcyAtLT4gRVthcHBseSBub3c8YnIvPmVmZmVjdGl2ZV9hZmZpbml0eSB1cGRhdGVkXQogIEQgLS0gbm8gLS0-IEZbbWFyayBwZW5kaW5nPGJyLz5wZW5kaW5nX21hc2tdCiAgRiAtLT4gR1twcm9jZnMgbm93IHNob3dzPGJyLz5wZW5kaW5nX21hc2tdCiAgRiAtLT4gSHtkb2VzIHRoZSBpbnRlcnJ1cHQ8YnIvPmZpcmUgYWdhaW4_fQogIEggLS0geWVzOiBidXN5IGxpbmUgLS0-IEUKICBIIC0tIG5vOiBxdWlldCBsaW5lIC0tPiBJW21vdmUgbmV2ZXIgaGFwcGVuczxici8-ZWZmZWN0aXZlX2FmZmluaXR5IGZyb3plbl0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVt3cml0ZSB0byBzbXBfYWZmaW5pdHldIC0tPiBCe2lycV9jYW5fc2V0X2FmZmluaXR5X3Vzcj99CiAgQiAtLSBubzogbWFuYWdlZCw8YnIvPnVuYmFsYW5jZWFibGUgb3I8YnIvPm5vaXJxYWZmaW5pdHkgLS0-IENbRUlPIG9uIHY2LjggLyBFUEVSTSBvbiB2Ni4xMis8YnIvPm5vdGhpbmcgY2hhbmdlc10KICBCIC0tIHllcyAtLT4gRHtjYW4gdGhlIGNoaXAgbW92ZSBpdDxici8-aW4gcHJvY2VzcyBjb250ZXh0P30KICBEIC0tIHllcyAtLT4gRVthcHBseSBub3c8YnIvPmVmZmVjdGl2ZV9hZmZpbml0eSB1cGRhdGVkXQogIEQgLS0gbm8gLS0-IEZbbWFyayBwZW5kaW5nPGJyLz5wZW5kaW5nX21hc2tdCiAgRiAtLT4gR1twcm9jZnMgbm93IHNob3dzPGJyLz5wZW5kaW5nX21hc2tdCiAgRiAtLT4gSHtkb2VzIHRoZSBpbnRlcnJ1cHQ8YnIvPmZpcmUgYWdhaW4_fQogIEggLS0geWVzOiBidXN5IGxpbmUgLS0-IEUKICBIIC0tIG5vOiBxdWlldCBsaW5lIC0tPiBJW21vdmUgbmV2ZXIgaGFwcGVuczxici8-ZWZmZWN0aXZlX2FmZmluaXR5IGZyb3plbl0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="642" height="1420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I can't ask the kernel this question
&lt;/h2&gt;

&lt;p&gt;Earlier I said I couldn't determine which condition made those two lines refuse. Here's why. The kernel has a debug interface that exposes per-interrupt flags, but it depends on a build option, and my server's configuration has it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# CONFIG_GENERIC_IRQ_DEBUGFS is not set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's why there's no &lt;code&gt;/sys/kernel/debug/irq&lt;/code&gt; directory — debugfs is mounted, there are forty-odd other directories inside it, this one isn't there. So on 6.8 the only answer I can get to "is this interrupt managed?" is the &lt;code&gt;errno&lt;/code&gt; of a write attempt. The question isn't free: to get the answer you have to touch the thing. That's why the probe had to be side-effect-free. And the kernel's own documentation recommends exactly this debugfs file for confirming a managed interrupt's state — a recipe that doesn't run on my box as long as the distribution doesn't compile it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documentation: one page from 2011, one from this April
&lt;/h2&gt;

&lt;p&gt;The page that comes to mind first for this knob is &lt;code&gt;Documentation/core-api/irq/irq-affinity.rst&lt;/code&gt;. Seventy lines, and reading it surprised me twice.&lt;/p&gt;

&lt;p&gt;First, its example: on an eight-CPU box the &lt;code&gt;IO-APIC-level eth1&lt;/code&gt; line spreads interrupts across CPU0-3 once the mask is set to &lt;code&gt;0f&lt;/code&gt; (1068, 1785, 1785, 1783), and moves to the other four with &lt;code&gt;f0&lt;/code&gt;. On my machine, opening the mask spreads nothing. Both are correct — one describes an IO-APIC level-triggered card, the other a virtio device using MSI-X — but the document doesn't help you tell which situation you're in. Second, the omissions: neither the &lt;code&gt;effective_affinity&lt;/code&gt; file nor managed interrupts appear on that page. The only sentence saying writes aren't entirely free is the warning that you're not allowed to switch off all CPUs.&lt;/p&gt;

&lt;p&gt;The page's stillness got measured too: the difference between &lt;code&gt;v6.8&lt;/code&gt; and today's &lt;code&gt;master&lt;/code&gt; is four lines — "cpu" capitalised to "CPU", and a missing &lt;code&gt;grep&lt;/code&gt; restored in the example. The last addition to its content was the &lt;code&gt;smp_affinity_list&lt;/code&gt; paragraph in 2011; all five touches since then were spelling, formatting and a move.&lt;/p&gt;

&lt;p&gt;But ending the story there would be unfair, because the kernel closed this gap seven months ago. On 1 April 2026, commit &lt;code&gt;bb6a85b4b652&lt;/code&gt; added &lt;strong&gt;a new page in the same section&lt;/strong&gt;: &lt;code&gt;managed_irq.rst&lt;/code&gt;, listed directly beneath &lt;code&gt;irq-affinity&lt;/code&gt;. Both of this article's findings are in it: that the affinity of managed interrupts is handled entirely in the kernel and cannot be modified through the &lt;code&gt;/proc&lt;/code&gt; interfaces, and example output where &lt;code&gt;smp_affinity_list&lt;/code&gt; and &lt;code&gt;effective_affinity_list&lt;/code&gt; diverge. The example hardware is too familiar to be a coincidence: a QEMU instance booted with &lt;code&gt;-device virtio-scsi-pci&lt;/code&gt;, three management interrupts and one interrupt per queue.&lt;/p&gt;

&lt;p&gt;The only problem is that the page doesn't exist where I work. It's absent in &lt;code&gt;v7.0&lt;/code&gt; and present from &lt;code&gt;v7.1&lt;/code&gt; on. The official account of managed interrupts is three generations ahead of the kernel on this server. The knowledge exists; it just isn't on the page I was reading or in the kernel I'm running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the real lever is on this machine
&lt;/h2&gt;

&lt;p&gt;The measurement showed that the "open the mask, let the load spread" move is empty on this box. The right doors got measured as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The network side.&lt;/strong&gt; &lt;code&gt;ethtool -l eth0&lt;/code&gt; says one thing: &lt;code&gt;Combined: 1&lt;/code&gt;, with a pre-set maximum of &lt;code&gt;1&lt;/code&gt;. The device has a single queue pair; there's no second interrupt vector to spread. Fiddling with interrupt affinity on a card like that is like looking for a second cashier in a shop with one till. If the load genuinely saturates one core, the lever is on the software side: RPS. That's off too — &lt;code&gt;/sys/class/net/eth0/queues/rx-0/rps_cpus&lt;/code&gt; reads &lt;code&gt;00000&lt;/code&gt;. I haven't touched it, because I have not measured a bottleneck right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The block side.&lt;/strong&gt; Nothing to do there, because it's already done. The sixty-second delta for the eighteen &lt;code&gt;virtio1-request&lt;/code&gt; lines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;line&lt;/th&gt;
&lt;th&gt;interrupts&lt;/th&gt;
&lt;th&gt;landed on&lt;/th&gt;
&lt;th&gt;line&lt;/th&gt;
&lt;th&gt;interrupts&lt;/th&gt;
&lt;th&gt;landed on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IRQ27&lt;/td&gt;
&lt;td&gt;1,350&lt;/td&gt;
&lt;td&gt;CPU0&lt;/td&gt;
&lt;td&gt;IRQ36&lt;/td&gt;
&lt;td&gt;934&lt;/td&gt;
&lt;td&gt;CPU9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ28&lt;/td&gt;
&lt;td&gt;1,259&lt;/td&gt;
&lt;td&gt;CPU1&lt;/td&gt;
&lt;td&gt;IRQ37&lt;/td&gt;
&lt;td&gt;883&lt;/td&gt;
&lt;td&gt;CPU10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ29&lt;/td&gt;
&lt;td&gt;1,122&lt;/td&gt;
&lt;td&gt;CPU2&lt;/td&gt;
&lt;td&gt;IRQ38&lt;/td&gt;
&lt;td&gt;1,095&lt;/td&gt;
&lt;td&gt;CPU11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ30&lt;/td&gt;
&lt;td&gt;1,111&lt;/td&gt;
&lt;td&gt;CPU3&lt;/td&gt;
&lt;td&gt;IRQ39&lt;/td&gt;
&lt;td&gt;1,128&lt;/td&gt;
&lt;td&gt;CPU12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ31&lt;/td&gt;
&lt;td&gt;1,396&lt;/td&gt;
&lt;td&gt;CPU4&lt;/td&gt;
&lt;td&gt;IRQ40&lt;/td&gt;
&lt;td&gt;1,131&lt;/td&gt;
&lt;td&gt;CPU13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ32&lt;/td&gt;
&lt;td&gt;670&lt;/td&gt;
&lt;td&gt;CPU5&lt;/td&gt;
&lt;td&gt;IRQ41&lt;/td&gt;
&lt;td&gt;1,120&lt;/td&gt;
&lt;td&gt;CPU14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ33&lt;/td&gt;
&lt;td&gt;1,320&lt;/td&gt;
&lt;td&gt;CPU6&lt;/td&gt;
&lt;td&gt;IRQ42&lt;/td&gt;
&lt;td&gt;1,058&lt;/td&gt;
&lt;td&gt;CPU15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ34&lt;/td&gt;
&lt;td&gt;1,364&lt;/td&gt;
&lt;td&gt;CPU7&lt;/td&gt;
&lt;td&gt;IRQ43&lt;/td&gt;
&lt;td&gt;640&lt;/td&gt;
&lt;td&gt;CPU16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRQ35&lt;/td&gt;
&lt;td&gt;1,123&lt;/td&gt;
&lt;td&gt;CPU8&lt;/td&gt;
&lt;td&gt;IRQ44&lt;/td&gt;
&lt;td&gt;676&lt;/td&gt;
&lt;td&gt;CPU17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;19,380 interrupts in total, not one of the eighteen lines saw a second core, and the core each landed on matches its &lt;code&gt;cpu_list&lt;/code&gt; exactly. It's the mirror image of the network table's "mask of eighteen, reality of one": here the mask is one and reality is one, and because the two agree, nobody gets confused.&lt;/p&gt;

&lt;p&gt;The list I took away for anyone who has to look at this knob in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read &lt;code&gt;effective_affinity&lt;/code&gt; first.&lt;/strong&gt; &lt;code&gt;smp_affinity&lt;/code&gt; is intent, &lt;code&gt;effective_affinity&lt;/code&gt; is state. Without seeing the gap it's very easy to conclude "the mask is wide, so it must be spreading."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with the counters.&lt;/strong&gt; Learn how many cores a line lands on from the &lt;code&gt;/proc/interrupts&lt;/code&gt; delta, not from the mask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On kernel 6.12 or newer, run &lt;code&gt;ls -l /proc/irq/*/smp_affinity&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;0444&lt;/code&gt; ones can't be moved, and asking costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On anything older, probe with each line's own mask.&lt;/strong&gt; The value is identical so nothing changes, and the &lt;code&gt;errno&lt;/code&gt; still arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you want spreading, widening the mask won't give it to you.&lt;/strong&gt; The measurement is unambiguous: a wide mask neither distributes nor re-picks. Spreading comes from queue count (&lt;code&gt;ethtool -L&lt;/code&gt;, the device's &lt;code&gt;num_queues&lt;/code&gt;) or from RPS/XPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't fight the lines that refuse; the right lever is the boot line.&lt;/strong&gt; &lt;code&gt;isolcpus=managed_irq,&amp;lt;mask&amp;gt;&lt;/code&gt; tries to keep managed interrupts off specific cores, and &lt;code&gt;irqaffinity=&lt;/code&gt; sets the default for the management interrupts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the cost of offlining a core under managed interrupts.&lt;/strong&gt; When the last core in the mask goes offline, the interrupt is shut down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A change on a quiet line may never be applied.&lt;/strong&gt; The pending move waits for the next interrupt; if it doesn't come, nothing happens.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;What I take from this is bigger than one knob. Under &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;/sys&lt;/code&gt; there are two kinds of file: the ones holding what I asked for, and the ones holding what the system is doing. Most of the time they show the same number, so we treat them as one file. The gap between &lt;code&gt;smp_affinity&lt;/code&gt; and &lt;code&gt;effective_affinity&lt;/code&gt; is exactly this, and it only surfaces when things aren't ordinary — while a move is pending, when the interrupt never fires, when the device distributed its own queues. Which is precisely when you reached for the setting.&lt;/p&gt;

&lt;p&gt;As for April's runbook: the "deliberate pinning" advice came out of the measurement intact — a narrow mask genuinely works, and works instantly. What was missing is &lt;strong&gt;where that advice cannot be applied&lt;/strong&gt; — on the interrupts of a driver that asks for a vector per queue, the step never starts, and it tells you so with an "I/O error". The me of seven months ago didn't know that, because I hadn't run that article all the way through on my own server.&lt;/p&gt;

&lt;p&gt;And third: the kernel wasn't wrong here, it had just picked the wrong word; it fixed the word two years ago and documented the gap this April. Both are ahead of the version I'm running. Getting the name of an error right does as much work as adding a feature — provided you can reach the version where that name appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.kernel.org/doc/html/latest/core-api/irq/irq-affinity.html" rel="noopener noreferrer"&gt;SMP IRQ affinity — Linux kernel documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kernel.org/doc/html/latest/core-api/irq/managed_irq.html" rel="noopener noreferrer"&gt;Affinity managed interrupts — &lt;code&gt;managed_irq.rst&lt;/code&gt; (added April 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kernel.org/doc/html/latest/PCI/msi-howto.html" rel="noopener noreferrer"&gt;MSI howto: &lt;code&gt;pci_alloc_irq_vectors_affinity&lt;/code&gt; and &lt;code&gt;PCI_IRQ_AFFINITY&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/e8f897f4afef0031fe618a8e94127a0934896aba/kernel/irq/proc.c" rel="noopener noreferrer"&gt;&lt;code&gt;kernel/irq/proc.c&lt;/code&gt; — &lt;code&gt;write_irq_affinity&lt;/code&gt; and &lt;code&gt;show_irq_affinity&lt;/code&gt; (v6.8)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/e8f897f4afef0031fe618a8e94127a0934896aba/kernel/irq/manage.c" rel="noopener noreferrer"&gt;&lt;code&gt;kernel/irq/manage.c&lt;/code&gt; — &lt;code&gt;irq_can_set_affinity_usr&lt;/code&gt; and the pending move (v6.8)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/e8f897f4afef0031fe618a8e94127a0934896aba/drivers/scsi/virtio_scsi.c" rel="noopener noreferrer"&gt;&lt;code&gt;drivers/scsi/virtio_scsi.c&lt;/code&gt; — &lt;code&gt;struct irq_affinity desc&lt;/code&gt; (v6.8)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/commit/eb29369fa543" rel="noopener noreferrer"&gt;&lt;code&gt;eb29369fa543&lt;/code&gt; — genirq/proc: change the return value from &lt;code&gt;EIO&lt;/code&gt; to &lt;code&gt;EPERM&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/commit/c7718e5c76d4" rel="noopener noreferrer"&gt;&lt;code&gt;c7718e5c76d4&lt;/code&gt; — genirq/proc: read-only files for interrupts that cannot be set&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/commit/bb6a85b4b652" rel="noopener noreferrer"&gt;&lt;code&gt;bb6a85b4b652&lt;/code&gt; — Documentation: Add managed interrupts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Irqbalance/irqbalance/blob/master/activate.c" rel="noopener noreferrer"&gt;irqbalance &lt;code&gt;activate.c&lt;/code&gt; — the branch that logs &lt;code&gt;EIO&lt;/code&gt; as &lt;code&gt;EPERM&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>kernel</category>
      <category>olcum</category>
      <category>sistemyonetimi</category>
    </item>
  </channel>
</rss>
