<?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: The Homelab Postmortem</title>
    <description>The latest articles on DEV Community by The Homelab Postmortem (@homelabpm).</description>
    <link>https://dev.to/homelabpm</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%2F4083338%2Fcd4dae83-adc7-4b01-a553-ba40b4b5e209.jpeg</url>
      <title>DEV Community: The Homelab Postmortem</title>
      <link>https://dev.to/homelabpm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/homelabpm"/>
    <language>en</language>
    <item>
      <title>Ollama: a keep_alive on the loaded model cancels the swap another request waits for</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:49:28 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollama-a-keepalive-on-the-loaded-model-cancels-the-swap-another-request-waits-for-4506</link>
      <guid>https://dev.to/homelabpm/ollama-a-keepalive-on-the-loaded-model-cancels-the-swap-another-request-waits-for-4506</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: With &lt;code&gt;OLLAMA_MAX_LOADED_MODELS=1&lt;/code&gt;, a request for a model that isn't loaded makes Ollama schedule the loaded model to unload as soon as it's idle, and the request waits. If a request for the loaded model arrives during that wait and carries &lt;code&gt;keep_alive&lt;/code&gt;, that value replaces the pending unload: when the loaded model goes idle, Ollama sets a keep-alive timer instead of unloading, and the request for the other model waits for the whole keep_alive. On Ollama 0.40.8 on CPU, the second model answered in 9 seconds when the interfering request had no &lt;code&gt;keep_alive&lt;/code&gt;, and didn't answer within 150 seconds when it had &lt;code&gt;"keep_alive": "30m"&lt;/code&gt;. Nothing errors, and &lt;code&gt;/api/ps&lt;/code&gt; shows a healthy loaded model. Sending &lt;code&gt;keep_alive: 0&lt;/code&gt; for the loaded model released the waiting request in 1.5 seconds. Upstream: &lt;a href="https://github.com/ollama/ollama/issues/19182" rel="noopener noreferrer"&gt;ollama/ollama#19182&lt;/a&gt;, with a fix proposed in &lt;a href="https://github.com/ollama/ollama/pull/19190" rel="noopener noreferrer"&gt;#19190&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container, Ollama 0.40.8 from the GitHub release, CPU only, two small models, and the reporter's settings:&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;OLLAMA_MAX_LOADED_MODELS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;OLLAMA_NUM_PARALLEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nv"&gt;OLLAMA_KEEP_ALIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30m &lt;span class="nv"&gt;OLLAMA_DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;qwen3:0.6b&lt;/code&gt; is loaded. The same three requests as in the report:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a long generation on the loaded model (&lt;code&gt;num_predict&lt;/code&gt; 400),&lt;/li&gt;
&lt;li&gt;three seconds later, a short request for &lt;code&gt;gemma3:270m&lt;/code&gt;, which needs the slot,&lt;/li&gt;
&lt;li&gt;two seconds later, a one-token request for the loaded model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run once with step 3 as a plain request, and once with &lt;code&gt;"keep_alive": "30m"&lt;/code&gt; added to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== control: step 3 without keep_alive
  step3 (resident, no keep_alive) done at +5.129s
  step1 (resident long gen) done at +7.646s
  step2 (other model) http=200 at +9.156s
  ps after: [('gemma3:270m', ...)]
=== step 3 with keep_alive 30m
  step3 (resident, "keep_alive":"30m",) done at +5.113s
  step1 (resident long gen) done at +7.610s
  step2 (other model) http=000 at +153.0s
  ps after: [('qwen3:0.6b', ...)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;http=000&lt;/code&gt; is curl giving up at its 150-second limit. The scheduler's debug log for the second run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sched.go:309 msg="max runners achieved, unloading one to make room"
sched.go:382 msg="resetting model to expire immediately to make room"
sched.go:393 msg="waiting for pending requests to complete and unload to occur"
sched.go:1424 msg="evaluating already loaded"
sched.go:437 msg="runner with non-zero duration has gone idle, adding timer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The healthy run instead ended with &lt;code&gt;runner with zero duration has gone idle, expiring to unload&lt;/code&gt; and &lt;code&gt;unload completed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;The server is fine from every angle you'd check. &lt;code&gt;/api/ps&lt;/code&gt; shows the loaded model, requests for it are answered, and the scheduler messages that show what happened are debug-level (they were visible here with &lt;code&gt;OLLAMA_DEBUG=1&lt;/code&gt;). Only requests for other models hang, and they hang without an error: the client just waits, until its own timeout or the keep_alive runs out.&lt;/p&gt;

&lt;p&gt;The trigger is ordinary client behaviour. Many clients and UIs send &lt;code&gt;keep_alive&lt;/code&gt; on every request; the reporter lost two hours to a client sending &lt;code&gt;"2h"&lt;/code&gt; and thirteen minutes to a health probe. One slot is also the normal setting on a machine whose memory holds one model at a time, which is most homelab GPUs.&lt;/p&gt;

&lt;p&gt;And it depends on timing: the keep_alive request has to land while the swap is waiting. Under light use it may never happen; under a steady stream of requests for the loaded model it happens again and again, and the other model effectively never gets loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;With one slot full, Ollama picks the loaded runner to evict, sets its remaining time to zero so it unloads as soon as its last request finishes, and the waiting request sits until that happens. A request for the same model that arrives in between is served by the runner that's already loaded, and, according to the report's reading of &lt;code&gt;server/sched.go&lt;/code&gt;, its &lt;code&gt;keep_alive&lt;/code&gt; overwrites the runner's session duration. When the runner then goes idle it's no longer marked to expire, so it gets an ordinary keep-alive timer and stays. The waiting request stays queued for it.&lt;/p&gt;

&lt;p&gt;The debug log above shows exactly that sequence: the eviction is set up, the loaded model is evaluated for the new request, and the runner goes idle with a "non-zero duration" and a timer instead of unloading. Without &lt;code&gt;keep_alive&lt;/code&gt; on that request, the duration stays at zero and the unload goes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The proposed fix, &lt;a href="https://github.com/ollama/ollama/pull/19190" rel="noopener noreferrer"&gt;#19190&lt;/a&gt;, stops a request's &lt;code&gt;keep_alive&lt;/code&gt; from cancelling a pending eviction. It's open and not in a release, and none of the commit messages between v0.40.8 and v0.40.9-rc1 mention it.&lt;/p&gt;

&lt;p&gt;Until then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release a stuck swap by unloading the loaded model:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://127.0.0.1:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"qwen3:0.6b","keep_alive":0}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here that let the waiting request finish 1.5 seconds later, with &lt;code&gt;gemma3:270m&lt;/code&gt; loaded afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid the trigger:&lt;/strong&gt; stop clients from sending &lt;code&gt;keep_alive&lt;/code&gt; on every request (set it once on the server with &lt;code&gt;OLLAMA_KEEP_ALIVE&lt;/code&gt; instead), or give the server more than one slot if memory allows, so a second model doesn't need an eviction at all (the three-request sequence wasn't rerun with two slots here).&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-ollama-eviction-stall.sh&lt;/code&gt; reads the server's &lt;code&gt;OLLAMA_MAX_LOADED_MODELS&lt;/code&gt; and loaded models, and if the server logs at debug level, counts evictions that were turned into keep-alive timers. On the test server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Ollama 0.40.8 at http://127.0.0.1:11434, OLLAMA_MAX_LOADED_MODELS=1, loaded: qwen3:0.6b
  AT RISK  one model slot: a request for another model can wait for the whole keep_alive of the loaded one
  STALLED  2 unload(s) since -1day were replaced by a keep-alive timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--release &amp;lt;model&amp;gt;&lt;/code&gt; sends the unload. With two slots and no stall it reports "Not at risk here" and exits 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;When a per-request option and a scheduler decision touch the same field, ask which one wins. Here a client's preference ("keep this model around") silently overrode the server's own plan ("unload it now, someone is waiting"), and the request that paid for it was a different client's. Options that look harmless on their own, like keep_alive, can carry the most surprising side effects precisely because nobody thinks of them as instructions to the scheduler.&lt;/p&gt;

&lt;p&gt;And when something hangs with no error, measure the alternatives side by side: the same three requests with and without one field made the difference between 9 seconds and never.&lt;/p&gt;

&lt;p&gt;Another Ollama setting that doesn't reach the place you'd expect: &lt;a href="https://homelabpostmortem.com/2026/09/28/ollama-gpu-overhead-is-logged-but-never-reaches-llama-server/" rel="noopener noreferrer"&gt;&lt;code&gt;OLLAMA_GPU_OVERHEAD&lt;/code&gt; is logged but never reaches the runner&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ollama drops tool calls to names not in tools and returns an empty reply</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:47:32 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollama-drops-tool-calls-to-names-not-in-tools-and-returns-an-empty-reply-e9o</link>
      <guid>https://dev.to/homelabpm/ollama-drops-tool-calls-to-names-not-in-tools-and-returns-an-empty-reply-e9o</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: When a model served by Ollama calls a function that isn't in the request's &lt;code&gt;tools&lt;/code&gt; list, Ollama drops the call. The reply has no &lt;code&gt;content&lt;/code&gt; and no &lt;code&gt;tool_calls&lt;/code&gt;, but an &lt;code&gt;eval_count&lt;/code&gt; above zero, so tokens were generated and then discarded. On Ollama 0.40.8 with &lt;code&gt;qwen3:0.6b&lt;/code&gt;, a system prompt saying to call &lt;code&gt;lookup_order&lt;/code&gt;, with only &lt;code&gt;get_time&lt;/code&gt; in &lt;code&gt;tools&lt;/code&gt;, gave 8 of 8 empty replies, each with &lt;code&gt;eval_count&lt;/code&gt; 25. Generating the same prompt raw produced &lt;code&gt;&amp;lt;tool_call&amp;gt;{"name": "lookup_order", "arguments": {"order_id": "48213"}}&amp;lt;/tool_call&amp;gt;&lt;/code&gt;, also 25 tokens. With &lt;code&gt;lookup_order&lt;/code&gt; added to &lt;code&gt;tools&lt;/code&gt;, 8 of 8 replies were that call. It happens in the generic, template-based tool parser that Ollama uses for models without a dedicated parser, which includes the library &lt;code&gt;qwen3&lt;/code&gt;. The client-side fix is to list every tool the model may name. Upstream: &lt;a href="https://github.com/ollama/ollama/issues/19187" rel="noopener noreferrer"&gt;ollama/ollama#19187&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container, Ollama 0.40.8 from the GitHub release, CPU, &lt;code&gt;qwen3:0.6b&lt;/code&gt; from the Ollama library. Ollama's log shows which path the model takes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;msg="template selection" model=registry.ollama.ai/library/qwen3:0.6b selected=go_template renderer="" parser=""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so tool calls are parsed by the generic parser built from the chat template.&lt;/p&gt;

&lt;p&gt;The request, to &lt;code&gt;/api/chat&lt;/code&gt; with &lt;code&gt;stream: false&lt;/code&gt; and &lt;code&gt;think: false&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;system: You are a shop assistant. To answer questions about an order, always call the lookup_order
        tool with the order id. Never answer order questions from memory.
user:   What is the status of order 48213?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight runs each (seeds 0 to 7, temperature 0.7), with two &lt;code&gt;tools&lt;/code&gt; lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qwen3:0.6b tools=[get_time] (lookup_order not listed): EMPTY x8  eval_count of EMPTY: [25, 25, 25, 25, 25, 25]
qwen3:0.6b tools=[get_time, lookup_order]: tool_call lookup_order x8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EMPTY&lt;/code&gt; means &lt;code&gt;content&lt;/code&gt; was an empty string and there was no &lt;code&gt;tool_calls&lt;/code&gt; field. The OpenAI-compatible endpoint did the same: &lt;code&gt;finish_reason&lt;/code&gt; &lt;code&gt;stop&lt;/code&gt;, empty &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;tool_calls&lt;/code&gt; null.&lt;/p&gt;

&lt;p&gt;To see what the model actually generated, the same conversation went through &lt;code&gt;/api/generate&lt;/code&gt; with &lt;code&gt;raw: true&lt;/code&gt; and a prompt written out by hand following the model's template, with &lt;code&gt;get_time&lt;/code&gt; as the only tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw seed 0: eval_count=25 '&amp;lt;tool_call&amp;gt;\n{"name": "lookup_order", "arguments": {"order_id": "48213"}}\n&amp;lt;/tool_call&amp;gt;'
raw seed 1: eval_count=25 '&amp;lt;tool_call&amp;gt;\n{"name": "lookup_order", "arguments": {"order_id": "48213"}}\n&amp;lt;/tool_call&amp;gt;'
raw seed 2: eval_count=25 '&amp;lt;tool_call&amp;gt;\n{"name": "lookup_order", "arguments": {"order_id": "48213"}}\n&amp;lt;/tool_call&amp;gt;'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A complete, well-formed call, 25 tokens, the same count as the empty replies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;An empty reply looks like the model's fault. Small models do sometimes return nothing, and a client that gets an empty assistant message usually retries or shows nothing, without logging that tokens were generated. Only &lt;code&gt;eval_count&lt;/code&gt; says the model produced something.&lt;/p&gt;

&lt;p&gt;Calling a tool that isn't listed sounds like an edge case, but it comes up in ordinary agent setups. Tools that are loaded on demand, tools named in another tool's description or in the system prompt, a tool list trimmed per request to save context, or a model that remembers a tool from earlier in the conversation. The report came from an app where the unlisted tool was offered on demand; 28 of 32 replays of its real requests came back empty.&lt;/p&gt;

&lt;p&gt;And there's no signal on the server either: no warning, no &lt;code&gt;done_reason&lt;/code&gt;, nothing in the log about a discarded call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;Ollama's generic tool parser (&lt;code&gt;tools/tools.go&lt;/code&gt; at v0.40.8) watches the output for the template's tool-call tag, here &lt;code&gt;&amp;lt;tool_call&amp;gt;&lt;/code&gt;. Once the tag appears, the parser switches to tool-calling mode and holds everything after it in a buffer. It then looks for one of the names in the request's &lt;code&gt;tools&lt;/code&gt; at the start of that buffer (&lt;code&gt;findTool&lt;/code&gt;); a name that isn't in the list matches nothing, so no call is produced. For tags other than &lt;code&gt;{&lt;/code&gt; or &lt;code&gt;[&lt;/code&gt; the parser never decides it's finished, and at the end of the reply &lt;code&gt;Content()&lt;/code&gt; returns an empty string unless the tag is &lt;code&gt;{&lt;/code&gt; or &lt;code&gt;[&lt;/code&gt;. So the buffered text, which is the entire tool call, is neither returned as a call nor passed through as content.&lt;/p&gt;

&lt;p&gt;That matches every observation above: the tag and the call were generated (the raw output), the tokens were counted (&lt;code&gt;eval_count&lt;/code&gt; 25), and nothing came out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no fix upstream yet; the issue had no reply as of 22 October.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List every tool the model may call.&lt;/strong&gt; With &lt;code&gt;lookup_order&lt;/code&gt; listed, every reply here was the call. The reporter found that entries with empty parameter schemas were enough (30 of 32 calls came back); here the listed tool had a full schema. If tools are loaded on demand, include their names up front and load the implementation when the call arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat an empty reply with &lt;code&gt;eval_count &amp;gt; 0&lt;/code&gt; as a dropped call&lt;/strong&gt;, not as "the model said nothing". Log it, and retry with the full tool list.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-ollama-dropped-tool-calls.sh&lt;/code&gt; runs this probe against a model on your server: first with &lt;code&gt;lookup_order&lt;/code&gt; listed, to check the model calls tools at all, then without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  control, lookup_order listed:     ['call:lookup_order', 'call:lookup_order', 'call:lookup_order', 'call:lookup_order']
  probe, lookup_order not listed:   ['EMPTY(eval_count 25)', 'EMPTY(eval_count 25)', 'EMPTY(eval_count 25)', 'EMPTY(eval_count 25)']

4 of 4 replies came back empty: the model called an unlisted tool and Ollama dropped the call.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 there, 0 when the unlisted call comes back as a call or as text, and 2 when the model doesn't call tools at all (&lt;code&gt;gemma3:270m&lt;/code&gt; returned HTTP 400 for a request with tools).&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Check that a parser has a path for input it doesn't recognise. A parser that only knows how to emit the things it can match will, on anything else, either fail loudly or drop it, and dropping is the worse of the two because nothing downstream can tell. The question to ask of any structured-output layer is: what comes out when the model writes something valid that isn't on the list?&lt;/p&gt;

&lt;p&gt;And count what was generated, not just what was returned. &lt;code&gt;eval_count&lt;/code&gt; against an empty reply is the whole diagnosis here, and it's sitting in every response.&lt;/p&gt;

&lt;p&gt;The Responses API in llama-server has a related gap in the other direction, &lt;a href="https://homelabpostmortem.com/2026/10/20/llama-server-responses-api-reports-a-cut-off-reply-as-completed/" rel="noopener noreferrer"&gt;returning a cut-off tool call as completed&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>ai</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Docker's graphdriver store re-pushes zstd layers labelled gzip</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:47:31 +0000</pubDate>
      <link>https://dev.to/homelabpm/dockers-graphdriver-store-re-pushes-zstd-layers-labelled-gzip-222d</link>
      <guid>https://dev.to/homelabpm/dockers-graphdriver-store-re-pushes-zstd-layers-labelled-gzip-222d</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: When a Docker daemon uses the graphdriver image store (overlay2: every Docker 28, and Docker 29 installs that were upgraded from 28 or have &lt;code&gt;"containerd-snapshotter": false&lt;/code&gt;), &lt;code&gt;docker push&lt;/code&gt; of an image it pulled with zstd-compressed layers writes a manifest that labels those layers &lt;code&gt;application/vnd.docker.image.rootfs.diff.tar.gzip&lt;/code&gt;. The blobs are reused as they are, so they're still zstd. The push succeeds, the registry accepts it, and &lt;code&gt;docker run&lt;/code&gt; of the image works, because Docker detects the compression from the data. Tools that trust the label don't: a BuildKit build &lt;code&gt;FROM&lt;/code&gt; the relabelled image with &lt;code&gt;compression=zstd,force-compression=true&lt;/code&gt; failed with &lt;code&gt;failed to get compression blob "zstd": not found&lt;/code&gt;, and without &lt;code&gt;force-compression&lt;/code&gt; it succeeded and copied the gzip label into the new image. Reproduced on Docker 29.9.1 and 29.9.2; the containerd image store labels the same layers correctly. Upstream: &lt;a href="https://github.com/moby/moby/issues/53999" rel="noopener noreferrer"&gt;moby/moby#53999&lt;/a&gt; and &lt;a href="https://github.com/moby/buildkit/issues/7308" rel="noopener noreferrer"&gt;moby/buildkit#7308&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable Debian 13 VM with Docker CE 29.9.2 from download.docker.com. First the reporter's own reproducer, after reading it: Compose runs &lt;code&gt;registry:3.1.2&lt;/code&gt;, two &lt;code&gt;docker:&amp;lt;version&amp;gt;-dind&lt;/code&gt; daemons (one with each image store) and an Alpine container with curl, jq and zstd. It pushes a one-layer zstd image to the registry with curl, then has each daemon pull it, retag it and push it, and compares each manifest's media type with the blob's first bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 1. Push a one-layer zstd image built with curl and the zstd CLI (registry:5000/repro/app:zstd)
  application/vnd.oci.image.layer.v1.tar+zstd        sha256:b520d8eb3b4b…  content: zstd

== 2. Graphdriver store: pull it, then push it under new tags
  registry:5000/repro/app:graphdriver (blob already in the repo):
  application/vnd.docker.image.rootfs.diff.tar.gzip  sha256:b520d8eb3b4b…  content: zstd
  registry:5000/repro/other-graphdriver:latest (blob mounted from repro/app):
  application/vnd.docker.image.rootfs.diff.tar.gzip  sha256:b520d8eb3b4b…  content: zstd

== 3. containerd store: the same, for comparison
  registry:5000/repro/app:containerd (blob already in the repo):
  application/vnd.oci.image.layer.v1.tar+zstd        sha256:b520d8eb3b4b…  content: zstd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same blob digest everywhere; only the graphdriver pushes call it gzip. It reproduced with &lt;code&gt;docker:29.9.1-dind&lt;/code&gt; and with &lt;code&gt;docker:29.9.2-dind&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the part the reproducer doesn't show: what happens downstream. On the VM's own daemon, switched to the graphdriver store, with a registry on &lt;code&gt;localhost:5000&lt;/code&gt; and a BuildKit builder (&lt;code&gt;docker-container&lt;/code&gt; driver):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. buildkit pushed base:zstd rc=0  layers: tar+zstd tar+zstd
2. graphdriver dockerd re-pushed base:relabeled  layers: gzip gzip
3. FROM relabeled, compression=zstd,force-compression=true rc=1
   ERROR: failed to build: failed to solve: failed to get compression blob "zstd": not found
4. FROM relabeled, compression=zstd (no force) rc=0  layers: tar+gzip tar+gzip tar+zstd
6. docker run of relabeled image: base
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relabelled layer's first bytes are &lt;code&gt;28 b5 2f fd&lt;/code&gt;, the zstd magic. Step 4 is the quieter failure: the build works and the new image inherits the two wrong labels, so whoever builds on it next gets them too. &lt;code&gt;docker run&lt;/code&gt; doesn't care (step 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Nothing in Docker's own workflow ever fails. Push succeeds, pull succeeds, run succeeds, because Docker and containerd look at the blob's magic bytes rather than the label. The wrong label only matters to something that trusts it, and that's usually another tool, later, maybe on someone else's machine. BuildKit's recompression, below, is one; any tool that decides how to decompress a layer from its media type would be another.&lt;/p&gt;

&lt;p&gt;The store you're on isn't obvious either. Fresh Docker 29 installs use the containerd image store; according to the report, a Docker 29 that was upgraded from 28 keeps overlay2 ("using prior storage driver: overlay2" at startup), and any daemon with &lt;code&gt;containerd-snapshotter&lt;/code&gt; turned off uses it too, as the test daemon here did. &lt;code&gt;docker info&lt;/code&gt; shows it as the storage driver: &lt;code&gt;overlay2&lt;/code&gt; is the graphdriver store, &lt;code&gt;overlayfs&lt;/code&gt; the containerd one.&lt;/p&gt;

&lt;p&gt;And there was one more trap when testing. After BuildKit had built from the relabelled image once, a build &lt;code&gt;FROM&lt;/code&gt; the correctly labelled original also failed with the same error, because the builder's cache now held the layer under the gzip label. After &lt;code&gt;docker buildx prune -af&lt;/code&gt;, the correctly labelled base built fine and the relabelled one still failed. So a single mislabelled image can make unrelated builds fail on the same builder until its cache is cleared.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;When the graphdriver store pushes a layer that the registry already has, or can mount from another repository, it doesn't upload anything, it just writes the manifest entry. According to the report, the media type for both of those paths is hard-coded to the gzip Docker type in &lt;code&gt;daemon/internal/distribution/push_v2.go&lt;/code&gt;. For a layer that was originally gzip that's correct; for a zstd layer pulled from a registry it's wrong, and nothing checks.&lt;/p&gt;

&lt;p&gt;The containerd image store keeps the layer's original descriptor, media type included, which is why it pushes the same layers correctly.&lt;/p&gt;

&lt;p&gt;On the BuildKit side, according to buildkit#7308, &lt;code&gt;force-compression&lt;/code&gt; decides whether a layer needs converting from its media type, so a zstd layer labelled gzip is treated as needing conversion to zstd, and the lookup for a zstd version of it fails, which is the &lt;code&gt;failed to get compression blob "zstd": not found&lt;/code&gt; above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no fix in a release; the issue is labelled &lt;code&gt;help wanted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push from a containerd-store daemon.&lt;/strong&gt; Either a fresh Docker 29 install, or set &lt;code&gt;"features": {"containerd-snapshotter": true}&lt;/code&gt; in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; and restart. Images stored under the old store don't appear in the new one, so pull them again after switching. With that store the same push kept &lt;code&gt;tar+zstd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or don't re-push pulled zstd images from a graphdriver daemon&lt;/strong&gt;; rebuild them instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear the builder cache&lt;/strong&gt; after hitting the BuildKit error: &lt;code&gt;docker buildx prune -af&lt;/code&gt; on the builder involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what's already in your registry.&lt;/strong&gt; The toolkit's &lt;code&gt;check-image-layer-compression.sh&lt;/code&gt; fetches an image's manifest (each platform of an index, or one with &lt;code&gt;--platform&lt;/code&gt;) and the first bytes of every layer, and compares them with the label. Anonymous pulls, read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ok       localhost:5000/base:zstd e2975860bc76  label zstd  content zstd
  MISLABEL localhost:5000/base:relabeled e2975860bc76  label gzip  content zstd
  MISLABEL localhost:5000/app:noforce e2975860bc76  label gzip  content zstd
  ok       localhost:5000/app:noforce 0cc3546756f5  label zstd  content zstd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 when any layer is mislabelled. Against Docker Hub's &lt;code&gt;alpine:3.24&lt;/code&gt; and &lt;code&gt;nginx:alpine&lt;/code&gt; it reported every layer &lt;code&gt;ok&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;A label is a claim, not a fact. When a format records what something is in metadata next to the data, the two can drift apart, and the tools that read only the metadata are the ones that break. If you consume artifacts produced by other people's tooling, check the content when it's cheap to, the way Docker does here with magic bytes.&lt;/p&gt;

&lt;p&gt;And test the next step, not just the one that was reported. The reproducer showed a wrong label; the cost of that label only appeared one build later, as an error in a different tool and a cache that went on failing for a correctly labelled image.&lt;/p&gt;

&lt;p&gt;Another Docker 29 behaviour that depends on which store the daemon uses: &lt;a href="https://homelabpostmortem.com/2026/10/18/docker-image-mount-comes-back-empty-after-a-reboot/" rel="noopener noreferrer"&gt;type=image mounts come back empty after a reboot&lt;/a&gt;, differently on overlay2 and on the containerd store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>linux</category>
    </item>
    <item>
      <title>Proxmox disk move with Delete source deletes the source even when the copy is missing</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:59:16 +0000</pubDate>
      <link>https://dev.to/homelabpm/proxmox-disk-move-with-delete-source-deletes-the-source-even-when-the-copy-is-missing-o52</link>
      <guid>https://dev.to/homelabpm/proxmox-disk-move-with-delete-source-deletes-the-source-even-when-the-copy-is-missing-o52</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update 2026-10-23.&lt;/strong&gt; Proxmox has a fix in review. The report is now &lt;a href="https://bugzilla.proxmox.com/show_bug.cgi?id=8209" rel="noopener noreferrer"&gt;Bugzilla #8209&lt;/a&gt; (status PATCH AVAILABLE), and Fiona Ebner posted a four-patch series for qemu-server to pve-devel, &lt;a href="https://lore.proxmox.com/pve-devel/20261022093901.52732-1-f.ebner@proxmox.com/" rel="noopener noreferrer"&gt;"fix #8209: clone disk: do not ignore failure to query size of target"&lt;/a&gt;. As the patch describes it, a failed size query of the target is now logged as a warning and reported to the caller so it can avoid the destructive step, and because the file-based storage plugins return nothing instead of failing, the size is checked explicitly. The series isn't applied or in a package yet, so qemu-server 9.1.15 behaves as described below. Why the reporter's target disappeared on the NFS share is, in the Bugzilla thread's words, still unclear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: In Proxmox VE 9.2, moving a VM disk with "Delete source" (&lt;code&gt;qm disk move ... --delete 1&lt;/code&gt;) deletes the original as soon as the copy step returns, without checking that the copy exists. On a test VM, the target file was removed while the copy was running. The move finished with exit 0 and &lt;code&gt;TASK OK&lt;/code&gt;. The task log had one line, &lt;code&gt;failed to stat '&amp;lt;target&amp;gt;'&lt;/code&gt;, followed by &lt;code&gt;removing source volume ... after successful copy&lt;/code&gt;, and the source volume was gone. The VM's config pointed at the missing file, still showing &lt;code&gt;size=2G&lt;/code&gt;, so nothing in the config looked wrong. With "Delete source" off, the same failure kept the original as &lt;code&gt;unused0&lt;/code&gt;. Until this changes: move without "Delete source", check the copy against the original, then remove the original yourself. The toolkit has a script that searches past task logs for this and one that does the checked move. Report: &lt;a href="https://forum.proxmox.com/threads/qm-disk-move-offline-delete-removes-source-although-target-file-is-missing-failed-to-stat-%E2%80%93-task-ok-data-loss.187166/" rel="noopener noreferrer"&gt;forum.proxmox.com thread 187166&lt;/a&gt;, from someone who lost a disk this way moving to an NFS share.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The Proxmox host behind this site's lab, pve-manager 9.2.2, qemu-server 9.1.15, libpve-storage-perl 9.1.5. A throwaway directory storage &lt;code&gt;hpmtest&lt;/code&gt; and a stopped test VM with a 2 GiB disk on &lt;code&gt;local-lvm&lt;/code&gt;, 1 GiB of it filled with random data so the copy takes a few seconds at &lt;code&gt;--bwlimit 51200&lt;/code&gt;. A second shell deleted the target file 3 seconds after it appeared, which is a stand-in for whatever made the reporter's target disappear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# qm disk move 9901 scsi0 hpmtest --format qcow2 --delete 1 --bwlimit 51200
...
transferred 2.0 GiB of 2.0 GiB (100.00%)
failed to stat '/var/lib/hpmtest/images/9901/vm-9901-disk-0.qcow2'
removing source volume 'local-lvm:vm-9901-disk-0' after successful copy
  Logical volume "vm-9901-disk-0" successfully removed.
rc=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task log ends &lt;code&gt;TASK OK&lt;/code&gt;. Afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config: scsi0: hpmtest:9901/vm-9901-disk-0.qcow2,size=2G
source LV: 0 volume(s) for vm 9901
target dir: (empty)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source is deleted, the target doesn't exist, and the config still looks complete. The same run with &lt;code&gt;--delete 0&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;failed to stat '/var/lib/hpmtest/images/9901/vm-9901-disk-0.qcow2'
adding source volume 'local-lvm:vm-9901-disk-0' as 'unused0' to config
rc=0
config: scsi0: hpmtest:9901/vm-9901-disk-0.qcow2,size=2G unused0: local-lvm:vm-9901-disk-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also &lt;code&gt;TASK OK&lt;/code&gt;, also pointing at a missing file, but the original is still there as &lt;code&gt;unused0&lt;/code&gt;, and the VM can be pointed back at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;The task succeeds. In the GUI it shows &lt;code&gt;OK&lt;/code&gt;, the CLI exits 0, and the only sign is one &lt;code&gt;failed to stat&lt;/code&gt; line in a log of progress lines, immediately followed by a line that says "after successful copy". Nobody reads a long move log that ended OK.&lt;/p&gt;

&lt;p&gt;The config doesn't help either. The disk line keeps its &lt;code&gt;size=2G&lt;/code&gt;, because that comes from the old disk's entry; only a missing size would hint at a problem, and there isn't one. The problem only shows when something needs the disk, most likely the next start of the VM (not tried here), which after an offline move of a stopped VM may be days later, when the space on the old storage has been reused.&lt;/p&gt;

&lt;p&gt;And the trigger is not something you'd test for. Here the file was removed by hand. In the forum report it was a move to an NFS share on a Synology, where the copy ran for about nine hours and the target was not there at the end. Why it vanished there is a separate question, but anything that can make a target disappear or become unreadable, a network share, a full or remounted filesystem, a cleanup job, turns "Delete source" into data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The installed code on this host, read directly:&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;PVE/API2/Qemu.pm&lt;/code&gt;, the disk move runs the clone inside an &lt;code&gt;eval&lt;/code&gt;, and if that &lt;code&gt;eval&lt;/code&gt; didn't die, the delete block runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$param&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;("&lt;/span&gt;&lt;span class="s2"&gt;removing source volume '&lt;/span&gt;&lt;span class="si"&gt;$old_volid&lt;/span&gt;&lt;span class="s2"&gt;' after successful copy&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;");&lt;/span&gt;
    &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;PVE::Storage::&lt;/span&gt;&lt;span class="nv"&gt;deactivate_volumes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storecfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$old_volid&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nn"&gt;PVE::Storage::&lt;/span&gt;&lt;span class="nv"&gt;vdisk_free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storecfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$old_volid&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;"Successful" means only that nothing died. The one place the target is looked at after the copy is in &lt;code&gt;PVE/QemuServer.pm&lt;/code&gt;, where the clone asks for its size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;PVE::Storage::&lt;/span&gt;&lt;span class="nv"&gt;volume_size_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storecfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$newvolid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;For a file-based target, that ends up in the storage plugin's &lt;code&gt;file_size_info&lt;/code&gt;, which, when &lt;code&gt;stat&lt;/code&gt; fails, prints &lt;code&gt;failed to stat '...'&lt;/code&gt; as a warning and returns nothing, rather than dying. The &lt;code&gt;eval&lt;/code&gt; would catch a death anyway. So a missing target produces a warning and an undefined size, the clone returns normally, and the delete goes ahead. The &lt;code&gt;size=2G&lt;/code&gt; in the config is the old disk's size, kept because the new one came back undefined.&lt;/p&gt;

&lt;p&gt;The copy itself (&lt;code&gt;qemu-img convert&lt;/code&gt;) didn't fail either: it keeps writing to the file it has open, even after the name has been removed, and finishes normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no fix in Proxmox yet; the forum thread had no reply as of 21 October and there's no Bugzilla entry for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't use "Delete source"&lt;/strong&gt; for moves you can't watch. Move with it off, so the original stays as &lt;code&gt;unusedN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the copy before removing the original.&lt;/strong&gt; For a stopped VM, &lt;code&gt;qemu-img compare&lt;/code&gt; reads both and checks the guest-visible contents match, across formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-img compare &lt;span class="nt"&gt;-f&lt;/span&gt; raw &lt;span class="nt"&gt;-F&lt;/span&gt; qcow2 /dev/pve/vm-101-disk-0 /mnt/pve/nas/images/101/vm-101-disk-0.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 0 means identical. Then remove the original with &lt;code&gt;qm disk unlink &amp;lt;vmid&amp;gt; --idlist unused0&lt;/code&gt;, which deletes the volume. If the target is missing or differs, point the VM back at the original with &lt;code&gt;qm set &amp;lt;vmid&amp;gt; --scsi0 &amp;lt;old volume&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;pve-move-disk-verified.sh&lt;/code&gt; does exactly that for a stopped VM: moves without delete, checks the target exists, compares, and removes the source only if they match and you passed &lt;code&gt;--delete-source&lt;/code&gt;. With the target removed mid-copy it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TARGET MISSING: hpmtest:9901/vm-9901-disk-0.qcow2 does not exist after the move. The source is kept as unused0 (local-lvm:vm-9901-disk-0).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and exited 1, with the original intact. A normal move compared &lt;code&gt;identical&lt;/code&gt; and removed the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether it has already happened to you.&lt;/strong&gt; &lt;code&gt;check-pve-move-delete.sh&lt;/code&gt; reads every disk-move task log on the node and lists the ones with &lt;code&gt;failed to stat&lt;/code&gt;, as LOST if the source was then removed, and checks every VM disk in the current configs for a volume that doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  LOST  vm 9901  2026-10-21 09:06:26  source local-lvm:vm-9901-disk-0 was deleted; target /var/lib/hpmtest/images/9901/vm-9901-disk-0.qcow2 could not be stat'ed (TASK OK)
  KEPT  vm 9901  2026-10-21 09:07:25  target /var/lib/hpmtest/images/9901/vm-9901-disk-0.qcow2 could not be stat'ed; source kept: as 'unused0'
...
  MISSING  vm 9901  scsi0  hpmtest:9901/vm-9901-disk-0.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A LOST source can't be recovered from Proxmox; that's what backups are for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Never let a tool delete the original on the strength of its own success message. "After successful copy" is a claim made by the same code that did the copy, and here it meant only that nothing threw an error. Before deleting a source, verify the destination independently: it exists, it has the right size, and its contents match. That's one command, and it's the one step the tool skipped.&lt;/p&gt;

&lt;p&gt;More generally, a warning followed by success is a pattern worth searching your logs for. A warning means some check failed and the program decided to carry on; when the next step is destructive, that decision deserves a look.&lt;/p&gt;

&lt;p&gt;Another case of a status that said everything was fine while the data was gone: &lt;a href="https://homelabpostmortem.com/2026/10/18/docker-image-mount-comes-back-empty-after-a-reboot/" rel="noopener noreferrer"&gt;a Docker container that came back running after a reboot with its image mount empty&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>linux</category>
      <category>backup</category>
    </item>
    <item>
      <title>llama.cpp splits Hindi and Thai differently for Nemotron 3 because its GGUF says pixtral</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:59:15 +0000</pubDate>
      <link>https://dev.to/homelabpm/llamacpp-splits-hindi-and-thai-differently-for-nemotron-3-because-its-gguf-says-pixtral-7ke</link>
      <guid>https://dev.to/homelabpm/llamacpp-splits-hindi-and-thai-differently-for-nemotron-3-because-its-gguf-says-pixtral-7ke</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update 2026-10-23.&lt;/strong&gt; Fixed in llama.cpp. &lt;a href="https://github.com/ggml-org/llama.cpp/pull/30960" rel="noopener noreferrer"&gt;#30960&lt;/a&gt; ("vocab : use Tekken pre-tokenizer for pixtral rather than the Llama 3 one") was merged on 2026-10-22 and closed #30894; the first release with it is b11813. With the b11829 release and the same unmodified GGUF, which still says &lt;code&gt;pixtral&lt;/code&gt;, &lt;code&gt;llama-server&lt;/code&gt; with no override tokenized every sample here exactly as Hugging Face does (Hindi 19 tokens, Tamil 15, Thai 15, &lt;code&gt;दी&lt;/code&gt; as &lt;code&gt;[44213]&lt;/code&gt;), and &lt;code&gt;llama-tokenize&lt;/code&gt; agreed. On b11813 or later nothing below is needed; on older builds, and in front ends that bundle an older llama.cpp, the override still is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 2026-10-22.&lt;/strong&gt; A llama.cpp maintainer &lt;a href="https://github.com/ggml-org/llama.cpp/issues/30894" rel="noopener noreferrer"&gt;suggested&lt;/a&gt; fixing this in llama.cpp itself by treating &lt;code&gt;pixtral&lt;/code&gt; as Tekken, since the &lt;code&gt;pixtral&lt;/code&gt; hash came from a community copy of the Pixtral tokenizer and the Llama 3 rules may be wrong for Pixtral too; the reporter agreed. That would fix GGUFs already published without rewriting them. No pull request yet, so the override below is still needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: NVIDIA's &lt;code&gt;Nemotron-3-Nano-4B&lt;/code&gt; GGUF says &lt;code&gt;tokenizer.ggml.pre = "pixtral"&lt;/code&gt;. In llama.cpp, &lt;code&gt;pixtral&lt;/code&gt; selects the Llama 3 pre-tokenizer, but Nemotron's own tokenizer uses the Tekken split rules. The result: on llama.cpp master (339f324), English, Japanese, Arabic and code tokenized exactly as Hugging Face's tokenizer does, while Hindi took 30 tokens instead of 19, Tamil 20 instead of 15 and Thai 16 instead of 15, and &lt;code&gt;दी&lt;/code&gt; became two tokens instead of one. Nothing errors; the model loads and answers. Starting &lt;code&gt;llama-server&lt;/code&gt; with &lt;code&gt;--override-kv tokenizer.ggml.pre=str:tekken&lt;/code&gt;, or rewriting the file's metadata to &lt;code&gt;tekken&lt;/code&gt;, made every sample match Hugging Face. One trap: &lt;code&gt;llama-tokenize&lt;/code&gt;, the obvious tool to check this with, accepts &lt;code&gt;--override-kv&lt;/code&gt; and ignores it, so the fix looks like it doesn't work. Upstream: &lt;a href="https://github.com/ggml-org/llama.cpp/issues/30894" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#30894&lt;/a&gt;, which lists other affected uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container, llama.cpp built from master at 339f324, &lt;code&gt;NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf&lt;/code&gt; from &lt;code&gt;nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF&lt;/code&gt; (2,837,072,864 bytes, matching the size on Hugging Face), and the same model's &lt;code&gt;tokenizer.json&lt;/code&gt; from &lt;code&gt;nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16&lt;/code&gt; as the reference. The GGUF's tokenizer metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;general.architecture&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nemotron_h&lt;/span&gt;
&lt;span class="py"&gt;tokenizer.ggml.model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;gpt2&lt;/span&gt;
&lt;span class="py"&gt;tokenizer.ggml.pre&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;pixtral&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token IDs for the same text, without special tokens, Hugging Face &lt;code&gt;tokenizers&lt;/code&gt; against llama-server's &lt;code&gt;/tokenize&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;  english    HF  19  server  19 ==
  hindi-one  HF   1  server   2 !=  [4894, 2063]
  hindi      HF  19  server  30 !=
  tamil      HF  15  server  20 !=
  thai       HF  15  server  16 !=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hindi-one&lt;/code&gt; is &lt;code&gt;दी&lt;/code&gt;, which Hugging Face encodes as &lt;code&gt;[44213]&lt;/code&gt;. Japanese (13), Arabic (5) and a line of Python (12) matched exactly.&lt;/p&gt;

&lt;p&gt;The same server started with &lt;code&gt;--override-kv tokenizer.ggml.pre=str:tekken&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;  english    HF  19  server  19 ==
  hindi-one  HF   1  server   1 ==  [44213]
  hindi      HF  19  server  19 ==
  tamil      HF  15  server  15 ==
  thai       HF  15  server  15 ==
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A copy of the GGUF with the metadata rewritten to &lt;code&gt;tekken&lt;/code&gt; gave the same full match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Everything a quick check looks at is fine. The file loads, &lt;code&gt;llama-server&lt;/code&gt; starts, chat works, and English, the language most people test with, is token-for-token identical. Code and Japanese were identical too. The difference is confined to scripts where the two split rules disagree, here the Indic scripts and Thai, and the only symptoms are that those prompts use more tokens and the model sees them split in a way it never saw in training. How much that hurts answers wasn't measured here, and it may be small for a short prompt.&lt;/p&gt;

&lt;p&gt;It also hides behind something people already expect: Indic scripts take more tokens than English with most tokenizers, so a high count for Hindi doesn't look unusual. The extra from llama.cpp only shows up next to the Hugging Face count.&lt;/p&gt;

&lt;p&gt;And the obvious way to test a fix gives the wrong answer. &lt;code&gt;llama-tokenize&lt;/code&gt; is llama.cpp's tool for exactly this, and it accepts &lt;code&gt;--override-kv&lt;/code&gt; without complaint:&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;$ &lt;/span&gt;llama-tokenize &lt;span class="nt"&gt;-m&lt;/span&gt; NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"दी"&lt;/span&gt; &lt;span class="nt"&gt;--ids&lt;/span&gt; &lt;span class="nt"&gt;--log-disable&lt;/span&gt; &lt;span class="nt"&gt;--no-bos&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--override-kv&lt;/span&gt; tokenizer.ggml.pre&lt;span class="o"&gt;=&lt;/span&gt;str:tekken
&lt;span class="o"&gt;[&lt;/span&gt;4894, 2063]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still two tokens, exit 0. Its source (&lt;code&gt;tools/tokenize/tokenize.cpp&lt;/code&gt;) has no override handling at all, while an unknown flag such as &lt;code&gt;--no-such-flag&lt;/code&gt; is rejected with an error, so the override is parsed and then dropped. The same flag on &lt;code&gt;llama-server&lt;/code&gt; works, as shown above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;When a model is converted, llama.cpp's converter identifies the tokenizer by hashing how it splits a fixed test string, and looks the hash up in a table of known tokenizers to choose the &lt;code&gt;pre&lt;/code&gt; name it writes into the GGUF. According to the report, the Nemotron tokenizer's hash is the same as Pixtral-12B's, so the converter writes &lt;code&gt;pixtral&lt;/code&gt;. In &lt;code&gt;src/llama-vocab.cpp&lt;/code&gt;, &lt;code&gt;pixtral&lt;/code&gt; is grouped with &lt;code&gt;llama3&lt;/code&gt; and gets the Llama 3 pre-tokenizer, which is right for Pixtral. Nemotron's &lt;code&gt;tokenizer.json&lt;/code&gt; uses the Tekken split pattern, which llama.cpp has under the separate name &lt;code&gt;tekken&lt;/code&gt;. The two patterns behave the same on English and code and differ on the scripts above.&lt;/p&gt;

&lt;p&gt;The test here can't see the converter's hash, but the results fit that explanation exactly: everything else in the file is unchanged, and changing only the &lt;code&gt;pre&lt;/code&gt; name, by override or by rewriting the file, makes every sample match.&lt;/p&gt;

&lt;p&gt;The report proposes a load-time fix (treat &lt;code&gt;pixtral&lt;/code&gt; as Tekken when the architecture is Nemotron), which would also correct GGUFs already published. As of 21 October the issue has no replies and there is no pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;At load time:&lt;/strong&gt; add the override to whatever runs the model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf &lt;span class="nt"&gt;--override-kv&lt;/span&gt; tokenizer.ggml.pre&lt;span class="o"&gt;=&lt;/span&gt;str:tekken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Or rewrite the file once&lt;/strong&gt;, with the script that ships in llama.cpp's &lt;code&gt;gguf-py&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;python gguf-py/gguf/scripts/gguf_new_metadata.py &lt;span class="nt"&gt;--pre-tokenizer&lt;/span&gt; tekken &lt;span class="se"&gt;\&lt;/span&gt;
    NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf nemotron-tekken.gguf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes a new file and leaves the original alone; it took about five seconds for the 2.8 GB file here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check with &lt;code&gt;llama-server&lt;/code&gt;'s &lt;code&gt;/tokenize&lt;/code&gt;, not &lt;code&gt;llama-tokenize&lt;/code&gt;&lt;/strong&gt;, for the reason above.&lt;/p&gt;

&lt;p&gt;Other front ends that bundle llama.cpp, such as Ollama and LM Studio, have their own ways of passing metadata overrides, if any; those weren't tested here. Only NVIDIA's Nano 4B upload was tested; the report lists other Nemotron 3 and 3.5 GGUFs from NVIDIA, ggml-org, Unsloth, bartowski and LM Studio as carrying &lt;code&gt;pixtral&lt;/code&gt; too, and one Unsloth upload as carrying &lt;code&gt;tekken&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-gguf-pretokenizer.sh&lt;/code&gt; reads the GGUF header directly (no model load, 0.05 s for this file) and flags this combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  WRONG    m/NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf: architecture nemotron_h, pre-tokenizer 'pixtral' (llama.cpp splits it as Llama 3); should be 'tekken'
  ok       m/nemo-tekken.gguf: architecture nemotron_h, pre-tokenizer 'tekken'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--server &amp;lt;url&amp;gt; --hf-tokenizer tokenizer.json&lt;/code&gt; it sends multilingual samples to a running &lt;code&gt;llama-server&lt;/code&gt; and compares them with Hugging Face's tokenizer, which catches this kind of mismatch for any model, not just this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Test a local model in the languages you'll actually use, against the reference tokenizer, not just by chatting with it. A tokenizer mismatch doesn't break anything visibly; it shifts what the model sees. Comparing token IDs with the original &lt;code&gt;tokenizer.json&lt;/code&gt; for a few sentences in each language takes a minute and settles it.&lt;/p&gt;

&lt;p&gt;And when a check says a fix didn't work, check the checker. Here the tool silently ignored the option being tested. A tool that accepts a flag it doesn't implement will make a correct fix look wrong; trying the same thing a second way, through the server, showed it.&lt;/p&gt;

&lt;p&gt;A different metadata field steering a model to the wrong behaviour came up with Ollama: &lt;a href="https://homelabpostmortem.com/2026/10/07/ollama-picks-the-gemma-4-template-from-the-model-name/" rel="noopener noreferrer"&gt;it picks the Gemma 4 prompt template from the model's name&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>llamacpp</category>
      <category>selfhosted</category>
      <category>ai</category>
    </item>
    <item>
      <title>llama-server's Responses API marks truncated replies and tool calls as completed</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 22:41:50 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-servers-responses-api-marks-truncated-replies-and-tool-calls-as-completed-153n</link>
      <guid>https://dev.to/homelabpm/llama-servers-responses-api-marks-truncated-replies-and-tool-calls-as-completed-153n</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: llama-server's OpenAI-compatible &lt;code&gt;/v1/responses&lt;/code&gt; endpoint never reports a reply as cut off. On llama.cpp b11753, a request with &lt;code&gt;max_output_tokens: 8&lt;/code&gt; came back with &lt;code&gt;"status": "completed"&lt;/code&gt;, &lt;code&gt;incomplete_details: null&lt;/code&gt; and the text &lt;code&gt;1 2 3 4&lt;/code&gt;, stopped mid-count. A tool call cut off by the limit came back as a &lt;code&gt;function_call&lt;/code&gt; item with arguments &lt;code&gt;{"path": "/&lt;/code&gt;, which is not valid JSON, and both the item and the response said &lt;code&gt;completed&lt;/code&gt;. The streaming version ended with &lt;code&gt;response.completed&lt;/code&gt; and never sent &lt;code&gt;function_call_arguments.done&lt;/code&gt;. The same requests on &lt;code&gt;/v1/chat/completions&lt;/code&gt; said &lt;code&gt;finish_reason: "length"&lt;/code&gt;. A client that trusts &lt;code&gt;status&lt;/code&gt;, which is how the Responses API is meant to be used, will treat half an answer as a whole one, or run a tool with broken arguments. The one signal left is &lt;code&gt;usage.output_tokens&lt;/code&gt; equal to the limit you sent. Upstream: &lt;a href="https://github.com/ggml-org/llama.cpp/issues/30773" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#30773&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container, the b11753 release binary, &lt;code&gt;gemma-3-1b-it-Q4_K_M&lt;/code&gt; and &lt;code&gt;Qwen3-0.6B-Q8_0&lt;/code&gt;, &lt;code&gt;--jinja -c 4096&lt;/code&gt;. A request that can't finish within its limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/responses&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Count from 1 to 200, separated by spaces."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"incomplete_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1 2 3 4 "&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same question with room to finish used 52 tokens and also said &lt;code&gt;completed&lt;/code&gt;, so &lt;code&gt;status&lt;/code&gt; is the same whether the reply ended or was cut. On the chat endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/chat/completions   ... "max_tokens": 8
finish_reason= length completion_tokens= 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With streaming, the last events of the 8-token request were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;response.content_part.done, response.output_item.done, response.completed status=completed incomplete_details=None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;response.incomplete&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;A tool call is where it matters most. One tool, &lt;code&gt;write_file(path, content)&lt;/code&gt;, Qwen3-0.6B, &lt;code&gt;temperature: 0&lt;/code&gt;, and &lt;code&gt;/no_think&lt;/code&gt; in the prompt so the model calls the tool straight away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;responses limit=20:  status=completed usage.output_tokens=20 args='{"path": "/'
chat      limit=20:  finish_reason=length completion_tokens=20 tool_calls=[('write_file', '{"path": "/')]
responses limit=400: status=completed usage.output_tokens=89 args='{"path": "/tmp/poem.txt", "content": "Th...'
chat      limit=400: finish_reason=tool_calls completion_tokens=89
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a 20-token limit the Responses API returned a &lt;code&gt;function_call&lt;/code&gt; item marked &lt;code&gt;completed&lt;/code&gt; whose arguments end after &lt;code&gt;{"path": "/&lt;/code&gt;. At 30 they ended after &lt;code&gt;"content": "&lt;/code&gt;. Both endpoints produced the same truncated arguments; only the chat endpoint said so. In the stream there were five &lt;code&gt;response.function_call_arguments.delta&lt;/code&gt; events, then &lt;code&gt;response.output_item.done&lt;/code&gt; and &lt;code&gt;response.completed&lt;/code&gt;. No &lt;code&gt;response.function_call_arguments.done&lt;/code&gt;, and that event was also missing from a call that completed normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Short limits are rare in interactive use, so most replies really are complete, and &lt;code&gt;completed&lt;/code&gt; is the right answer most of the time. The ones that aren't are the long ones: a big file write, a long code edit, a model that thinks for a while first. Those are exactly the replies where an agent is about to do something with the output.&lt;/p&gt;

&lt;p&gt;The model also isn't wrong. The text is what the model generated, and in the tool-call case the arguments are a correct prefix of a correct call. Nothing errors until the client tries to parse the arguments, and a lenient JSON parser or a retry loop can hide even that. When the call does fail, it looks like the model produced bad JSON, which small models are known for.&lt;/p&gt;

&lt;p&gt;And the chat endpoint is fine. Anyone who checked &lt;code&gt;finish_reason&lt;/code&gt; on &lt;code&gt;/v1/chat/completions&lt;/code&gt; has seen &lt;code&gt;length&lt;/code&gt; work. The Responses API is newer, it's what Codex CLI and other recent agent tools speak, and the only thing llama-server's README says about how it works is that it "works by converting Responses request into Chat Completions request."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The conversion is only one way. The request is turned into a chat completion and generated normally, and the server knows the reply stopped at the limit, because the chat endpoint reports &lt;code&gt;length&lt;/code&gt; from the same generation. When the result is turned back into a Responses object, in &lt;code&gt;tools/server/server-task.cpp&lt;/code&gt;, the response &lt;code&gt;status&lt;/code&gt; and the &lt;code&gt;status&lt;/code&gt; of every output item (message, reasoning, function call) are written as the literal string &lt;code&gt;"completed"&lt;/code&gt;. At the b11753 tag that is seven &lt;code&gt;{"status", "completed"}&lt;/code&gt; entries across &lt;code&gt;to_json_oaicompat_resp()&lt;/code&gt; and &lt;code&gt;to_json_oaicompat_resp_stream()&lt;/code&gt;, the non-streaming and streaming builders. Neither reads the stop reason, and the string &lt;code&gt;incomplete&lt;/code&gt; does not appear anywhere in the file. The Responses specification has &lt;code&gt;status: "incomplete"&lt;/code&gt; with &lt;code&gt;incomplete_details.reason: "max_output_tokens"&lt;/code&gt; for exactly this case; llama-server never produces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no fix upstream yet; the issue has no replies or PR as of 20 October. On the client side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;usage.output_tokens == max_output_tokens&lt;/code&gt; as cut off.&lt;/strong&gt; In every truncated reply here it was exactly the limit (8 of 8, 20 of 20), and in every complete one it was lower (52 of 400, 89 of 400). It's a heuristic: a reply that ends naturally on its last allowed token would look the same, which errs on the safe side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate tool-call arguments before running anything.&lt;/strong&gt; A &lt;code&gt;function_call&lt;/code&gt; whose arguments don't parse as JSON was cut off, whatever its status says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;/v1/chat/completions&lt;/code&gt; if your client can.&lt;/strong&gt; &lt;code&gt;finish_reason: "length"&lt;/code&gt; is reported correctly there, for text and for tool calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give agent requests a generous limit&lt;/strong&gt;, so the truncation case is rare in the first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-llama-responses-status.sh&lt;/code&gt; sends one 8-token request to &lt;code&gt;/v1/responses&lt;/code&gt; and one to &lt;code&gt;/v1/chat/completions&lt;/code&gt;, and compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  /v1/responses        max_output_tokens=8: status='completed' incomplete_details=None output_tokens=8
  /v1/chat/completions max_tokens=8:        finish_reason='length'

http://127.0.0.1:18090: a reply cut off at max_output_tokens is reported as 'completed'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 there, 0 when a cut-off reply is reported as &lt;code&gt;incomplete&lt;/code&gt;, and 2 when it can't tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;A status field is only useful if something can make it say no. When you rely on one, find out whether the implementation has a code path that ever produces a value other than success, and test the failure case on purpose: set a limit too small for the answer and look at what comes back. A field that is hard-coded to &lt;code&gt;completed&lt;/code&gt; passes every test that only checks successful replies.&lt;/p&gt;

&lt;p&gt;And when an API is implemented by translating to another one, check that the translation carries the failure signals back, not just the content. Here the information existed one layer down, in the chat endpoint's &lt;code&gt;finish_reason&lt;/code&gt;, and was dropped on the way out.&lt;/p&gt;

&lt;p&gt;This is the second place this month where llama-server's compatibility layers lose something on the way through: its Anthropic endpoint &lt;a href="https://homelabpostmortem.com/2026/10/18/llama-server-anthropic-endpoint-ignores-tool-choice-and-stops-inside-reasoning/" rel="noopener noreferrer"&gt;ignores which tool you ask for and stops inside the model's reasoning&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ollama loses tool calls in a different way: &lt;a href="https://homelabpostmortem.com/2026/10/22/ollama-drops-a-tool-call-to-a-name-not-in-tools/" rel="noopener noreferrer"&gt;a call to a tool name that isn't in &lt;code&gt;tools&lt;/code&gt; is dropped&lt;/a&gt;, and the reply comes back empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>api</category>
    </item>
    <item>
      <title>Docker 29.6+: docker exec creates world-writable files in dind and rootless Docker</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 22:40:03 +0000</pubDate>
      <link>https://dev.to/homelabpm/docker-296-docker-exec-creates-world-writable-files-in-dind-and-rootless-docker-5bp9</link>
      <guid>https://dev.to/homelabpm/docker-296-docker-exec-creates-world-writable-files-in-dind-and-rootless-docker-5bp9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Since Docker 29.6.0, dockerd runs with umask &lt;code&gt;0000&lt;/code&gt;, so that modes you ask for explicitly (such as &lt;code&gt;COPY --chmod=777&lt;/code&gt;) are not filtered. A process started with &lt;code&gt;docker exec&lt;/code&gt; inherits its umask from the container's shim, which inherits it from containerd. On the standard docker-ce packages containerd is a separate systemd service with umask &lt;code&gt;0022&lt;/code&gt;, and exec'd processes still got &lt;code&gt;0022&lt;/code&gt;. But in docker-in-docker (&lt;code&gt;docker:29.9.1-dind&lt;/code&gt;) and in rootless Docker, dockerd starts containerd itself, and there every &lt;code&gt;docker exec&lt;/code&gt; ran with umask &lt;code&gt;0000&lt;/code&gt;: &lt;code&gt;touch&lt;/code&gt; made &lt;code&gt;-rw-rw-rw-&lt;/code&gt; files and &lt;code&gt;mkdir&lt;/code&gt; made &lt;code&gt;drwxrwxrwx&lt;/code&gt; directories, with no warning. &lt;code&gt;docker run&lt;/code&gt;'s main process still got &lt;code&gt;0022&lt;/code&gt;. &lt;code&gt;docker:29.5.3-dind&lt;/code&gt; was fine; &lt;code&gt;docker:29.6.0-dind&lt;/code&gt; was not. Creating the container with &lt;code&gt;--umask 0022&lt;/code&gt; fixes exec too. Upstream: &lt;a href="https://github.com/moby/moby/issues/53985" rel="noopener noreferrer"&gt;moby/moby#53985&lt;/a&gt;, filed from OrbStack on macOS, which has the same structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable Debian 13 VM with Docker CE 29.9.1 from download.docker.com. The test is the one from the upstream report: start a container, exec &lt;code&gt;umask&lt;/code&gt; in it, create a file and a directory.&lt;/p&gt;

&lt;p&gt;On the plain install, nothing looks wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dockerd: Umask: 0000
containerd: Umask:  0022
shim (after run): Umask:    0022
exec umask: 0022
  exec created: drwxr-xr-x /tmp/d
  exec created: -rw-r--r-- /tmp/f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;dockerd itself is at &lt;code&gt;0000&lt;/code&gt;, but containerd isn't, and the shim and the exec'd shell follow containerd.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;docker:29.9.1-dind&lt;/code&gt;, which runs its own dockerd and lets it start containerd, the same steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dockerd: Umask: 0000
shim: Umask:    0000
inner exec umask: 0000
  inner exec created: drwxrwxrwx /tmp/d
  inner exec created: -rw-rw-rw- /tmp/f
inner run umask: 0022
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with rootless Docker, set up with &lt;code&gt;dockerd-rootless-setuptool.sh install&lt;/code&gt; on the same VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dockerd: Umask: 0000
containerd: Umask:  0000
exec umask: 0000
  exec created: -rw-rw-rw-
run umask: 0022
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version boundary, in dind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== docker:29.5.3-dind
server: 29.5.3  dockerd Umask:  0022
inner exec umask: 0022
=== docker:29.6.0-dind
server: 29.6.0  dockerd Umask:  0000
inner exec umask: 0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;On the setup most people test with, the docker-ce packages on a Linux host, it doesn't happen. That is probably why the change, merged in June, was only reported in October.&lt;/p&gt;

&lt;p&gt;Where it does happen, nothing fails at the time. The files are created, the command exits 0, and everything works, because world-writable is more permissive, not less. The trouble shows up later and somewhere else. In the upstream report, files written by a &lt;code&gt;docker exec&lt;/code&gt; hook were &lt;code&gt;0666&lt;/code&gt;, and OpenSSH refused to use them (&lt;code&gt;Bad owner or permissions&lt;/code&gt;). Other tools that check permissions refuse such files the same way; SSH private keys are the usual example. And anything written to a volume shared with other containers or users is writable by all of them.&lt;/p&gt;

&lt;p&gt;The places it does happen are common in homelabs and CI: Docker-in-Docker CI runners, rootless Docker on a shared machine, Docker Desktop alternatives on a laptop. &lt;code&gt;docker run&lt;/code&gt; behaves normally in all of them, so a quick test that starts a container and creates a file looks fine. Only &lt;code&gt;exec&lt;/code&gt; is affected, and setup scripts, CI steps and maintenance commands are exactly what people run with &lt;code&gt;exec&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/moby/moby/pull/52892" rel="noopener noreferrer"&gt;moby#52892&lt;/a&gt;, "daemon: Set umask to 0000", merged in June for 29.6.0, clears the daemon's umask so that "explicitly requested file modes are preserved instead of being filtered by the previous &lt;code&gt;0022&lt;/code&gt; umask". That fixes things like &lt;code&gt;COPY --chmod=777&lt;/code&gt; producing &lt;code&gt;0755&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A process's umask is inherited by its children. The container's main process doesn't care: runc sets &lt;code&gt;0022&lt;/code&gt; for it explicitly. An exec'd process gets no such default when the container spec has no umask, so it keeps whatever its parent, the shim, has. The shim is started by containerd and inherits containerd's umask.&lt;/p&gt;

&lt;p&gt;So the outcome depends on who started containerd. With the docker-ce packages, containerd is &lt;code&gt;containerd.service&lt;/code&gt;, started by systemd with the usual &lt;code&gt;0022&lt;/code&gt;, and dockerd's &lt;code&gt;0000&lt;/code&gt; never reaches it. In dind and rootless Docker, dockerd starts containerd as its own child, containerd inherits &lt;code&gt;0000&lt;/code&gt;, and so does every shim and every exec. The upstream reporter, on OrbStack, saw dockerd, containerd and every shim at &lt;code&gt;0000&lt;/code&gt; as well.&lt;/p&gt;

&lt;p&gt;The open fix, &lt;a href="https://github.com/moby/moby/pull/53986" rel="noopener noreferrer"&gt;moby#53986&lt;/a&gt;, sets &lt;code&gt;0022&lt;/code&gt; for exec processes when the container spec has no umask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Until that lands, either give the container its own umask or set one in each exec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the container with &lt;code&gt;--umask 0022&lt;/code&gt;&lt;/strong&gt; (the flag came with &lt;a href="https://github.com/moby/moby/pull/53463" rel="noopener noreferrer"&gt;moby#53463&lt;/a&gt; in Docker 29.8.0). Exec'd processes then use it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inner --umask 0022: exec umask 0022              (dind 29.9.1)
rootless --umask 0022: exec umask 0022           (rootless 29.9.1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs a recreate, and it applies per container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or set the umask in the exec'd command:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'umask 022; &amp;lt;command&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave &lt;code&gt;0022&lt;/code&gt; in rootless Docker and works on any version, but every caller has to remember it.&lt;/p&gt;

&lt;p&gt;A plain install of the docker-ce packages doesn't need either. Check rather than assume, since a distribution package or a custom setup may start containerd differently.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-docker-exec-umask.sh&lt;/code&gt; reads the shim's umask for every running container from &lt;code&gt;/proc&lt;/code&gt;, notes which containers were created with their own &lt;code&gt;--umask&lt;/code&gt;, and with &lt;code&gt;--probe&lt;/code&gt; runs &lt;code&gt;umask&lt;/code&gt; in a throwaway container with &lt;code&gt;docker exec&lt;/code&gt;. In rootless Docker on the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ok       r2: created with --umask 0022; exec uses it
  AFFECTED r1: shim umask 0000, so docker exec processes create 0666 files and 0777 directories
  probe    docker exec in a new container without --umask: umask 0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 there and inside &lt;code&gt;docker:29.9.1-dind&lt;/code&gt;, and 0 on the plain install and on 29.5.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;When a change sets something process-wide, ask what inherits it. A umask, an environment variable, a resource limit or an open file descriptor is passed down to every child, and the change's author tested the processes they had in mind. Here that was the daemon's own file creation; the exec'd process three generations down was not.&lt;/p&gt;

&lt;p&gt;And reproduce on the reporter's setup, not just on the usual one. On a standard install this looked like a bug that didn't exist. The process tree is what differs, so the first thing to check was who starts containerd.&lt;/p&gt;

&lt;p&gt;Rootless Docker has another quiet difference from rootful on this same release: &lt;a href="https://homelabpostmortem.com/2026/10/18/rootless-docker-build-ignores-resource-memory-limits/" rel="noopener noreferrer"&gt;&lt;code&gt;docker buildx build --resource&lt;/code&gt; limits are ignored there&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>COPY --chown with a missing user builds fine and leaves the files owned by root</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 22:40:01 +0000</pubDate>
      <link>https://dev.to/homelabpm/copy-chown-with-a-missing-user-builds-fine-and-leaves-the-files-owned-by-root-7pe</link>
      <guid>https://dev.to/homelabpm/copy-chown-with-a-missing-user-builds-fine-and-leaves-the-files-owned-by-root-7pe</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: The Dockerfile reference says a &lt;code&gt;COPY --chown&lt;/code&gt; name that isn't in the image's &lt;code&gt;/etc/passwd&lt;/code&gt; or &lt;code&gt;/etc/group&lt;/code&gt; makes the build fail. With Docker 29.9.1 and buildx v0.38.0 (BuildKit v0.34.0 inside), it didn't. &lt;code&gt;COPY --chown=nosuchuser file.txt /a&lt;/code&gt; built with exit 0, printed no warning, and &lt;code&gt;/a&lt;/code&gt; was owned by &lt;code&gt;0:0&lt;/code&gt;. A numeric ID of 2^31 or more (&lt;code&gt;--chown=2147483648&lt;/code&gt;) also became &lt;code&gt;0:0&lt;/code&gt;. A missing group with a numeric user (&lt;code&gt;--chown=1000:nosuchgroup&lt;/code&gt;) became &lt;code&gt;1000:1000&lt;/code&gt;. The only cases that failed were &lt;code&gt;COPY --link --chown=nosuchuser&lt;/code&gt;, with a clear error, and a &lt;code&gt;FROM scratch&lt;/code&gt; stage with no passwd file at all. So a typo, or a user created in a different build stage, quietly gives files to root, and you find out when the non-root process that was supposed to own them gets &lt;code&gt;Permission denied&lt;/code&gt;. Upstream: &lt;a href="https://github.com/moby/buildkit/issues/7292" rel="noopener noreferrer"&gt;moby/buildkit#7292&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable Debian 13 VM, Docker CE 29.9.1 with buildx v0.38.0 from download.docker.com, the default &lt;code&gt;docker&lt;/code&gt; builder. One Dockerfile with four &lt;code&gt;--chown&lt;/code&gt; forms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.24&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=nosuchuser file.txt /a&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=2147483648 file.txt /b&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=1000:nosuchgroup file.txt /c&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=nobody file.txt /d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker buildx build &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;--load&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; chtest &lt;span class="nb"&gt;.&lt;/span&gt;
...
build &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; chtest &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%n %u:%g'&lt;/span&gt; /a /b /c /d
/a 0:0
/b 0:0
/c 1000:1000
/d 65534:65534
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning in the build output. &lt;code&gt;/d&lt;/code&gt; is the control: &lt;code&gt;nobody&lt;/code&gt; exists in alpine, and the file got &lt;code&gt;65534:65534&lt;/code&gt;. Every other line asked for an owner that doesn't exist and got something else without a word. With both names missing (&lt;code&gt;--chown=nosuchuser:nosuchuser&lt;/code&gt;), a &lt;code&gt;RUN stat&lt;/code&gt; later in the same build printed &lt;code&gt;/a 0:0&lt;/code&gt;. With a known user and a missing group (&lt;code&gt;--chown=nobody:nosuchgroup&lt;/code&gt;), the result was &lt;code&gt;65534:65534&lt;/code&gt;, the user's own group.&lt;/p&gt;

&lt;p&gt;The two cases that did fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --link --chown=nosuchuser file.txt /a&lt;/span&gt;
ERROR: failed to build: failed to solve: --chown=nosuchuser: user and group names can't be resolved
when used with --link, use numeric uid[:gid] instead

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; scratch&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=nosuchuser file.txt /a&lt;/span&gt;
ERROR: failed to build: failed to solve: failed to load cache key: invalid user index -1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.docker.com/reference/dockerfile/#copy---chown" rel="noopener noreferrer"&gt;Dockerfile reference&lt;/a&gt;, read on 20 October, says that if the passwd and group files "don't contain the specified names, the build fails". Here that held only for a base with no passwd file at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;The build succeeds and the image runs. Ownership only matters when a process other than root touches the file, and in a typical image that happens at run time: an app running as &lt;code&gt;USER app&lt;/code&gt; can't write to a directory that was supposed to be its own, or a config file meant to be private belongs to root instead. The error points at the running container, not at a &lt;code&gt;COPY&lt;/code&gt; line in the Dockerfile.&lt;/p&gt;

&lt;p&gt;The ways to get a missing name are ordinary. A typo. A user created in a build stage, and the &lt;code&gt;COPY --chown&lt;/code&gt; in the next stage, which starts from a different base without that user. A base image changed from Debian to alpine, where the same user doesn't exist. A distroless base, where few names exist at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;When BuildKit applies a &lt;code&gt;--chown&lt;/code&gt; name, it reads the destination stage's &lt;code&gt;/etc/passwd&lt;/code&gt; and looks the name up (&lt;code&gt;readUser&lt;/code&gt; in &lt;code&gt;solver/llbsolver/ops/user_linux.go&lt;/code&gt;, the same code in BuildKit v0.34.0, which Docker 29.9.1 vendors). The result starts as uid 0, gid 0, and is only filled in if the lookup finds something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;us&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Uid&lt;/span&gt;
    &lt;span class="n"&gt;us&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gid&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 no &lt;code&gt;else&lt;/code&gt;. A name that isn't found leaves &lt;code&gt;0:0&lt;/code&gt; and returns no error. Groups work the same way, which is why &lt;code&gt;1000:nosuchgroup&lt;/code&gt; kept the gid that the numeric user had set, and &lt;code&gt;nobody:nosuchgroup&lt;/code&gt; kept nobody's group.&lt;/p&gt;

&lt;p&gt;The numeric case is a parsing limit. In BuildKit's LLB client, &lt;code&gt;parseUID&lt;/code&gt; parses a &lt;code&gt;--chown&lt;/code&gt; value with &lt;code&gt;strconv.ParseInt(str, 10, 32)&lt;/code&gt;, a 32-bit signed parse, so &lt;code&gt;2147483648&lt;/code&gt; and above don't parse as numbers, are treated as names, aren't found, and end up as root.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--link&lt;/code&gt; refuses names outright, and its error says why: they "can't be resolved when used with --link". The &lt;code&gt;scratch&lt;/code&gt; failure comes from a different place in the build, not from this lookup. A fix for the silent fallback was proposed in &lt;a href="https://github.com/moby/buildkit/pull/7295" rel="noopener noreferrer"&gt;buildkit#7295&lt;/a&gt;, but it was closed without being merged on 20 October, and no other fix is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make sure the user exists in the stage that does the &lt;code&gt;COPY&lt;/code&gt;.&lt;/strong&gt; In a multi-stage build, create it again in the final stage, or start the final stage from a base that has it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.24&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-S&lt;/span&gt; svc &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; svc svc
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=svc:svc file.txt /a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Or use numeric IDs below 2^31&lt;/strong&gt;, which never need a lookup: &lt;code&gt;--chown=1000:1000&lt;/code&gt;. &lt;code&gt;/c&lt;/code&gt; above shows that a numeric user with a named group can still go wrong, so make both numeric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the result, not the Dockerfile.&lt;/strong&gt; After a build, &lt;code&gt;docker run --rm &amp;lt;image&amp;gt; stat -c '%n %u:%g' &amp;lt;paths&amp;gt;&lt;/code&gt; on the paths you chowned takes a second and catches all of the above.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-dockerfile-chown.sh&lt;/code&gt; reads a Dockerfile, finds every &lt;code&gt;COPY&lt;/code&gt;/&lt;code&gt;ADD --chown&lt;/code&gt; without &lt;code&gt;--link&lt;/code&gt;, and resolves each user and group against the stage's base image (read with &lt;code&gt;docker create&lt;/code&gt; and &lt;code&gt;docker cp&lt;/code&gt;, nothing is run) plus any users created with &lt;code&gt;useradd&lt;/code&gt;/&lt;code&gt;adduser&lt;/code&gt;/&lt;code&gt;groupadd&lt;/code&gt;/&lt;code&gt;addgroup&lt;/code&gt; earlier in the stage or its parents. On a test Dockerfile (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tap"&gt;&lt;code&gt;  ok       app
  SILENT   nosuchuser: user 'nosuchuser' is not in alpine:3.24's /etc/passwd. The build succeeds and the files get root (0:0).
  SILENT   2147483648: numeric user 2147483648 is 2^31 or more and is treated as a name. The build succeeds and the files get root (0:0).
  SILENT   1000:nosuchgroup: group 'nosuchgroup' is not in alpine:3.24's /etc/group. The build succeeds and the files get a group other than the one named.
  ok       nobody
  ok       nosuchuser: used with --link, which fails the build if a name does not resolve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 when any &lt;code&gt;--chown&lt;/code&gt; would fall back. Building that Dockerfile confirmed each line: the paths it flagged came out as &lt;code&gt;0:0&lt;/code&gt;, &lt;code&gt;0:0&lt;/code&gt;, &lt;code&gt;1000:1000&lt;/code&gt; and, for &lt;code&gt;nobody:nosuchgroup&lt;/code&gt;, &lt;code&gt;65534:65534&lt;/code&gt;, and the &lt;code&gt;app&lt;/code&gt; user created with &lt;code&gt;adduser&lt;/code&gt; earlier in the build owned its file as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;When the documentation promises a failure, test the failure. "The build fails if the user doesn't exist" is the kind of sentence that's easy to believe and never check, because you only discover it's wrong by making the mistake it's supposed to catch. One deliberately wrong &lt;code&gt;--chown&lt;/code&gt; would have shown it.&lt;/p&gt;

&lt;p&gt;The code pattern is worth recognising too: a value that starts as a safe-looking default (zero) and is only overwritten when a lookup succeeds. Zero is not safe for a uid; it's root. A lookup that can fail should either fail loudly or have a default chosen for that purpose.&lt;/p&gt;

&lt;p&gt;Ownership going quietly wrong between build and run came up before with Podman, where &lt;a href="https://homelabpostmortem.com/2026/09/30/podman-export-of-a-keep-id-container-shifts-every-owner/" rel="noopener noreferrer"&gt;&lt;code&gt;podman export&lt;/code&gt; of a keep-id container shifted every file owner&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker 29.8+: Swarm containers can't reach their own node's ports under ufw</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:29:24 +0000</pubDate>
      <link>https://dev.to/homelabpm/docker-298-swarm-containers-cant-reach-their-own-nodes-ports-under-ufw-3ae0</link>
      <guid>https://dev.to/homelabpm/docker-298-swarm-containers-cant-reach-their-own-nodes-ports-under-ufw-3ae0</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update 2026-10-23.&lt;/strong&gt; A Docker maintainer opened a fix for the Swarm case: &lt;a href="https://github.com/moby/moby/pull/54006" rel="noopener noreferrer"&gt;moby#54006&lt;/a&gt;, "libnetwork: DNAT gwbridge packets to ingress ports", set for 29.10.0, not yet reviewed or merged. As its description reads, IPv4 packets from a task to an ingress port on its own node would be DNATed on &lt;code&gt;docker_gwbridge&lt;/code&gt; again, as before 29.8, so they no longer pass through INPUT, while &lt;code&gt;docker-proxy&lt;/code&gt; keeps handling IPv6 clients and the host's loopback addresses. It also says that hairpin connections to ordinary containers' published ports still go through &lt;code&gt;docker-proxy&lt;/code&gt;, so the plain bridge-network case in the update below is not changed by it. Until a release has it, the per-port rules below still apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 2026-10-21.&lt;/strong&gt; It isn't only Swarm. A Docker maintainer explained in &lt;a href="https://github.com/moby/moby/issues/53962#issuecomment-6296732678" rel="noopener noreferrer"&gt;#53962&lt;/a&gt; that since 29.8.0 Swarm ports are published through the same machinery as ordinary bridge-published ports, and that ufw's default policy also blocks a container from reaching a bridge-published port when the client is on the same bridge, an existing bug that Swarm inherited. Tested on a fresh Debian 13 VM with ufw as below and no Swarm: a container on a user-defined network &lt;code&gt;appnet&lt;/code&gt; calling &lt;code&gt;&amp;lt;host-ip&amp;gt;:8080&lt;/code&gt;, published by another container on &lt;code&gt;appnet&lt;/code&gt;, timed out, as did a container on the default bridge calling a port published from the default bridge. A container on a different network, and the host itself, got through. The same per-port rule fixed it, on the bridge the client comes from (&lt;code&gt;ufw allow in on br-&amp;lt;network id&amp;gt; to any port 8080 proto tcp&lt;/code&gt;, or &lt;code&gt;on docker0&lt;/code&gt; for the default bridge), and so did &lt;code&gt;"userland-proxy": false&lt;/code&gt;. Docker 29.9.2 and 29.7.2 behaved the same; on 28.5.2 a container on a different network was blocked too. &lt;code&gt;check-swarm-ufw-ingress.sh&lt;/code&gt; now checks bridge-published ports as well and names the bridge each rule needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On Docker 29.9.1 with Swarm and ufw (default deny incoming), a container on an overlay network that connects to an ingress-published port on the same node times out. The host itself, connecting to the same address and port, gets HTTP 200. The kernel log shows ufw dropping the SYN as it arrives from &lt;code&gt;docker_gwbridge&lt;/code&gt;. A Swarm and service created on 29.7.2 passed the same test. The upstream report (&lt;a href="https://github.com/moby/moby/issues/53962" rel="noopener noreferrer"&gt;moby/moby#53962&lt;/a&gt;) traces it to 29.8.0, which hands this connection to &lt;code&gt;docker-proxy&lt;/code&gt; through the host's INPUT chain instead of DNATing it. The workaround in that thread, &lt;code&gt;ufw allow in on docker_gwbridge&lt;/code&gt;, works but opens every host port to every container on the node. A per-port rule works too: &lt;code&gt;ufw allow in on docker_gwbridge to any port 8080 proto tcp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable Debian 13 VM, Docker CE 29.9.1, ufw with nothing but SSH allowed:&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;$ &lt;/span&gt;ufw default deny incoming &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ufw allow 22/tcp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker swarm init &lt;span class="nt"&gt;--advertise-addr&lt;/span&gt; 192.168.128.179
&lt;span class="nv"&gt;$ &lt;/span&gt;docker network create &lt;span class="nt"&gt;-d&lt;/span&gt; overlay &lt;span class="nt"&gt;--attachable&lt;/span&gt; appnet
&lt;span class="nv"&gt;$ &lt;/span&gt;docker service create &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="nt"&gt;--network&lt;/span&gt; appnet nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single node, so the service's task always runs on the node being connected to. From a container on the overlay network, through the published port:&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;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; appnet alpine wget &lt;span class="nt"&gt;-T&lt;/span&gt; 5 &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://192.168.128.179:8080/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1                  &lt;span class="o"&gt;(&lt;/span&gt;nothing back within the 5 s &lt;span class="nb"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel log at the same moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[UFW BLOCK] IN=docker_gwbridge OUT= ... SRC=172.18.0.4 DST=192.168.128.179 ... DPT=8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the host, same address and port:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; http://192.168.128.179:8080/
200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ss -tlnp&lt;/code&gt; showed &lt;code&gt;docker-proxy&lt;/code&gt; listening on &lt;code&gt;0.0.0.0:8080&lt;/code&gt; and &lt;code&gt;[::]:8080&lt;/code&gt;. &lt;code&gt;ufw disable&lt;/code&gt; made the container's request succeed, and so did &lt;code&gt;"userland-proxy": false&lt;/code&gt; in &lt;code&gt;daemon.json&lt;/code&gt; with ufw left on.&lt;/p&gt;

&lt;p&gt;On 29.7.2 with a Swarm created on that version, the same probe succeeded with ufw on and nothing allowed. No &lt;code&gt;UFW BLOCK&lt;/code&gt; line, no &lt;code&gt;docker-proxy&lt;/code&gt; on 8080, and a DNAT rule for the port in the nat table's &lt;code&gt;DOCKER-INGRESS&lt;/code&gt; chain instead. One detail from getting there: downgrading the packages to 29.7.2 and keeping the Swarm and service created on 29.9.1 still failed the same way. Only after &lt;code&gt;docker swarm leave --force&lt;/code&gt; and re-creating them on 29.7.2 did it pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Every check from outside the container passes. The service is up with 1/1 replicas, the port answers from the host and, according to the report, from other nodes.&lt;/p&gt;

&lt;p&gt;What fails is one container calling another service by the node's address and published port, which is common in homelab setups: a reverse proxy pointing at &lt;code&gt;host:port&lt;/code&gt;, or an app configured with the host's IP because that's what the documentation showed. It shows up as a timeout, not a refusal, so it looks like a routing or overlay MTU problem. Those are what search results are full of.&lt;/p&gt;

&lt;p&gt;And the trigger was a minor Docker upgrade on a machine where ufw had been configured long before. Nothing about the firewall changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;Up to 29.7, a connection to an ingress-published port from a container on the same node was DNATed by Docker's rules in the nat table and never reached the host's INPUT chain. ufw's rules live in INPUT and never saw it.&lt;/p&gt;

&lt;p&gt;According to the reporter's analysis in #53962, since 29.8.0 the per-port DNAT rule excludes traffic arriving from &lt;code&gt;docker_gwbridge&lt;/code&gt;, which is where a container's connection to the node address comes from. With the userland proxy on (the default), that connection is delivered to the host and accepted by &lt;code&gt;docker-proxy&lt;/code&gt; instead. Being addressed to the host, it goes through INPUT, where ufw's default deny drops it. The test here matches every part of that: the drop logged with &lt;code&gt;IN=docker_gwbridge&lt;/code&gt;, &lt;code&gt;docker-proxy&lt;/code&gt; listening on 29.9.1 and not on 29.7.2, the DNAT rule on 29.7.2, and both &lt;code&gt;ufw disable&lt;/code&gt; and &lt;code&gt;userland-proxy: false&lt;/code&gt; fixing it. The connection from the host itself never crosses &lt;code&gt;docker_gwbridge&lt;/code&gt;, which is why it still works.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/moby/moby/pull/53723" rel="noopener noreferrer"&gt;moby#53723&lt;/a&gt;, merged in September, fixed the same kind of path with the userland proxy disabled. A maintainer asked in the issue whether 29.9.1 still shows the problem. On this single-node test, it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The workaround in the issue is to let everything in from &lt;code&gt;docker_gwbridge&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;&lt;span class="nv"&gt;$ &lt;/span&gt;ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on docker_gwbridge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works (the probe passed), but &lt;code&gt;docker_gwbridge&lt;/code&gt; is where every container on the node reaches the host from, so it exposes every port the host listens on, including SSH and anything bound to &lt;code&gt;0.0.0.0&lt;/code&gt;, to every container on that node.&lt;/p&gt;

&lt;p&gt;Allowing only the published ports is enough. With two services, &lt;code&gt;web&lt;/code&gt; on 8080 and &lt;code&gt;web2&lt;/code&gt; on 9090:&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;$ &lt;/span&gt;ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on docker_gwbridge to any port 8080 proto tcp
  probe 8080: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0   probe 9090: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nv"&gt;$ &lt;/span&gt;ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on docker_gwbridge to any port 9000:9100 proto tcp
  probe 8080: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0   probe 9090: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each published port needs its own rule (or a range), and a new service needs a new one. Two alternatives, both observed to work: connect to the service by name on the overlay network (&lt;code&gt;http://web/&lt;/code&gt;, which never leaves the overlay), or set &lt;code&gt;"userland-proxy": false&lt;/code&gt;, which has wider effects on how Docker publishes ports and is worth reading up on before changing.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-swarm-ufw-ingress.sh&lt;/code&gt; lists the node's ingress-published ports and says which ones have no matching ufw rule on &lt;code&gt;docker_gwbridge&lt;/code&gt;. With no rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  DROPPED  web  8080/tcp
  DROPPED  web2  9090/tcp

UFW BLOCK lines from docker_gwbridge in the last day: 21
Docker 29.9.1, swarm + ufw: containers on this node cannot reach 2 of 2 ingress-published port(s) on this node.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits 1 until every port is covered, and 0 with the per-port rules or the interface-wide one. The ports come from &lt;code&gt;docker service inspect&lt;/code&gt;, so it needs to run on a manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Test from where the client actually is. "The port answers" checked from the host and "the port answers" checked from a container are different claims, and here only the first one was true. When something works from the host and times out from a container on the same machine, look at the firewall's log for the bridge the container comes from before looking at routing.&lt;/p&gt;

&lt;p&gt;The other habit is about workarounds. The first one that works is often the widest one, and an interface-wide allow on a bridge is a much bigger change than the bug it works around. Narrow it, and test the narrow version too.&lt;/p&gt;

&lt;p&gt;This is the reverse of an older problem on this site: &lt;a href="https://homelabpostmortem.com/2026/08/22/docker-publishes-past-ufw/" rel="noopener noreferrer"&gt;Docker publishing ports straight past ufw&lt;/a&gt;, where ufw said a port was closed and Docker made it reachable anyway. Here ufw is consulted where it wasn't before, and the traffic it now drops is Docker's own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>networking</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Rootless Docker builds silently ignore --resource memory limits</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:28:22 +0000</pubDate>
      <link>https://dev.to/homelabpm/rootless-docker-builds-silently-ignore-resource-memory-limits-22m3</link>
      <guid>https://dev.to/homelabpm/rootless-docker-builds-silently-ignore-resource-memory-limits-22m3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Per-step resource limits in BuildKit (&lt;code&gt;docker buildx build --resource memory=...&lt;/code&gt;, Bake &lt;code&gt;resources&lt;/code&gt;) are silently ignored when the builder is rootless, including the builder inside rootless Docker. On rootless Docker 29.9.1 with buildx v0.38.0, a &lt;code&gt;RUN&lt;/code&gt; step limited to 256 MB allocated 600 MB and printed "not killed"; the build exited 0 and said nothing about the limit. On the same rootless daemon, &lt;code&gt;docker run --memory 256m&lt;/code&gt; with the same allocation was killed (exit 137), so the cgroup controllers were delegated and limits were possible. On rootful Docker the build failed with 137, as intended. The upstream report (&lt;a href="https://github.com/moby/buildkit/issues/7287" rel="noopener noreferrer"&gt;moby/buildkit#7287&lt;/a&gt;) points at BuildKit's rootless spec conversion, which removes the resource settings. The toolkit's &lt;code&gt;check-rootless-build-limits.sh&lt;/code&gt; tells you which kind of builder you have and can measure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable Debian 13 VM, Docker CE 29.9.1 installed rootless for a normal user with &lt;code&gt;dockerd-rootless-setuptool.sh install&lt;/code&gt;. cgroup v2 with the systemd driver, and the user's service cgroup has the controllers delegated:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/user.slice/user-&lt;span class="nv"&gt;$UID&lt;/span&gt;.slice/user@&lt;span class="nv"&gt;$UID&lt;/span&gt;.service/cgroup.subtree_control
cpu memory pids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Dockerfile whose only step pushes 600 MB through a pipe buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 600m /dev/zero | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &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;"not killed"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built with a 256 MB limit on its steps:&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;$ &lt;/span&gt;docker buildx build &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256m &lt;span class="nt"&gt;--resource&lt;/span&gt; memory-swap&lt;span class="o"&gt;=&lt;/span&gt;256m &lt;span class="nb"&gt;.&lt;/span&gt;
...
&lt;span class="c"&gt;#5 11.79 not killed&lt;/span&gt;
...
rootless build &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning about the limit anywhere in the output. The same allocation with &lt;code&gt;docker run&lt;/code&gt; on the same rootless daemon:&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;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--memory&lt;/span&gt; 256m &lt;span class="nt"&gt;--memory-swap&lt;/span&gt; 256m alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'head -c 600m /dev/zero | tail &amp;gt; /dev/null'&lt;/span&gt;
Killed
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;137
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the same build on rootful Docker on the same VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rootful build rc=1      (the RUN step failed with "exit code: 137")
rootful build without --resource rc=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;A limit that works shows up as a failure, and a limit that is ignored shows up as nothing. A build that stays within its limit looks the same either way, so the only way to see whether the limit exists is to exceed it on purpose. Most people set a limit to protect a small machine from one runaway step and never see it trigger.&lt;/p&gt;

&lt;p&gt;The rootless daemon also behaves correctly in every other place you'd check. &lt;code&gt;docker run --memory&lt;/code&gt; is enforced, &lt;code&gt;docker info&lt;/code&gt; shows cgroup v2 with the systemd driver, and the limits are delegated. Nothing suggests the build path is different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;Before BuildKit runs a step in rootless mode, it converts the container spec for rootless use. According to the issue, and in BuildKit's v0.34.0 source, that conversion (&lt;code&gt;ToRootless()&lt;/code&gt;) sets the spec's Linux &lt;code&gt;Resources&lt;/code&gt; to nil and clears its cgroup path, with a comment about cgroup v1, where an unprivileged user can't set limits. On cgroup v2 with delegation an unprivileged user can, which is how rootless &lt;code&gt;docker run --memory&lt;/code&gt; works. The build path never gets to use it. The step runs with whatever cgroup the builder has, and the request is dropped before it reaches the runtime, so there is nothing to warn about at that point.&lt;/p&gt;

&lt;p&gt;Which code path Docker's embedded builder takes was not traced here. What was measured is the effect: the limit is gone in rootless mode and present in rootful mode, with everything else equal.&lt;/p&gt;

&lt;p&gt;The reporter has opened two fixes, &lt;a href="https://github.com/moby/buildkit/pull/7288" rel="noopener noreferrer"&gt;buildkit#7288&lt;/a&gt; (warn when limits are dropped) and &lt;a href="https://github.com/moby/buildkit/pull/7289" rel="noopener noreferrer"&gt;#7289&lt;/a&gt; (apply them on delegated cgroup v2). A maintainer approved #7288 on 19 October; neither is merged yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no setting that makes rootless BuildKit apply them yet. The options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build on a rootful builder&lt;/strong&gt; where the limits matter. On this VM the rootful build was killed at the limit as intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit the builder as a whole.&lt;/strong&gt; In rootless Docker the builder runs inside the user's systemd service, so a &lt;code&gt;MemoryMax=&lt;/code&gt; on that service (for example &lt;code&gt;systemctl --user edit docker&lt;/code&gt; or a limit on &lt;code&gt;user@&amp;lt;uid&amp;gt;.service&lt;/code&gt;) caps every build and container together. This was not tested here, and it is a ceiling for everything rather than a limit per step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which one you have.&lt;/strong&gt; &lt;code&gt;check-rootless-build-limits.sh&lt;/code&gt; reports whether the current Docker endpoint is rootless (exit 1 if it is). With &lt;code&gt;--probe&lt;/code&gt; it builds a one-step image that allocates 300 MB under a 128 MB limit and reports what happened. On the rootless daemon:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Docker 29.9.1 (rootless): a step limited to 128 MB used 300 MB and the build succeeded.
--resource limits are not applied here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;exit 1. On the rootful daemon: &lt;code&gt;the step was killed at its 128 MB limit (137). Limits apply.&lt;/code&gt;, exit 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Test a limit by breaking it. "The flag was accepted" and "the build passed" say nothing about whether a limit exists; only a step that exceeds it and gets stopped does. Keep a tiny over-the-limit probe next to any limit you rely on, and run it whenever the environment changes, here from rootful to rootless.&lt;/p&gt;

&lt;p&gt;And when one path enforces a limit, don't assume the neighbouring path does. &lt;code&gt;docker run&lt;/code&gt; and &lt;code&gt;docker build&lt;/code&gt; on the same daemon took the same &lt;code&gt;256m&lt;/code&gt; and did different things with it.&lt;/p&gt;

&lt;p&gt;This is the same shape as &lt;a href="https://homelabpostmortem.com/2026/08/19/pi-memory-cgroup-disabled-by-firmware/" rel="noopener noreferrer"&gt;a Pi that accepted every Docker memory limit and enforced none&lt;/a&gt;. There it was the memory controller that was missing, so &lt;code&gt;docker run&lt;/code&gt; lied as well. Here the controller works and one code path throws the request away.&lt;/p&gt;

&lt;p&gt;Rootless Docker differs from rootful in one more way since 29.6: &lt;a href="https://homelabpostmortem.com/2026/10/20/docker-exec-creates-world-writable-files-in-dind-and-rootless-since-29-6/" rel="noopener noreferrer"&gt;&lt;code&gt;docker exec&lt;/code&gt; there creates world-writable files&lt;/a&gt;, because dockerd's umask reaches the exec'd process through containerd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>llama-server can leave a keep-alive connection unread until another closes</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:27:19 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-server-can-leave-a-keep-alive-connection-unread-until-another-closes-i71</link>
      <guid>https://dev.to/homelabpm/llama-server-can-leave-a-keep-alive-connection-unread-until-another-closes-i71</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: When clients keep more keep-alive connections open to &lt;code&gt;llama-server&lt;/code&gt; than it has base HTTP threads, a newly accepted connection can sit with its request unread until some other connection closes. With &lt;code&gt;--threads-http 4&lt;/code&gt; and 32 keep-alive clients on llama.cpp b11717, 3 of 9 runs on a freshly started server had exactly one such connection: its first response took about 42 seconds while the median was 1.6 to 1.7 seconds, and &lt;code&gt;ss&lt;/code&gt; showed its 209 request bytes sitting in the socket's Recv-Q for the whole wait. No error, no log line, and the response was correct when it arrived. With &lt;code&gt;--threads-http 64&lt;/code&gt; it never happened in 8 runs. The cause was in cpp-httplib's thread pool, which llama-server uses for HTTP. Its maintainer fixed it on 18 October, llama.cpp took the fixed library into build b11737 the next day, and b11750 ran the same test 12 times without a single stall. Update to b11737 or later; on an older build, set &lt;code&gt;--threads-http&lt;/code&gt; to at least the number of connections your clients keep open. Upstream: &lt;a href="https://github.com/ggml-org/llama.cpp/issues/30675" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#30675&lt;/a&gt; and &lt;a href="https://github.com/yhirose/cpp-httplib/issues/2633" rel="noopener noreferrer"&gt;yhirose/cpp-httplib#2633&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container with 6 cores, the b11717 release binary, &lt;code&gt;gemma-3-1b-it-Q4_K_M&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;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-3-1b-it-Q4_K_M.gguf &lt;span class="nt"&gt;--parallel&lt;/span&gt; 8 &lt;span class="nt"&gt;-c&lt;/span&gt; 4096 &lt;span class="nt"&gt;--threads-http&lt;/span&gt; 4 &lt;span class="nt"&gt;--port&lt;/span&gt; 18089
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client opens 32 HTTP/1.1 keep-alive connections back to back, then each one sends &lt;code&gt;POST /completion&lt;/code&gt; (4 tokens) in a loop for 40 seconds and records how long its first response took. That is roughly what a proxy, a chat UI backend or an agent framework with a connection pool does. A typical run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clients 32, 40s: requests 584, median first 1.75s, max first 2.5s, slowest later 2.4s, stuck 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one of the bad ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clients 32, 40s: requests 584, median first 1.70s, max first 42.1s, slowest later 2.5s, stuck 1 #31 42.1s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connection #31, the last one opened, waited 42.1 seconds for its first answer. Every later request on it was as fast as the rest. Sampling &lt;code&gt;ss&lt;/code&gt; once a second during that run showed the same socket the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;05:19:29 209    0          127.0.0.1:18089    127.0.0.1:33046
05:19:30 209    0          127.0.0.1:18089    127.0.0.1:33046
05:19:31 209    0          127.0.0.1:18089    127.0.0.1:33046
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;209 bytes in the Recv-Q is the whole request, delivered by the kernel and never read by the server. That went on for about 40 seconds.&lt;/p&gt;

&lt;p&gt;The count over all runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;&lt;code&gt;--threads-http&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Runs with a stuck connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fresh for each run&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3 of 9 (41.8 s, 42.1 s, 42.1 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reused across runs&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fresh or reused&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;0 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server log had nothing for any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;It is rare, and it only hits one connection. On average everything is fine: throughput is normal, every request succeeds, and the slow one eventually gets a correct answer. Unless something records per-request latency, the only symptom is an occasional user whose message hangs for most of a minute, and that looks like a slow model or a busy GPU.&lt;/p&gt;

&lt;p&gt;It also didn't show in 4 runs on a server that had already handled a run. All three stalls were on a freshly started server, which in a homelab is the moment right after a restart or a model swap, when a proxy reconnects its whole pool at once.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;--threads-http&lt;/code&gt; doesn't sound like it matters. Generation is bounded by &lt;code&gt;--parallel&lt;/code&gt; slots, so it's natural to assume the HTTP threads only parse requests and a handful is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;cpp-httplib gives each accepted connection to a thread for the life of that connection: a keep-alive connection is one job, not one job per request. The pool starts with a base number of threads and can add dynamic threads when it runs out. llama-server sets the base to &lt;code&gt;--threads-http&lt;/code&gt; if given, otherwise &lt;code&gt;max(--parallel + 4, CPUs - 1)&lt;/code&gt;, which is 12 for &lt;code&gt;--parallel 8&lt;/code&gt; on 6 cores.&lt;/p&gt;

&lt;p&gt;The reporter's analysis in cpp-httplib#2633, which the maintainer confirmed, is that the pool only adds a dynamic thread when its idle-thread counter is zero, but that counter still includes threads that have already been woken for an earlier job and haven't picked it up yet. When connections are accepted in a burst, the pool sees "idle threads &amp;gt; 0" for a job that no idle thread will actually take, adds nothing, and the job waits in the queue. The queued connection is then served only when some thread finishes a whole connection: the client closes it, it hits the idle timeout, or it reaches cpp-httplib's limit of 100 requests per connection. In this test each connection made about 18 requests in its 40 seconds, so none reached the limit, and the stuck connection was served at about 42 seconds, right after the other clients finished their 40-second loops and closed their connections. So the 42 seconds is the length of the test, not a property of the bug. With clients that never close, the wait would last until one of them did.&lt;/p&gt;

&lt;p&gt;The test here can't see inside the pool, but the observable parts match: the stall only happened when connections outnumbered base threads, it was always the last connection accepted, its request was unread rather than slow to process, and it ended when other connections closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Update llama.cpp to b11737 or later.&lt;/strong&gt; The library fix went into cpp-httplib on 18 October as &lt;a href="https://github.com/yhirose/cpp-httplib/commit/e97165b5dc6960ad19f9cd152965442afe06469f" rel="noopener noreferrer"&gt;e97165b&lt;/a&gt;, "Spawn a dynamic thread when queued jobs outnumber idle threads". It changes that one condition from &lt;code&gt;idle_thread_count_ == 0&lt;/code&gt; to &lt;code&gt;jobs_.size() &amp;gt; idle_thread_count_&lt;/code&gt; and adds a regression test, and it shipped in cpp-httplib 0.62.0. llama.cpp updated its vendored copy to 0.62.0 in &lt;a href="https://github.com/ggml-org/llama.cpp/pull/30785" rel="noopener noreferrer"&gt;#30785&lt;/a&gt;, merged on 19 October, which is build b11737; b11738 is the first release with binaries.&lt;/p&gt;

&lt;p&gt;On the same container, the b11750 release binary, the same model and client, &lt;code&gt;--threads-http 4&lt;/code&gt;, a fresh server for every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fresh threads-http 4 run 1: clients 32, 40s: requests 576, median first 1.66s, max first 2.4s, slowest later 2.5s, stuck 0
...
fresh threads-http 4 run 12: clients 32, 40s: requests 656, median first 1.50s, max first 2.0s, slowest later 2.2s, stuck 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0 of 12 runs stuck, the slowest first response 2.4 seconds, and no request left in a Recv-Q in any sample. b11717 had stalled in 3 of 9.&lt;/p&gt;

&lt;p&gt;On an older build, start llama-server with enough HTTP threads for every connection your clients keep open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server ... &lt;span class="nt"&gt;--threads-http&lt;/span&gt; 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 64 for 32 connections, no run stalled on b11717 either. Size it from the client side: the connection pool of your proxy, UI or agent framework, plus some headroom for other clients. An idle thread costs little.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-llama-http-stall.sh&lt;/code&gt; reads each running llama-server's build number and command line. A build before b11737 with more established connections than base HTTP threads is flagged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  TOO MANY llama-server 9677 port 18089 (build 11717): 32 client connection(s), 4 base HTTP thread(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the same load on b11750 passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ok       llama-server 9783 port 18089: build 11750 has the fix; 32 client connection(s), 4 base HTTP thread(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--watch SECONDS&lt;/code&gt; it also samples the server's sockets every second and reports any connection whose request stays unread for 5 seconds or more. In the b11717 run that stalled, it printed &lt;code&gt;STALLED  127.0.0.1:18089&amp;lt;-127.0.0.1:54264: request unread for 38 s&lt;/code&gt; and exited 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Look at the maximum, not the average. A median of 1.7 seconds and a maximum of 42 came out of the same run, and only the second one is what a user remembers. Any benchmark of a server that only reports averages or throughput will miss this whole class of problem.&lt;/p&gt;

&lt;p&gt;When a request is slow, find out where it was waiting. A non-zero Recv-Q on the server's socket means the request was delivered and nobody read it, which rules out the network and the model in one look and points at the server's own scheduling.&lt;/p&gt;

&lt;p&gt;Another llama-server setting that doesn't mean what it seems to: &lt;a href="https://homelabpostmortem.com/2026/09/24/llama-server-cache-ram-minus-one-is-not-no-limit/" rel="noopener noreferrer"&gt;&lt;code&gt;--cache-ram -1&lt;/code&gt; is not "no limit"&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>selfhosted</category>
      <category>linux</category>
      <category>performance</category>
    </item>
    <item>
      <title>llama-server's Anthropic API ignores tool_choice and stops inside reasoning</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:26:17 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-servers-anthropic-api-ignores-toolchoice-and-stops-inside-reasoning-4gea</link>
      <guid>https://dev.to/homelabpm/llama-servers-anthropic-api-ignores-toolchoice-and-stops-inside-reasoning-4gea</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: llama-server's Anthropic-compatible &lt;code&gt;/v1/messages&lt;/code&gt; endpoint accepts two request controls and doesn't apply them. A forced tool, &lt;code&gt;tool_choice: {"type": "tool", "name": "get_time"}&lt;/code&gt;, is treated as "call some tool": with &lt;code&gt;get_weather&lt;/code&gt; and &lt;code&gt;get_time&lt;/code&gt; available and a weather question, the model called &lt;code&gt;get_weather&lt;/code&gt;. &lt;code&gt;tool_choice: {"type": "none"}&lt;/code&gt; is not applied at all, and the model called &lt;code&gt;get_weather&lt;/code&gt; again. No error, no log line. Separately, &lt;code&gt;stop_sequences&lt;/code&gt; are matched against the model's reasoning as well as its answer, so a reasoning model that writes the stop string while thinking ends the request before it answers: only a &lt;code&gt;thinking&lt;/code&gt; block comes back, and &lt;code&gt;stop_reason&lt;/code&gt; is &lt;code&gt;end_turn&lt;/code&gt; while &lt;code&gt;stop_sequence&lt;/code&gt; names the match. Both observed on llama.cpp b11717 with Qwen3-0.6B. The stop behaviour has the same shape as a vLLM report filed on 16 October, &lt;a href="https://github.com/vllm-project/vllm/issues/62085" rel="noopener noreferrer"&gt;vllm-project/vllm#62085&lt;/a&gt;, where it breaks Claude Code's auto mode. I found no llama.cpp report of either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A disposable LXC container, the b11717 release binary, &lt;code&gt;Qwen3-0.6B-Q8_0.gguf&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;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; Qwen3-0.6B-Q8_0.gguf &lt;span class="nt"&gt;--jinja&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 4096 &lt;span class="nt"&gt;--port&lt;/span&gt; 18090
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  tool_choice
&lt;/h3&gt;

&lt;p&gt;Two tools, &lt;code&gt;get_weather(city)&lt;/code&gt; and &lt;code&gt;get_time(city)&lt;/code&gt;, the user message "What is the weather in Vienna?", &lt;code&gt;temperature: 0&lt;/code&gt;, every documented &lt;code&gt;tool_choice&lt;/code&gt; form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anthropic tool_choice=null: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "auto"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "any"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "tool", "name": "get_time"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "tool", "name": "get_weather"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "none"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All six came back with &lt;code&gt;stop_reason: tool_use&lt;/code&gt; and the same call. Asking for &lt;code&gt;get_time&lt;/code&gt; by name changed nothing, and neither did &lt;code&gt;none&lt;/code&gt;. The server log had no line for any of them.&lt;/p&gt;

&lt;p&gt;The OpenAI-compatible endpoint on the same server has its own version of this, already reported upstream (&lt;a href="https://github.com/ggml-org/llama.cpp/issues/30369" rel="noopener noreferrer"&gt;llama.cpp#30369&lt;/a&gt;): the named-function form &lt;code&gt;{"type": "function", "function": {"name": "get_time"}}&lt;/code&gt; produced &lt;code&gt;get_weather&lt;/code&gt;, and this time the log said so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;W Wrong type supplied for parameter 'tool_choice', using default value: [json.exception.type_error.302] type must be string, but is object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;tool_choice: "none"&lt;/code&gt; on that endpoint, no tool call was parsed, but the model still wrote one, and it arrived as text in &lt;code&gt;content&lt;/code&gt;: &lt;code&gt;'&amp;lt;tool_call&amp;gt;\n{"name": "get_weather", "arguments": {...'&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  stop_sequences
&lt;/h3&gt;

&lt;p&gt;A question that makes Qwen3 think first, with a stop string that shows up in its reasoning. &lt;code&gt;temperature: 0.6&lt;/code&gt;, two seeds, both endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;stop='answer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;should&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;be'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;seed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;OpenAI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;finish=stop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;content=''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reasoning_len=&lt;/span&gt;&lt;span class="mi"&gt;1639&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tail='...&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;can&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;divide&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Therefore,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="err"&gt;Anthropic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stop_reason=end_turn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stop_sequence='answer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;should&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;be'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;blocks=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;('thinking'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;807&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'...&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;indeed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;prime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Therefore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;')&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;stop='prime'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;seed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;OpenAI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;finish=stop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;content=''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reasoning_len=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tail='Okay,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;so&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;question&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;asking&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="err"&gt;Anthropic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stop_reason=end_turn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stop_sequence='prime'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;blocks=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;('thinking'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'Okay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;so&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;question&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;whether&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;')&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seed 2 gave the same shape. The reasoning stops right before the stop string, the answer is empty (OpenAI) or absent (Anthropic, a single &lt;code&gt;thinking&lt;/code&gt; block and no &lt;code&gt;text&lt;/code&gt; block), and the request reports a normal finish. Without a stop string the same request answered &lt;code&gt;&amp;lt;block&amp;gt;yes&amp;lt;/block&amp;gt;&lt;/code&gt; after about 1,660 characters of reasoning.&lt;/p&gt;

&lt;p&gt;On the Anthropic endpoint the reply also contradicts itself: &lt;code&gt;stop_sequence&lt;/code&gt; says which sequence matched, while &lt;code&gt;stop_reason&lt;/code&gt; says &lt;code&gt;end_turn&lt;/code&gt; instead of &lt;code&gt;stop_sequence&lt;/code&gt;. That happened in the other case too, when the stop string &lt;code&gt;&amp;lt;/block&amp;gt;&lt;/code&gt; cut the visible answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Every reply is well-formed. A tool call that is the wrong tool is still a valid tool call, and with only one tool defined, or a model that happens to pick the forced tool anyway, the bug is invisible. &lt;code&gt;get_weather&lt;/code&gt; was also the right answer to the question here, so the forced-tool test only shows the bug because the forced tool was deliberately the wrong one.&lt;/p&gt;

&lt;p&gt;The stop case looks like the model's fault. An empty answer with &lt;code&gt;finish_reason: stop&lt;/code&gt; reads as "the model decided it was done", and small reasoning models do sometimes ramble and stop oddly. The only clue is that the reasoning ends mid-sentence exactly where the stop string would have started.&lt;/p&gt;

&lt;p&gt;Both matter most for agent tools pointed at a local server. Claude Code can be pointed at any Anthropic-compatible endpoint. According to vllm#62085 its auto mode sends a classifier request with &lt;code&gt;stop_sequences: ["&amp;lt;/block&amp;gt;"]&lt;/code&gt;. In my runs Qwen3 never wrote &lt;code&gt;&amp;lt;/block&amp;gt;&lt;/code&gt; inside its reasoning, so I didn't see that exact request fail here. But any stop string a model can write while thinking will do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;tool_choice&lt;/code&gt;, llama-server converts the Anthropic request to its OpenAI-style internal form (&lt;code&gt;tools/server/server-chat.cpp&lt;/code&gt;). Reading that conversion in the source (master and b10703), &lt;code&gt;{"type": "tool", "name": ...}&lt;/code&gt; becomes OpenAI &lt;code&gt;"required"&lt;/code&gt; and the name is dropped, and &lt;code&gt;{"type": "none"}&lt;/code&gt; has no mapping, so the default &lt;code&gt;"auto"&lt;/code&gt; stays. "Required" means "call one of the tools", which the model did, and nothing tells it which one. The results above match that: &lt;code&gt;any&lt;/code&gt; and the named forms behave identically. The same conversion has been reported dropping other fields too: &lt;code&gt;output_config.effort&lt;/code&gt; (&lt;a href="https://github.com/ggml-org/llama.cpp/issues/30564" rel="noopener noreferrer"&gt;llama.cpp#30564&lt;/a&gt;), and per its fix PR &lt;a href="https://github.com/ggml-org/llama.cpp/pull/30608" rel="noopener noreferrer"&gt;#30608&lt;/a&gt;, &lt;code&gt;thinking: {"type": "disabled"}&lt;/code&gt;. Neither was tested here, and #30608 does not touch &lt;code&gt;tool_choice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For stop strings, the server checks them in &lt;code&gt;process_token&lt;/code&gt; against the slot's whole generated text (&lt;code&gt;find_stopping_strings(slot.generated.text, ...)&lt;/code&gt; in &lt;code&gt;tools/server/server-context.cpp&lt;/code&gt;). The split into reasoning and answer happens later, when the reply is built. So a stop string is found wherever it appears in the generated text, including the reasoning section, and the reply is then built from whatever was generated before it.&lt;/p&gt;

&lt;p&gt;Whether the Anthropic API defines &lt;code&gt;stop_sequences&lt;/code&gt; to apply to thinking is a fair question I haven't settled. But an empty answer is clearly not what a client asking for a stop sequence wants, and a reply with &lt;code&gt;stop_sequence&lt;/code&gt; set should have &lt;code&gt;stop_reason: "stop_sequence"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There's no server setting for either one. What helps on the client side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tool_choice&lt;/strong&gt;: don't rely on forcing a tool through &lt;code&gt;/v1/messages&lt;/code&gt;. If a step needs one specific tool, send only that tool in &lt;code&gt;tools&lt;/code&gt; for that request, with &lt;code&gt;{"type": "any"}&lt;/code&gt;; since &lt;code&gt;any&lt;/code&gt; does work as "call some tool", that should force it, though it wasn't tested here. To prevent tool calls, leave &lt;code&gt;tools&lt;/code&gt; out of the request instead of sending &lt;code&gt;{"type": "none"}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stop_sequences with a reasoning model&lt;/strong&gt;: pick stop strings the model won't write while thinking, such as a closing tag of your own format, or turn reasoning off for requests that depend on a stop string. With llama-server, &lt;code&gt;--reasoning-budget 0&lt;/code&gt; turns it off server-wide. That wasn't tested in this run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect it&lt;/strong&gt;: treat a reply with no text block (or empty &lt;code&gt;content&lt;/code&gt;) plus a set &lt;code&gt;stop_sequence&lt;/code&gt; as this failure, not as an empty answer, and retry without the stop string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-llama-anthropic-controls.sh&lt;/code&gt; sends four small requests to a server and reports each control. Against this server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  IGNORED  tool_choice {type: tool, name: get_time} -&amp;gt; called ['get_weather']
  IGNORED  tool_choice {type: none} -&amp;gt; called ['get_weather']
  MATCHED IN REASONING  stop_sequences ['prime'] ended the reply inside thinking: blocks ['thinking'], stop_reason 'end_turn', stop_sequence 'prime'
  note     stop_sequence is 'prime' but stop_reason is 'end_turn'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;exit 1. With a model that doesn't call tools or doesn't reason (gemma-3-1b here), it says the tests are inconclusive and exits 2 rather than passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;A compatibility layer is a translation, and translations lose things silently. When a server says it speaks another vendor's API, test the parameters you actually depend on, and design the test so the right answer is different from the default: force the tool the model wouldn't pick on its own, and choose a stop string the model will write where you don't want it to stop. A test where the correct behaviour and the broken one give the same output proves nothing.&lt;/p&gt;

&lt;p&gt;The same pattern of a control being accepted and then quietly overridden by the model's reasoning came up with Ollama: &lt;a href="https://homelabpostmortem.com/2026/10/05/ollama-thinking-model-skips-your-json-schema-when-it-answers-directly/" rel="noopener noreferrer"&gt;a thinking model skipped the JSON schema when it answered directly&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The OpenAI-compatible Responses endpoint on the same server has a related gap: &lt;a href="https://homelabpostmortem.com/2026/10/20/llama-server-responses-api-reports-a-cut-off-reply-as-completed/" rel="noopener noreferrer"&gt;a reply cut off by &lt;code&gt;max_output_tokens&lt;/code&gt; is reported as completed&lt;/a&gt;, tool calls with half their arguments included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>api</category>
    </item>
  </channel>
</rss>
