<?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>Podman's Docker-compatible API resets a container's restart policy to 'no' on any update that omits it</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 17 Sep 2026 23:10:58 +0000</pubDate>
      <link>https://dev.to/homelabpm/podmans-docker-compatible-api-resets-a-containers-restart-policy-to-no-on-any-update-that-omits-3k14</link>
      <guid>https://dev.to/homelabpm/podmans-docker-compatible-api-resets-a-containers-restart-policy-to-no-on-any-update-that-omits-3k14</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update 2026-09-21.&lt;/strong&gt; Fixed upstream. &lt;a href="https://github.com/containers/podman/pull/29795" rel="noopener noreferrer"&gt;containers/podman#29795&lt;/a&gt; ("compat: Restart policy no longer resets on compat update") was merged into &lt;code&gt;main&lt;/code&gt; on 2026-09-19, one day after this post and the comment below it; the maintainers closed #29790 the same day. The change is the guard described in "The fix" — &lt;code&gt;restartPolicy&lt;/code&gt; is now only set when &lt;code&gt;options.RestartPolicy.Name != ""&lt;/code&gt; — plus a regression test in &lt;code&gt;test/apiv2/20-containers.at&lt;/code&gt;. It is not in a tagged release yet: v6.1.2 and v5.8.7 (both 2026-09-16) predate it, so every packaged Podman, including Debian 13's 5.4.2, still has the bug, and the client-side workaround still applies. The toolkit probe reports per machine rather than per version for exactly this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On Podman 5.4.2 the Docker-compatible &lt;code&gt;POST /containers/{id}/update&lt;/code&gt; endpoint always writes a restart policy, whether or not the request contained one. A body of &lt;code&gt;{"Memory":67108864}&lt;/code&gt; applies the memory limit and changes &lt;code&gt;unless-stopped&lt;/code&gt; to &lt;code&gt;no&lt;/code&gt;; so does &lt;code&gt;{"CpuShares":512}&lt;/code&gt;; so does &lt;code&gt;{}&lt;/code&gt;. The response is &lt;code&gt;200 {"Warnings":null}&lt;/code&gt; every time. The consequence is exactly what the policy name says: a container that had been restarting itself after crashes stopped doing so, and a container with &lt;code&gt;always&lt;/code&gt; did not come back after a reboot. Docker 26.1.5 given the same three requests keeps the policy — its handler only touches the policy when the body names one. Podman's own &lt;code&gt;podman update&lt;/code&gt; CLI and its native libpod endpoint are fine; the reset lives in the compat handler alone, which passes a pointer to an empty string down to libpod, and libpod accepts the empty string as a valid policy. The reporter's fix — always send &lt;code&gt;RestartPolicy&lt;/code&gt; in the body — works and is the only fix until &lt;a href="https://github.com/containers/podman/issues/29790" rel="noopener noreferrer"&gt;containers/podman#29790&lt;/a&gt; is addressed. The toolkit's &lt;code&gt;check-podman-compat-update-restart.sh&lt;/code&gt; probes the box you are on and lists the running containers that will not come back.&lt;/p&gt;

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

&lt;p&gt;A fresh Debian 13 container on Proxmox, &lt;code&gt;apt install podman&lt;/code&gt; (5.4.2+ds1-2+b2, rootful, cgroup v2, crun), the API socket enabled with &lt;code&gt;systemctl enable --now podman.socket&lt;/code&gt;. The four requests from the upstream report, sent with curl to the Docker-compatible path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## 1 create (RestartPolicy unless-stopped)
201 {"Id":"7e20d62370660c7ed26b6c50e92665cfdf47c1da75eafd36c9ecc44cc2ef8937","Warnings":[]}
## 2 start
204
## 3 inspect
RestartPolicy=unless-stopped Memory=0
## 4 update, memory only:  POST /v1.41/containers/t/update  {"Memory":67108864}
200 {"Warnings":null}
## 5 inspect
RestartPolicy=no Memory=67108864
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The memory limit is applied. The restart policy is gone. Nothing in the response says so, and the compat &lt;code&gt;GET /containers/t/json&lt;/code&gt; agrees with &lt;code&gt;podman inspect&lt;/code&gt;: &lt;code&gt;{"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"Memory":67108864}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is not specific to memory. Three more containers, each created with &lt;code&gt;--restart unless-stopped --memory 128m&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;## C. compat update {"CpuShares":512}
before  RestartPolicy=unless-stopped Memory=134217728 CpuShares=0
after   RestartPolicy=no             Memory=134217728 CpuShares=512
## E. compat update {}
before  RestartPolicy=unless-stopped Memory=134217728 CpuShares=0
after   RestartPolicy=no             Memory=134217728 CpuShares=0
## D. compat update {"Memory":67108864,"RestartPolicy":{"Name":"unless-stopped"}}
before  RestartPolicy=unless-stopped Memory=134217728
after   RestartPolicy=unless-stopped Memory=67108864
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;C is the telling one: the memory limit set at creation survives a CpuShares-only update — resources are merged into the existing set, as you would expect — and the restart policy does not. E says it more bluntly: an update with nothing in it resets the policy. D is the workaround from the report, and it holds.&lt;/p&gt;

&lt;p&gt;Then whether it matters. Two containers whose command exits on its own after four seconds (&lt;code&gt;sh -c 'sleep 4; exit 1'&lt;/code&gt;), both created with &lt;code&gt;unless-stopped&lt;/code&gt;; &lt;code&gt;g&lt;/code&gt; gets a memory-only compat update, &lt;code&gt;f&lt;/code&gt; is left alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f RestartPolicy=unless-stopped State=running RestartCount=0
g RestartPolicy=no             State=running RestartCount=0
-- after 8 s
f RestartPolicy=unless-stopped State=running RestartCount=2
g RestartPolicy=no             State=exited  RestartCount=0
-- after 16 s
f RestartPolicy=unless-stopped State=running RestartCount=3
g RestartPolicy=no             State=exited  RestartCount=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And across a reboot, with &lt;code&gt;podman-restart.service&lt;/code&gt; enabled: &lt;code&gt;p&lt;/code&gt; created with &lt;code&gt;--restart always&lt;/code&gt;, &lt;code&gt;q&lt;/code&gt; created the same way and then given a CpuShares-only compat update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p always
q no
(pct reboot)
up 0 minutes
p Up 20 seconds
q Exited (137) 25 seconds ago
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;Nobody types this request by hand. It comes out of a Docker client library — dockerode, docker-py, the Docker SDK for Go — called by something that manages containers: a dashboard that lets you drag a memory slider, an autoscaler, a plugin that keeps a fleet of service containers within limits. Those libraries build the body from the fields you passed. If you passed a memory limit, the body has a memory limit in it and nothing else, which on Docker is the correct and documented way to change one thing. The upstream report came from exactly that shape of software: a container-management plugin that updated limits and found, later, that none of its containers had a restart policy any more.&lt;/p&gt;

&lt;p&gt;Nothing on the Podman side flags it. The call returns 200. &lt;code&gt;Warnings&lt;/code&gt; is null. The memory change is visible immediately and is what the caller checks. The policy change is visible only if you inspect a field you had no reason to touch, and its effect — the container not coming back — arrives at the next crash or the next reboot, hours or weeks later, with no line in any log connecting it to an API call that happened in between. When the container is found dead, &lt;code&gt;podman inspect&lt;/code&gt; shows &lt;code&gt;RestartPolicy.Name: no&lt;/code&gt;, which looks like it was always that way.&lt;/p&gt;

&lt;p&gt;One more thing that hides the affected containers once you go looking: the stored value is not the string &lt;code&gt;no&lt;/code&gt;. It is the empty string, which Podman's &lt;code&gt;define.RestartPolicyNone&lt;/code&gt; is. &lt;code&gt;podman inspect&lt;/code&gt; renders it as &lt;code&gt;no&lt;/code&gt;, but the &lt;code&gt;ps&lt;/code&gt; filter does not accept that spelling:&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;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; restart-policy&lt;span class="o"&gt;=&lt;/span&gt;no
&lt;span class="o"&gt;(&lt;/span&gt;nothing&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; restart-policy&lt;span class="o"&gt;=&lt;/span&gt;none
t c e g h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pkg/api/handlers/compat/containers.go&lt;/code&gt;, &lt;code&gt;UpdateContainer&lt;/code&gt;, at v5.4.2 and — for these lines — identical on &lt;code&gt;main&lt;/code&gt; today. After decoding the body into a Docker-shaped &lt;code&gt;container.UpdateConfig&lt;/code&gt; and merging the resource fields one by one (&lt;code&gt;if options.CPUShares != 0 { … }&lt;/code&gt;, and so on for every limit), it gets to the policy:&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="n"&gt;localPolicy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;restartPolicy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;localPolicy&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;restartRetries&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;uint&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaximumRetryCount&lt;/span&gt; &lt;span class="o"&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;localRetries&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaximumRetryCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;restartRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;localRetries&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;restartPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;restartRetries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;define&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateHealthCheckConfig&lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every resource field is guarded by "did the caller set it". The policy is not: &lt;code&gt;restartPolicy&lt;/code&gt; is a pointer to whatever &lt;code&gt;Name&lt;/code&gt; was, and when the body had no &lt;code&gt;RestartPolicy&lt;/code&gt;, &lt;code&gt;Name&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. libpod's &lt;code&gt;Update&lt;/code&gt; treats a non-nil pointer as an instruction:&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="n"&gt;restartPolicy&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;define&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidateRestartPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;restartPolicy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;restartPolicy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;ValidateRestartPolicy("")&lt;/code&gt; succeeds, because &lt;code&gt;""&lt;/code&gt; is &lt;code&gt;RestartPolicyNone&lt;/code&gt;, one of the five accepted values. So the empty string is stored as the policy, and from then on the container has none.&lt;/p&gt;

&lt;p&gt;The CLI does not have this problem because &lt;code&gt;cmd/podman/containers/update.go&lt;/code&gt; only fills in the pointer when the flag was given:&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="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"restart"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseRestartPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updateOpts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed on the box: &lt;code&gt;podman update --memory 64m a&lt;/code&gt; changed the memory limit and left &lt;code&gt;unless-stopped&lt;/code&gt; alone. The native libpod endpoint (&lt;code&gt;/v5.4.2/libpod/containers/{id}/update&lt;/code&gt;, which takes an OCI-shaped body — &lt;code&gt;{"memory":{"limit":67108864}}&lt;/code&gt;; send it the Docker shape and you get a 500 decode error, not a silent reset) also left the policy alone.&lt;/p&gt;

&lt;p&gt;Docker, given the identical compat requests from a second throwaway container running &lt;code&gt;docker.io&lt;/code&gt; 26.1.5 from the same Debian release: &lt;code&gt;{}&lt;/code&gt;, &lt;code&gt;{"CpuShares":512}&lt;/code&gt;, &lt;code&gt;{"Memory":67108864,"MemorySwap":134217728}&lt;/code&gt; — all 200, policy &lt;code&gt;unless-stopped&lt;/code&gt; after every one. (Docker rejected a bare &lt;code&gt;{"Memory":67108864}&lt;/code&gt; with a 409 about the memoryswap limit, which is its own quirk and beside the point; the policy was untouched by that too.) That is the comparison the report drew, and it holds: the same request means "change these limits" to one daemon and "change these limits and delete the restart policy" to the other.&lt;/p&gt;

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

&lt;p&gt;Upstream: the handler needs the same guard the resource fields have — set &lt;code&gt;restartPolicy&lt;/code&gt; only when &lt;code&gt;options.RestartPolicy.Name != ""&lt;/code&gt;. That is the reporter's suggestion and there is no PR yet; #29790 is where it will happen.&lt;/p&gt;

&lt;p&gt;Until then, on the client side, &lt;strong&gt;every compat update request must carry the restart policy you want to keep&lt;/strong&gt;, even when you are not changing it:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;67108864&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"RestartPolicy"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unless-stopped"&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;which means reading it back first (&lt;code&gt;GET /containers/{id}/json&lt;/code&gt;, &lt;code&gt;.HostConfig.RestartPolicy&lt;/code&gt;) unless your code already knows it. If you control the client and can call Podman natively, &lt;code&gt;podman update&lt;/code&gt; and the libpod endpoint do not have the bug.&lt;/p&gt;

&lt;p&gt;For containers that have already lost their policy, &lt;code&gt;podman update --restart=always &amp;lt;name&amp;gt;&lt;/code&gt; (or &lt;code&gt;unless-stopped&lt;/code&gt;) puts it back; the CLI path is safe. Finding them is the harder part, since a container that never had a policy and one that lost it look identical. The toolkit's &lt;code&gt;check-podman-compat-update-restart.sh&lt;/code&gt; does two things: lists the running containers whose policy is currently none — the set that will not come back — and probes the Podman you are on, because there is no fixed version to compare against yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;podman             5.4.2  (rootful)
running, policy=no 1 container(s): q
                   (will not restart after a crash or a reboot; if any were meant to,
                    re-apply with: podman update --restart=always &amp;lt;name&amp;gt;)
api socket         /run/podman/podman.sock
probe              create --restart always -&amp;gt; always; POST /v1.41/containers/{id}/update {} -&amp;gt; HTTP 200 {"Warnings":null}; policy now: no

AFFECTED: a Docker-compat update without RestartPolicy resets the policy to 'no' (containers/podman#29790).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe creates a throwaway container with &lt;code&gt;--restart always&lt;/code&gt;, sends &lt;code&gt;{}&lt;/code&gt; to the compat endpoint, inspects, and removes it. If no API socket is active it starts &lt;code&gt;podman system service&lt;/code&gt; on a private socket for thirty seconds and stops it afterwards; nothing is pulled and nothing else on the host is touched. &lt;code&gt;--no-probe&lt;/code&gt; does only the listing. The probe was run against 5.4.2 with the socket active, with the socket stopped, and on a machine with no Podman at all (exit 2); it has not been run against a fixed Podman because none exists.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;"Compatible" is a claim about the request format, not about what the daemon does with the fields you left out.&lt;/strong&gt; Every Docker-shaped client that talks to Podman is relying on the compat layer to have made the same decision Docker made for each absent field, and this is one field where it made a different one. There is no way to see that from the request or the response; the two daemons return the same &lt;code&gt;200 {"Warnings":null}&lt;/code&gt; and diverge in a field nobody asked about. &lt;a href="https://homelabpostmortem.com/2026/08/25/podman-respects-your-firewall/" rel="noopener noreferrer"&gt;The same runtime, on the same box, publishes a port with a different firewall outcome from Docker while printing the identical &lt;code&gt;0.0.0.0:8080-&amp;gt;80/tcp&lt;/code&gt;&lt;/a&gt; — there the difference was in Podman's favour; here it is not. Either way it was found by sending the same input to both and reading everything back, not the field that was changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you test a partial update, inspect the whole object, not the field you changed.&lt;/strong&gt; The report's reproduction is four commands, and the one that finds the bug is an inspect of a field the update had no business touching. Merging semantics — "fields you omit are left alone" — are the kind of thing every API is assumed to have and few document, and the only way to know is to omit a field that matters and see whether it survives.&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>podman</category>
      <category>docker</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>One failed WPA handshake and a headless Raspberry Pi on Trixie stays off WiFi until someone types a command</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:36:22 +0000</pubDate>
      <link>https://dev.to/homelabpm/one-failed-wpa-handshake-and-a-headless-raspberry-pi-on-trixie-stays-off-wifi-until-someone-types-a-3dcg</link>
      <guid>https://dev.to/homelabpm/one-failed-wpa-handshake-and-a-headless-raspberry-pi-on-trixie-stays-off-wifi-until-someone-types-a-3dcg</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Raspberry Pi OS Trixie ships NetworkManager 1.52.1. On that version a WPA-PSK connection gets exactly one 4-way handshake: if it fails, NM asks for a new key — on a profile that has connected successfully many times, and regardless of &lt;code&gt;connection.auth-retries&lt;/code&gt;, which 1.52's own documentation scopes to 802.1X. On a headless Pi there is no secret agent to answer, so the request fails with &lt;code&gt;no-secrets&lt;/code&gt;, and NetworkManager then &lt;strong&gt;blocks the profile from autoconnect&lt;/strong&gt;. That block survives restoring the key and reloading the profile: two minutes later the device was still &lt;code&gt;disconnected&lt;/code&gt;. Only &lt;code&gt;nmcli connection up&lt;/code&gt; (or a reboot) clears it. Upstream extended the retry budget to PSK in NetworkManager 1.58 (&lt;a href="https://github.com/NetworkManager/NetworkManager/commit/746a5902ad85ec0611a3e6ebfd7b68b45621a40b" rel="noopener noreferrer"&gt;746a5902&lt;/a&gt;) and the commit message names the real-world trigger: a device "can leave the range of an access point and therefore fail a 4-way handshake". Trixie does not have it (&lt;a href="https://github.com/raspberrypi/trixie-feedback/issues/102" rel="noopener noreferrer"&gt;raspberrypi/trixie-feedback#102&lt;/a&gt; is the open backport request). A systemd timer that re-activates the profile when the device is disconnected recovered the same failure in 40 seconds; the toolkit's &lt;code&gt;check-wifi-autoconnect-block.sh&lt;/code&gt; reports whether you are exposed and installs it.&lt;/p&gt;

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

&lt;p&gt;Raspberry Pi 4B, Raspberry Pi OS Lite Trixie (arm64), &lt;code&gt;network-manager 1.52.1-1+rpt4&lt;/code&gt;, one WiFi profile that has been the machine's only network for weeks (the ethernet port is empty). The test: set &lt;code&gt;connection.auth-retries 0&lt;/code&gt;, replace the PSK with a wrong one, and reconnect — from a script detached with &lt;code&gt;systemd-run&lt;/code&gt;, because the SSH session is on that WiFi and is about to die. NetworkManager's log at debug level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;01:08:48.5  device (wlan0): supplicant interface state: scanning -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;associating
&lt;span class="gp"&gt;01:08:48.8  device (wlan0): supplicant interface state: associating -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;4way_handshake
&lt;span class="gp"&gt;01:08:52.4  device (wlan0): supplicant interface state: 4way_handshake -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;disconnected
&lt;span class="go"&gt;01:08:52.4  device (wlan0): Activation: (wifi) disconnected during association, asking for new key
&lt;/span&gt;&lt;span class="gp"&gt;01:08:52.4  device (wlan0): state change: config -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;need-auth &lt;span class="o"&gt;(&lt;/span&gt;reason &lt;span class="s1"&gt;'supplicant-disconnect'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;01:08:52.4  device (wlan0): no secrets: No agents were available for this request.
&lt;/span&gt;&lt;span class="gp"&gt;01:08:52.4  device (wlan0): state change: need-auth -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;failed &lt;span class="o"&gt;(&lt;/span&gt;reason &lt;span class="s1"&gt;'no-secrets'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;01:08:52.4  policy: block-autoconnect: connection '&amp;lt;wifi profile&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;' now blocked from autoconnect due to no secrets
&lt;/span&gt;&lt;span class="gp"&gt;01:08:52.4  device (wlan0): Activation: failed for connection '&amp;lt;wifi profile&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One handshake. 3.6 seconds. No retry. &lt;code&gt;auth-retries&lt;/code&gt; had been set to 0 for the test, which is documented as "try indefinitely" — but read the whole comment in 1.52's &lt;code&gt;libnm-core-impl/nm-setting-connection.c&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;The number of retries for the authentication. Zero means to try indefinitely; -1 means
to use a global default. If the global default is not set, the authentication
retries for 3 times before failing the connection.

Currently, this only applies to 802-1x authentication.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a PSK network on this version there is no retry setting at all, and no retry. The upstream report and the research that led here both framed this as "auth-retries is ignored"; the last line of the comment says it more plainly — it was never meant to apply. That does not change what the machine does, which is the rest of this post.&lt;/p&gt;

&lt;p&gt;Then the second half, which is the one that matters on a headless box. The correct key was put back by copying the original &lt;code&gt;/etc/netplan/90-NM-*.yaml&lt;/code&gt; over the modified one and running &lt;code&gt;nmcli connection reload&lt;/code&gt;, with nothing else touched, and the device was polled every five seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;01:15:43  restoring profile files + reload only
          connection.auth-retries: -1        ← original setting is back
01:15:54  t+5s     wlan0:disconnected
01:16:19  t+30s    wlan0:disconnected
01:16:50  t+60s    wlan0:disconnected
01:17:21  t+90s    wlan0:disconnected
01:17:52  t+120s   wlan0:disconnected
01:17:52  explicit `nmcli connection up`
          Connection successfully activated
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The profile had the right key for two minutes and NetworkManager did not try it once. &lt;code&gt;nmcli connection up&lt;/code&gt; connected on the first attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nmcli connection modify … connection.auth-retries 0&lt;/code&gt; returns 0 and the value reads back, and the property's one-line description in &lt;code&gt;nmcli&lt;/code&gt;'s help is "Number of retries for authentication" with no qualifier. Only the full comment in the source, or the 1.58 changelog, says PSK is excluded. Someone who sets it to protect a headless Pi has done nothing, and nothing tells them.&lt;/p&gt;

&lt;p&gt;On a desktop, the whole thing is a password prompt: the handshake fails, NM asks the agent, the user sees "enter password for network X", types the same password, and is back online with a shrug. The bug reads as "Ubuntu keeps asking for my WiFi password" — and that is exactly how it appears in the threads that exist about it, none of which mention &lt;code&gt;auth-retries&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On a headless Pi there is no prompt and no shrug. The &lt;code&gt;no-secrets&lt;/code&gt; failure blocks the profile, &lt;code&gt;nmcli device status&lt;/code&gt; says &lt;code&gt;disconnected&lt;/code&gt;, and nothing on the machine will change that. From outside, the Pi has dropped off the network and stays off; the natural diagnosis is power, the SD card, or the radio. A reboot would clear it — the block is not persisted — which confirms the wrong diagnosis. The upstream commit that fixed the retry path describes the trigger as ordinary — a device that "can leave the range of an access point" — which for a Pi in a cupboard means the access point rebooting, changing channel, or a microwave running.&lt;/p&gt;

&lt;p&gt;The block is the part that turns a nuisance into an outage, and it is invisible: there is no &lt;code&gt;nmcli&lt;/code&gt; field for it. It shows up only as one journal line, &lt;code&gt;block-autoconnect … due to no secrets&lt;/code&gt;, and as the absence of any retry afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;Two mechanisms in sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retry that does not exist.&lt;/strong&gt; In NetworkManager 1.52, a 4-way handshake failure on a WPA-PSK connection lands in the device's auth-failure handler, which requests new secrets directly. The authentication retry budget — &lt;code&gt;nm_device_auth_retries_has_next()&lt;/code&gt;, driven by &lt;code&gt;connection.auth-retries&lt;/code&gt; — is consulted on the 802.1X path only, as the documentation says. The upstream change, +78/−12 in &lt;code&gt;src/core/devices/wifi/nm-device-wifi.c&lt;/code&gt;, makes a previously-connected PSK profile exhaust that budget before asking, and rewrites the documentation to match ("Connections using a pre-shared key to authenticate will only prompt for a new key during the last authentication attempt"). The reasoning in the commit message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;While NetworkManager tries its best to determine whether a new PSK is needed, it can still run into edge cases. One of these edge cases is that a device can leave the range of an access point and therefore fail a 4-way handshake. Because these cases can't be confidently detected, a device which was previously connected should try to exhaust its authentication retries before requesting new secrets.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That landed in 1.58.0 (July 2026). Trixie's 1.52.1 predates it by three stable releases, and the Pi OS package has not backported it. Before it, "one attempt, then prompt" was the designed behaviour for PSK — designed for a laptop with a person in front of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The block that never lifts.&lt;/strong&gt; When NM asks for secrets and no agent answers, the activation fails with reason &lt;code&gt;no-secrets&lt;/code&gt;, and policy marks the connection as blocked from autoconnect for that reason. The intent is sensible — do not retry a connection whose password the user has declined to provide. But on 1.52 the request was never justified, and on a headless machine the "user" is nobody. The block is cleared by an explicit activation of the connection — observed three times here. It was not cleared by rewriting the profile with the correct key and reloading it, which is what the two-minute poll above shows. (A reboot should clear it too, since the block is not persisted; that was not tested, and on a headless box it is not much of a remedy.)&lt;/p&gt;

&lt;p&gt;So the chain, on a headless Pi on Trixie, is: any single handshake failure → new-secrets request (no PSK retry exists on 1.52) → &lt;code&gt;no-secrets&lt;/code&gt; → autoconnect blocked → offline until a human runs &lt;code&gt;nmcli connection up&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Upstream's fix is the right one and it is not available on Trixie today; #102 is the request. Until it lands, the thing that clears the block is the thing to automate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A watchdog timer.&lt;/strong&gt; Every 30 seconds: if the WiFi device is &lt;code&gt;disconnected&lt;/code&gt; and the profile is not active, run &lt;code&gt;nmcli connection up&lt;/code&gt; for it. The same failure was induced a third time with this installed, the correct key restored by file copy and reload as before, and no manual command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;01:20:28  wifi-autoconnect-watchdog: wlan0 disconnected and '&amp;lt;wifi profile&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;' inactive — running nmcli connection up
&lt;/span&gt;&lt;span class="gp"&gt;01:20:34  wifi-autoconnect-watchdog: up failed              ← key still wrong at this point;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;correct
&lt;/span&gt;&lt;span class="go"&gt;01:20:29  restoring profile files + reload only
&lt;/span&gt;&lt;span class="gp"&gt;01:21:03  wifi-autoconnect-watchdog: wlan0 disconnected and '&amp;lt;wifi profile&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;' inactive — running nmcli connection up
&lt;/span&gt;&lt;span class="go"&gt;01:21:15  wifi-autoconnect-watchdog: recovered
01:21:15  t+40s    wlan0:connected
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty seconds, against two minutes and counting without it. The first attempt failing is the watchdog doing the right thing — the key really was wrong then — and it costs nothing but a log line.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-wifi-autoconnect-block.sh&lt;/code&gt; does the reporting and the install:&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;./check-wifi-autoconnect-block.sh
NetworkManager     1.52.1-1+rpt4
WiFi profile       &amp;lt;wifi profile&amp;gt;  &lt;span class="o"&gt;(&lt;/span&gt;device wlan0&lt;span class="o"&gt;)&lt;/span&gt;
auth-retries       &lt;span class="nt"&gt;-1&lt;/span&gt;   &lt;span class="o"&gt;(&lt;/span&gt;802.1X-only before NM 1.58&lt;span class="p"&gt;;&lt;/span&gt; makes no difference to PSK here&lt;span class="o"&gt;)&lt;/span&gt;
fix &lt;span class="k"&gt;in &lt;/span&gt;this NM     no  &lt;span class="o"&gt;(&lt;/span&gt;&amp;lt; 1.58: one handshake failure -&amp;gt; new-secrets request&lt;span class="o"&gt;)&lt;/span&gt;
blocked this boot  2 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="o"&gt;(&lt;/span&gt;journal: &lt;span class="s1"&gt;'blocked from autoconnect due to no secrets'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
watchdog timer     inactive

EXPOSED: NM 1.52.1-1+rpt4 asks &lt;span class="k"&gt;for &lt;/span&gt;a new key after one failed PSK handshake &lt;span class="o"&gt;(&lt;/span&gt;no retry on this version&lt;span class="o"&gt;)&lt;/span&gt;,
         and with no secret agent the profile is &lt;span class="k"&gt;then &lt;/span&gt;blocked from autoconnect &lt;span class="k"&gt;until&lt;/span&gt;
         &lt;span class="s1"&gt;'nmcli connection up'&lt;/span&gt; runs. A headless box stays off WiFi.
         Install the watchdog:  &lt;span class="nb"&gt;sudo&lt;/span&gt; ./check-wifi-autoconnect-block.sh &lt;span class="nt"&gt;--install-watchdog&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--install-watchdog&lt;/code&gt; writes a 12-line script to &lt;code&gt;/usr/local/sbin&lt;/code&gt;, a oneshot service, and a timer, and enables the timer; &lt;code&gt;--remove-watchdog&lt;/code&gt; takes all three away. Report mode is read-only and never touches secrets. &lt;code&gt;blocked this boot&lt;/code&gt; counts the journal line, so it also tells you whether this has already happened to the machine you are looking at.&lt;/p&gt;

&lt;p&gt;Things that do not fix it, for the record: setting &lt;code&gt;auth-retries&lt;/code&gt; to anything (on 1.52 it applies to 802.1X only); &lt;code&gt;nmcli connection reload&lt;/code&gt; (tested, two minutes); &lt;code&gt;connection.autoconnect-retries&lt;/code&gt; (a different budget for a different failure — not tested here, and not the reason the profile is blocked).&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A setting that is accepted and stored is not a setting that is consulted — and the qualifier is usually in the last line of the comment.&lt;/strong&gt; &lt;code&gt;auth-retries&lt;/code&gt; has a validator, a documented range, a documented meaning, a code path that reads it, and one sentence at the bottom saying it applies to a different authentication type. The research that surfaced this candidate, the upstream report, and the first draft of this post all missed that sentence. The only way to know a setting does nothing on your box is to make it matter and watch the log. That is a five-minute test on any machine that can afford to lose its network for two of them, and it is the same test that found &lt;a href="https://homelabpostmortem.com/2026/09/11/cloud-init-never-reads-the-instance-id-you-set/" rel="noopener noreferrer"&gt;&lt;code&gt;instance-id&lt;/code&gt; being ignored by cloud-init&lt;/a&gt; and &lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;&lt;code&gt;response_format&lt;/code&gt; being ignored by llama-server&lt;/a&gt;: change the value to something that must change the outcome, and see whether the outcome changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the recovery, not just the failure.&lt;/strong&gt; The retry bug is the upstream report. The block is what a headless machine actually experiences, and it only became visible by restoring the correct key and &lt;em&gt;not&lt;/em&gt; running the obvious command — waiting to see whether the system would come back on its own. It would not. A reproduction that ends at "yes, it fails" would have shipped a post about a missing retry; the post that matters is about a Pi that stays offline, and the difference was two minutes of watching a &lt;code&gt;disconnected&lt;/code&gt; line not change.&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>raspberrypi</category>
      <category>linux</category>
      <category>networking</category>
      <category>debugging</category>
    </item>
    <item>
      <title>llama.cpp reads past your pos array on embedding batches for M-RoPE models, and the logits change with the heap</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 16 Sep 2026 03:47:05 +0000</pubDate>
      <link>https://dev.to/homelabpm/llamacpp-reads-past-your-pos-array-on-embedding-batches-for-m-rope-models-and-the-logits-change-6c5</link>
      <guid>https://dev.to/homelabpm/llamacpp-reads-past-your-pos-array-on-embedding-batches-for-m-rope-models-and-the-logits-change-6c5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: decode the same &lt;code&gt;llama_batch.embd&lt;/code&gt; batch twice on an M-RoPE model — Qwen3.5, Qwen2.5-VL, Qwen3-VL, anything whose &lt;code&gt;llama_model_rope_type()&lt;/code&gt; is &lt;code&gt;MROPE&lt;/code&gt; or &lt;code&gt;IMROPE&lt;/code&gt; — and the logits differ, on the CPU, one thread, memory cleared, nothing random anywhere. Token ids through the same context are bitwise identical. The cause is a heap over-read: &lt;code&gt;llama.h&lt;/code&gt; says every array in a batch "must have size of n_tokens" and &lt;code&gt;llama_batch_init&lt;/code&gt; allocates &lt;code&gt;pos&lt;/code&gt; that way, but for an embedding batch on an M-RoPE model &lt;code&gt;llama_batch_allocr::ubatch_add&lt;/code&gt; reads &lt;code&gt;pos[j*n_tokens + i]&lt;/code&gt; for &lt;code&gt;j&lt;/code&gt; in 0..3, on the assumption that embeddings are images with per-section positions. Sections 1–3 are whatever sits after your array. valgrind: &lt;code&gt;Invalid read of size 4 … ubatch_add … 0 bytes after a block of size 20 alloc'd … llama_batch_init&lt;/code&gt;. Which batch sizes misbehave, and whether BF16 or F16 weights "matter", is heap layout and nothing else — an extra environment variable changed the answer. Allocate &lt;code&gt;pos&lt;/code&gt; with &lt;code&gt;4 * n_tokens&lt;/code&gt; entries and fill every section and every case is deterministic. The upstream report (&lt;a href="https://github.com/ggml-org/llama.cpp/issues/28963" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#28963&lt;/a&gt;) has the symptom and localises it to the first attention layer; the read that causes it is below, and it is the twin of &lt;a href="https://homelabpostmortem.com/2026/09/15/llama-cpp-batch-reads-past-its-own-pos-buffer-for-mrope/" rel="noopener noreferrer"&gt;the &lt;code&gt;pos = NULL&lt;/code&gt; over-read from the day before&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, 2026-09-16 (same day):&lt;/strong&gt; the reporter of #28963 &lt;a href="https://github.com/ggml-org/llama.cpp/issues/28963#issuecomment-5695180691" rel="noopener noreferrer"&gt;confirmed the diagnosis independently&lt;/a&gt; on a different platform, model and toolchain — Windows 11, MSYS2 g++ 15.2, Qwen3.5-2B in f16 — applying only the caller-side fix below (&lt;code&gt;pos&lt;/code&gt; sized &lt;code&gt;4 * n_tokens&lt;/code&gt;, every section filled). Two decodes of the same 141-position embedding batch went from argmax agreement 126/141 (cosine 0.85–0.99) to 141/141 (cosine 1.000000); the embedding path against the token path went from 132/141 to 141/141. In their real application — a layer-pipeline relay feeding a truncated model through &lt;code&gt;llama_batch.embd&lt;/code&gt; — a 141-step greedy generation had been diverging from the whole-model baseline somewhere between steps 46 and 67, differently on each run; after the fix it matches the baseline exactly, repeatedly. They also hit every one of the false variables described below (weight type, fresh context, graph reuse, an unrelated environment variable) before the cause was known.&lt;/p&gt;

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

&lt;p&gt;llama.cpp master &lt;code&gt;930e2fa&lt;/code&gt; (2026-09-16), CPU build, Debian 13 in an LXC on an i5-8400T, &lt;code&gt;n_threads = n_threads_batch = 1&lt;/code&gt;, flash attention off, &lt;code&gt;n_ctx = n_batch = n_ubatch = 512&lt;/code&gt;. A 90-line C program against &lt;code&gt;libllama&lt;/code&gt;: load a model, build one batch of N positions with &lt;code&gt;llama_batch_init&lt;/code&gt;, fill &lt;code&gt;pos[i] = i&lt;/code&gt; the way the header describes, then six times in a row call &lt;code&gt;llama_memory_clear(mem, true)&lt;/code&gt; and &lt;code&gt;llama_decode&lt;/code&gt; on that same batch, and compare the full N × n_vocab logits block of each run against run 0. In &lt;code&gt;tokens&lt;/code&gt; mode the batch carries token ids; in &lt;code&gt;embd&lt;/code&gt; mode it carries seeded pseudo-random floats through &lt;code&gt;llama_batch.embd&lt;/code&gt;, as in the report.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Qwen3.5-0.8B&lt;/code&gt;, BF16 GGUF from &lt;code&gt;ggml-org&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;mode=tokens N=5
run 1..5: bitwise_equal=yes  max_abs_diff=0  cosine=1.00000000  argmax_same=yes

mode=embd N=5
run 1: bitwise_equal=NO  max_abs_diff=2.4   cosine=0.99422627  argmax_same=yes
run 2: bitwise_equal=NO  max_abs_diff=2.05  cosine=0.99525302  argmax_same=NO
run 3: bitwise_equal=NO  max_abs_diff=2.11  cosine=0.99615401  argmax_same=yes
run 4: bitwise_equal=NO  max_abs_diff=1.85  cosine=0.99524820  argmax_same=NO
run 5: bitwise_equal=NO  max_abs_diff=2.06  cosine=0.99490347  argmax_same=yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report says &lt;code&gt;argmax&lt;/code&gt; stays constant and the practical impact is on reproducibility. Here it does not stay constant: at N=5 the greedy token differs from run 0 in two runs out of five, and at N=8 in every run. At N=8 the shape is also different — runs 1 through 5 are identical to each other and only run 0 stands apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mode=embd N=8
run 1..5: bitwise_equal=NO  max_abs_diff=2.47  cosine=0.99254973  argmax_same=NO   (all five identical)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;Nothing in the API says anything. &lt;code&gt;llama_decode&lt;/code&gt; returns 0, the logits are finite and plausible, and the top token is right most of the time. Greedy generation through this path can match a token-path baseline for a whole sequence and then diverge on the next run where two candidates were close, which reads as "the model is a bit unstable", not "the library read past my array".&lt;/p&gt;

&lt;p&gt;It is invisible to almost everyone. &lt;code&gt;llama-cli&lt;/code&gt;, &lt;code&gt;llama-server&lt;/code&gt;, chat, completion — all tokenise text and go through &lt;code&gt;llama_batch.token&lt;/code&gt;, where positions for text are broadcast across the M-RoPE sections and nothing is over-read. &lt;code&gt;llama_batch.embd&lt;/code&gt; is for people who inject vectors: multimodal projectors, hidden-state relays between model instances, embedding-space experiments. The report came from a layer-pipeline prototype. And the multimodal code in the tree that does use this path — &lt;code&gt;mtmd&lt;/code&gt; — allocates four positions per token itself, so it never sees it.&lt;/p&gt;

&lt;p&gt;Then there is the false trail, which cost most of the afternoon and is worth recording because it looked like a result. The first thing tried was the dense model already on the box, &lt;code&gt;gemma-3-1b&lt;/code&gt; Q4_K_M: deterministic at every N. Then Qwen3.5 in BF16: not. Then the same Qwen file converted to F16 and Q8_0 with &lt;code&gt;llama-quantize&lt;/code&gt;: deterministic. Then gemma converted to BF16: deterministic. A tidy six-cell grid with exactly one failing cell — hybrid architecture, BF16 weights — and a one-command workaround. Meanwhile, which batch sizes failed moved around when the graph was rebuilt instead of reused, moved again with a fresh context per decode, and N=8 failed in every configuration. That grid was going to be the article.&lt;/p&gt;

&lt;p&gt;It was heap layout. Every one of those variables changes what &lt;code&gt;malloc&lt;/code&gt; puts next to a 20-byte allocation. The proof that it was layout and not weights came from setting &lt;code&gt;MALLOC_PERTURB_&lt;/code&gt;, which fills freed and allocated memory with a byte pattern: the results changed, but so did they with &lt;code&gt;X_PERTURB_=1&lt;/code&gt;, a variable glibc has never heard of, because a longer environment shifts the initial heap. A "fix" that works for reasons you cannot state is a layout accident, and the F16 conversion was one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llama_context_params.cb_eval&lt;/code&gt; is a public callback that sees every graph node after it is computed. Hashing every node's output on each run and reporting the first one that differs from run 0, with the hashes of its sources, gives this at N=8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run 1: 948/1441 nodes differ; FIRST differing node = #0
  #0     node_0        op=GET_ROWS  ne=[1024,8,1,1]     srcs: ALL SAME
  #204   Qcur-3        op=ROPE      ne=[256,8,8,1]
          src0 Qcur_normed-3   (in-place, same buffer)
          src1 leaf_55         DIFFERS   data=…9c860..…9c8e0   (128 bytes)
  #211   Kcur-3        op=ROPE      ne=[256,2,8,1]
          src1 leaf_55         DIFFERS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;node_0&lt;/code&gt; is the unselected token-path branch that &lt;code&gt;ggml_build_forward_select&lt;/code&gt; leaves in the graph without computing; nothing reads it, and zero nodes list it as a source. The first divergence that matters is &lt;code&gt;Qcur-3&lt;/code&gt;, the RoPE on layer 3 — the first full-attention layer in this model, which is exactly where the reporter's per-layer comparison put it. Its output differs because its second input differs: &lt;code&gt;leaf_55&lt;/code&gt;, 128 bytes, is &lt;code&gt;int32 × 32&lt;/code&gt; = 8 tokens × 4. That is the M-RoPE position tensor, and it is different on every run with the same batch.&lt;/p&gt;

&lt;p&gt;Positions come from the batch through &lt;code&gt;llama-batch.cpp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;n_pos_per_embd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// if we are using M-RoPE&lt;/span&gt;
    &lt;span class="c1"&gt;//     if the current batch is text, we need to broadcast the same position across all RoPE sections&lt;/span&gt;
    &lt;span class="c1"&gt;//     otherwise, the input batch is image embeddings, we copy the positions as-is&lt;/span&gt;
    &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;src_off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_tokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;udata&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;n_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src_off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;idxs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a token batch, &lt;code&gt;src_off&lt;/code&gt; is 0 and the one position per token is broadcast into all four sections. For an embedding batch, the code assumes "image embeddings" and copies four sections from &lt;code&gt;batch.pos[j*n_tokens + i]&lt;/code&gt; — indices up to &lt;code&gt;4*n_tokens - 1&lt;/code&gt;. And the header, &lt;code&gt;include/llama.h&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The provided arrays (i.e. token, embd, pos, etc.) must have size of n_tokens&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;llama_batch_init&lt;/code&gt; documented, and implemented, to allocate &lt;code&gt;pos&lt;/code&gt; with &lt;code&gt;n_tokens&lt;/code&gt; entries. So a caller who does what the header says — the reporter, this probe, anyone relaying hidden states — hands over N positions, and the library reads 4N. valgrind on the unmodified library says it in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Invalid read of size 4
   at llama_batch_allocr::ubatch_add(...)
   by llama_batch_allocr::split_equal(...)
   by llama_memory_hybrid::init_batch(...)
   by llama_context::decode(...)
 Address 0xb4e4244 is 0 bytes after a block of size 20 alloc'd
   at malloc
   by llama_batch_init
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty bytes is five positions. The next twelve ints are the heap. What lives there depends on everything that was allocated before and after the batch — the model's tensors, the context's buffers, the environment — which is why the symptom tracked the weight type, the batch size, graph reuse, a fresh context and an unrelated environment variable, and why at N=8 run 0 differed from runs 1–5 (the neighbour was written once, early, and then left alone). It is also why the zero-input control in the report is bitwise stable: garbage positions rotate a zero vector to another zero vector.&lt;/p&gt;

&lt;p&gt;Dense models have &lt;code&gt;n_pos_per_embd = 1&lt;/code&gt;; the loop runs once and never leaves the array. The over-read needs an M-RoPE model and an embedding batch, and nothing else — not the weight type, not the architecture beyond the RoPE mode, not the batch size.&lt;/p&gt;

&lt;p&gt;This is the second over-read in the same fifteen lines in two days. &lt;a href="https://homelabpostmortem.com/2026/09/15/llama-cpp-batch-reads-past-its-own-pos-buffer-for-mrope/" rel="noopener noreferrer"&gt;Yesterday's&lt;/a&gt; was &lt;code&gt;pos = NULL&lt;/code&gt;, where the library auto-generates positions and sized its own buffer to &lt;code&gt;n_tokens&lt;/code&gt; before reading &lt;code&gt;4*n_tokens&lt;/code&gt;; that one had a PR, &lt;a href="https://github.com/ggml-org/llama.cpp/pull/28910" rel="noopener noreferrer"&gt;#28910&lt;/a&gt;, still open. This one is &lt;code&gt;pos != NULL&lt;/code&gt;, where the buffer is yours and the library reads past it. Same loop, same assumption — that an embedding batch on an M-RoPE model carries four positions per token — applied to a buffer the header told you to make one-quarter the size.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;In your code, today.&lt;/strong&gt; Allocate four positions per token for any embedding batch on an M-RoPE model, and fill every section — for text-like input, the same position in each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;llama_rope_type&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llama_model_rope_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;n_pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LLAMA_ROPE_TYPE_MROPE&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LLAMA_ROPE_TYPE_IMROPE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;llama_batch&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llama_batch_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_embd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calloc&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;n_pos&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llama_pos&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n_pos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* position of token i */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the single-variable test. With it and nothing else changed — same BF16 file, same build, same thread count — every case that failed above is bitwise deterministic across six runs, including N=7–10 with graph reuse disabled and with a fresh context per decode, the configurations nothing else fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### pos allocated 4*N, every section filled
RESULT embd N=3: DETERMINISTIC
RESULT embd N=5: DETERMINISTIC
RESULT embd N=8: DETERMINISTIC
RESULT embd N=10: DETERMINISTIC
### + LLAMA_GRAPH_REUSE_DISABLE=1 / fresh context
RESULT embd N=7..10: DETERMINISTIC (all)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;llama_batch_free&lt;/code&gt; frees &lt;code&gt;pos&lt;/code&gt; with &lt;code&gt;free()&lt;/code&gt;, so replacing the pointer with your own &lt;code&gt;calloc&lt;/code&gt; is safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upstream.&lt;/strong&gt; The header and the loop disagree, and one of them has to move. Either the header says that embedding batches on M-RoPE models carry &lt;code&gt;n_pos_per_embd * n_tokens&lt;/code&gt; positions and &lt;code&gt;llama_batch_init&lt;/code&gt; grows to match (which needs to know the model, and today it does not), or the batch code stops guessing that every embedding is an image and broadcasts when it is handed one position per token — which is what #28910 does for the &lt;code&gt;pos = NULL&lt;/code&gt; case. The &lt;code&gt;mtmd&lt;/code&gt; code that legitimately passes four sections would need a way to say so. That is a design decision for the maintainers; the comment in the thread has the trace, the valgrind line and the probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you build on this path.&lt;/strong&gt; The toolkit's &lt;code&gt;check-embd-determinism.sh&lt;/code&gt; compiles the probe against your llama.cpp checkout, runs the token path as a control, sweeps the embd path over a list of batch sizes with &lt;code&gt;pos&lt;/code&gt; sized as documented, and for any N that is not deterministic re-runs it with &lt;code&gt;pos&lt;/code&gt; sized &lt;code&gt;n_pos_per_embd * N&lt;/code&gt; to confirm that the over-read is the cause on your build rather than something new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;control  tokens N=5   deterministic   (rope_type=40 n_pos_per_embd=4)
embd     N=2          deterministic
embd     N=5          NON-DETERMINISTIC   worst cosine 0.99631981   argmax changed in 3 of 3 runs
         N=5   with pos sized n_pos_per_embd*N: deterministic  -&amp;gt; over-read confirmed
embd     N=8          NON-DETERMINISTIC   worst cosine 0.99226277   argmax changed in 3 of 3 runs
         N=8   with pos sized n_pos_per_embd*N: deterministic  -&amp;gt; over-read confirmed

FAIL: the embd path gave different logits for the same input at N = 5 8,
      while the token path did not.
      Cause on this build: llama-batch.cpp reads n_pos_per_embd*N positions from an
      embd batch on an M-RoPE model, past the N that llama.h documents and
      llama_batch_init allocates.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against gemma-3-1b, or against Qwen3.5 with the fix in place, it prints &lt;code&gt;OK&lt;/code&gt;. Note that "N=2 deterministic" above is not safety: the twelve bytes past a two-position array happened to be stable on this heap. The over-read is there at every N.&lt;/p&gt;

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

&lt;p&gt;Two, and the second is the one that cost time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a header states a size, find the loop that reads the array and check its bound against the same expression&lt;/strong&gt; — the habit from yesterday, and it would have found this in ten minutes if applied to &lt;code&gt;batch.pos&lt;/code&gt; instead of &lt;code&gt;udata-&amp;gt;pos&lt;/code&gt;. Yesterday's loop and today's are the same loop. The fix for one over-read was a resize of the library's own buffer; the buffer in the other over-read belongs to the caller, so the same loop needed the same look twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A workaround you cannot explain is a measurement of your heap, not of the bug.&lt;/strong&gt; The six-cell weight-type grid was real data: every cell was measured, reproducible, and wrong about what it meant. What gave it away was not a failed reproduction but a &lt;em&gt;successful&lt;/em&gt; one from a variable that could not possibly matter — an environment variable with a made-up name flipped the result, which meant the real variable was memory layout, which meant an uninitialised or out-of-bounds read, which meant stop characterising and start tracing. &lt;code&gt;cb_eval&lt;/code&gt; found the tensor in one run and valgrind found the line in one more. Neither needed a fork, a hidden-state API or a sanitiser build; both were available the whole afternoon. The order that would have saved three hours is: reproduce, then locate, then characterise — and treat any characterisation that depends on things like weight type or batch size with suspicion until the located cause explains why it should.&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>ai</category>
      <category>cpp</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Ollama's gemma4 renderer silently drops tool parameters named type or description, and the model invents a value</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:02:30 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollamas-gemma4-renderer-silently-drops-tool-parameters-named-type-or-description-and-the-model-3921</link>
      <guid>https://dev.to/homelabpm/ollamas-gemma4-renderer-silently-drops-tool-parameters-named-type-or-description-and-the-model-3921</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: define a tool for a gemma4 model in Ollama with a parameter whose &lt;em&gt;name&lt;/em&gt; is &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;properties&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt; or &lt;code&gt;nullable&lt;/code&gt;, and the model is never shown that parameter's definition. It is still listed under &lt;code&gt;required:[…]&lt;/code&gt; in the rendered declaration, so the model knows it must supply something and has no idea what. On &lt;code&gt;gemma4:e2b&lt;/code&gt; at temperature 0 the result is an invented value — &lt;code&gt;"type": "urgent"&lt;/code&gt; for a parameter whose only legal values are &lt;code&gt;urgent_A7&lt;/code&gt; and &lt;code&gt;routine_B3&lt;/code&gt; — and on the reporter's &lt;code&gt;gemma4:26b&lt;/code&gt; it is an omitted argument. HTTP 200, nothing in the log. The cause is a Go port of the reference Jinja template that dropped one argument: the template's &lt;code&gt;format_parameters&lt;/code&gt; macro takes &lt;code&gt;filter_keys=false&lt;/code&gt; and filters schema keywords in exactly one call site; the Go &lt;code&gt;writeSchemaProperties&lt;/code&gt; filters unconditionally and serves all four. Twelve lines restore the argument, and the same request then returns &lt;code&gt;urgent_A7&lt;/code&gt;. Until that lands, rename the parameter.&lt;/p&gt;

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

&lt;p&gt;Ollama 0.33.3, Debian 13, CPU only, &lt;code&gt;gemma4:e2b&lt;/code&gt;. The tool is the one from &lt;a href="https://github.com/ollama/ollama/issues/18468" rel="noopener noreferrer"&gt;ollama/ollama#18468&lt;/a&gt;: a ticket creator with two required string parameters, one of which has an enum. The user message is &lt;code&gt;Open an urgent ticket titled 'Water leak'.&lt;/code&gt; Temperature 0, &lt;code&gt;think: false&lt;/code&gt;, two runs each. The only thing that changes between requests is the name of the second parameter.&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="nl"&gt;"parameters"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;name&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"title"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ticket title"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                 &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;name&amp;gt;"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Priority code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                            &lt;/span&gt;&lt;span class="nl"&gt;"enum"&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;"urgent_A7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"routine_B3"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kind         run1  {"kind":"urgent_A7","title":"Water leak"}
kind         run2  {"kind":"urgent_A7","title":"Water leak"}
type         run1  {"title":"Water leak","type":"urgent"}
type         run2  {"title":"Water leak","type":"urgent"}
description  run1  {"description":"Urgent water leak.","title":"Water leak"}
description  run2  {"description":"Urgent water leak.","title":"Water leak"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;urgent_A7&lt;/code&gt; is a string that appears nowhere in the prompt except inside the parameter's definition. When the parameter is called &lt;code&gt;kind&lt;/code&gt;, the model produces it every time. When the same definition is called &lt;code&gt;type&lt;/code&gt;, the model produces &lt;code&gt;urgent&lt;/code&gt; — the word from the user's sentence — and when it is called &lt;code&gt;description&lt;/code&gt;, a sentence. It is filling a slot it was told exists with whatever the name suggests, because the definition that would have told it otherwise was never in the prompt.&lt;/p&gt;

&lt;p&gt;The reporter, on &lt;code&gt;gemma4:26b&lt;/code&gt;, saw the argument left out entirely instead. A bigger model declines to guess; a smaller one guesses. Both are downstream of the same missing text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;Nothing fails. The request is accepted, the response is 200, the tool call is well-formed JSON with the right function name, and one of its two arguments is correct. If your tool handler validates against the schema, you get a validation error on the &lt;em&gt;model's&lt;/em&gt; output and start debugging the model: prompt wording, temperature, whether gemma4 is any good at function calling. The reporter's numbers were 1 ticket created out of 14 attempts with 35 schema-validation failures, all on a required parameter called &lt;code&gt;description&lt;/code&gt;, and it took renaming the parameter to find out the model had never seen it.&lt;/p&gt;

&lt;p&gt;If your handler does not validate, you get &lt;code&gt;"type": "urgent"&lt;/code&gt; stored somewhere as a priority code, and you find out later.&lt;/p&gt;

&lt;p&gt;The names involved are not exotic. &lt;code&gt;type&lt;/code&gt; is the most natural name for a categorical field. &lt;code&gt;description&lt;/code&gt; is what you call the free-text field on a ticket, an event, a product. JSON Schema has no rule against property names that happen to be schema keywords — they live in a different namespace, inside &lt;code&gt;properties&lt;/code&gt;, and every validator handles them correctly. This renderer does not.&lt;/p&gt;

&lt;p&gt;Ollama has no debug setting that prints the rendered prompt, so the declaration the model actually receives is not observable from outside the process. The only ways to see it are to read the renderer or to run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;Ollama renders gemma4 prompts in Go, in &lt;code&gt;model/renderers/gemma4.go&lt;/code&gt;, and checks that renderer against the model's reference Jinja template in &lt;code&gt;model/renderers/testdata/&lt;/code&gt;. The template's parameter macro looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;macro&lt;/span&gt; &lt;span class="nv"&gt;format_parameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;filter_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;standard_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'properties'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'nullable'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;| &lt;/span&gt;&lt;span class="nf"&gt;dictsort&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nv"&gt;filter_keys&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;standard_keys&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;
            &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;:{
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filter_keys&lt;/code&gt; defaults to false, and the macro is called four times. Three of them pass nothing — top-level parameters, nested object properties, array items — because in those calls the keys are parameter names. One call passes &lt;code&gt;filter_keys=true&lt;/code&gt;: the branch where an object has no &lt;code&gt;properties&lt;/code&gt; map and the macro walks the object's own keys, where &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; really are schema keywords and must not be rendered as if they were parameters.&lt;/p&gt;

&lt;p&gt;The Go port has the same four call sites and the same skip list. It does not have the argument:&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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gemma4Renderer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;writeSchemaProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;isSchemaStandardKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the one-branch filter became an every-branch filter. A same-package test that calls &lt;code&gt;Render&lt;/code&gt; with the ticket tool and prints the declaration shows the consequence directly, on 0.33.3 (gemma4's &lt;code&gt;&amp;lt;|"|&amp;gt;&lt;/code&gt; quote token shown as &lt;code&gt;"&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;kind        properties:{kind:{description:"Priority code",enum:["urgent_A7","routine_B3"],type:"STRING"},title:{…}}  required:["title","kind"]
type        properties:{title:{…}}                                                                                   required:["title","type"]
description properties:{title:{…}}                                                                                   required:["title","description"]
properties  properties:{title:{…}}                                                                                   required:["title","properties"]
required    properties:{title:{…}}                                                                                   required:["title","required"]
nullable    properties:{title:{…}}                                                                                   required:["title","nullable"]
typo        properties:{title:{…},typo:{description:"Priority code",enum:[…],type:"STRING"}}                          required:["title","typo"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;required&lt;/code&gt; list is written by a different function that does not filter, which is why the name survives there. The model is told "you must supply &lt;code&gt;type&lt;/code&gt;" by one line and shown no &lt;code&gt;type&lt;/code&gt; by the other. &lt;code&gt;typo&lt;/code&gt; is in the table as a control: it is not on the list, so it renders. The problem is the five words, not anything about the schema.&lt;/p&gt;

&lt;p&gt;The existing reference tests in the package all pass with this behaviour and all still pass after the fix below, which means none of them ever used a parameter named after a keyword. That is why the port shipped.&lt;/p&gt;

&lt;p&gt;Upstream &lt;code&gt;main&lt;/code&gt; at &lt;code&gt;a43fad18&lt;/code&gt; (2026-09-15) has the same code.&lt;/p&gt;

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

&lt;p&gt;Two, in order of when you can have them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today: rename the parameter.&lt;/strong&gt; This is the whole workaround and it is enough — the runs above with &lt;code&gt;kind&lt;/code&gt; are the fixed state. If the name is part of an external contract you cannot change, map it in your tool handler: declare &lt;code&gt;kind&lt;/code&gt; to the model, translate to &lt;code&gt;type&lt;/code&gt; before you call the real function.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-tool-param-names.sh&lt;/code&gt; finds every colliding name in a tool definition file, including nested objects and array items, and prints the JSON path of each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./check-tool-param-names.sh tools.json
&lt;span class="go"&gt;tools.json: 3 colliding parameter name(s)
  create_ticket.parameters.properties.type
  create_ticket.parameters.properties.meta.properties.description
  create_ticket.parameters.properties.meta.properties.tags.items.properties.required
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also has a live mode, &lt;code&gt;--probe http://host:11434 --model gemma4:e2b&lt;/code&gt;, which sends the ticket request twice — &lt;code&gt;kind&lt;/code&gt; as the control, then &lt;code&gt;type&lt;/code&gt; — and reports FAIL when the &lt;code&gt;type&lt;/code&gt; call does not carry the enum value. The control is there because a model that cannot make tool calls at all would otherwise look like a renderer bug; that case is reported as UNKNOWN, not as a pass or a fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upstream: put the argument back.&lt;/strong&gt; The change that mirrors the template is a &lt;code&gt;filterKeys bool&lt;/code&gt; on &lt;code&gt;writeSchemaProperties&lt;/code&gt;, passed &lt;code&gt;true&lt;/code&gt; at the one call site where the object itself is being walked and &lt;code&gt;false&lt;/code&gt; at the other three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-func (r *Gemma4Renderer) writeSchemaProperties(sb *strings.Builder, props map[string]any) {
&lt;/span&gt;&lt;span class="gi"&gt;+func (r *Gemma4Renderer) writeSchemaProperties(sb *strings.Builder, props map[string]any, filterKeys bool) {
&lt;/span&gt;    …
&lt;span class="gd"&gt;-       if isSchemaStandardKey(name) {
&lt;/span&gt;&lt;span class="gi"&gt;+       if filterKeys &amp;amp;&amp;amp; isSchemaStandardKey(name) {
&lt;/span&gt;            continue
        }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve changed lines in total. With that applied, the same-package test renders all seven names, the package's existing tests still pass, and a Go binary rebuilt from the &lt;code&gt;v0.33.3&lt;/code&gt; tag with the patch — dropped in over the stock one, same runner libraries — answers the same requests differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type         run1  {"title":"Water leak","type":"urgent_A7"}
type         run2  {"title":"Water leak","type":"urgent_A7"}
description  run1  {"description":"urgent_A7","title":"Water leak"}
description  run2  {"description":"urgent_A7","title":"Water leak"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a one-variable change producing the enum value where the unpatched binary produced a guess, so the mechanism is not in doubt. The probe script reports OK against the patched server and FAIL against the stock one.&lt;/p&gt;

&lt;p&gt;One thing this session did not test: nested object parameters and array items end to end with a model. The code reads the same way for those paths — they call the same function with the same unconditional filter, and the static test confirms the top-level case only — but the E2E runs here were top-level parameters.&lt;/p&gt;

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

&lt;p&gt;When a template is ported from one language to another, the arguments with default values are the ones that go missing, because at every call site that uses the default the port looks complete. &lt;code&gt;format_parameters(properties, required, filter_keys=false)&lt;/code&gt; has three call sites that never mention &lt;code&gt;filter_keys&lt;/code&gt; and one that does. A port that reads the three and generalises gets the filter wrong; a port that reads the one and generalises gets it wrong the other way; only a port that carries the parameter gets all four right. The test suite did not catch it because the test cases were written from the same three call sites.&lt;/p&gt;

&lt;p&gt;This is the third post here where a local-model server accepted a request, returned 200, and quietly did something other than what the request said — &lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;llama-server's README &lt;code&gt;response_format&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://homelabpostmortem.com/2026/09/14/ollama-responses-api-drops-previous-response-id/" rel="noopener noreferrer"&gt;Ollama's &lt;code&gt;previous_response_id&lt;/code&gt;&lt;/a&gt; are the other two. The common thread is that the thing being dropped is a &lt;em&gt;name&lt;/em&gt;: a JSON key the server does not read, a request field the struct does not have, a parameter the renderer decides is not a parameter. Names are the cheapest thing to get wrong and the last thing a 200 will tell you about. When a model is "bad at" something that depends on it having seen a specific piece of text, the first question is whether it saw the text — and for a renderer with no debug output, the only way to answer that is to render it yourself.&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>debugging</category>
    </item>
    <item>
      <title>llama.cpp reads past its own pos buffer for M-RoPE embeddings, and nothing reports it</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 15 Sep 2026 15:06:03 +0000</pubDate>
      <link>https://dev.to/homelabpm/llamacpp-reads-past-its-own-pos-buffer-for-m-rope-embeddings-and-nothing-reports-it-4bmo</link>
      <guid>https://dev.to/homelabpm/llamacpp-reads-past-its-own-pos-buffer-for-m-rope-embeddings-and-nothing-reports-it-4bmo</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;include/llama.h&lt;/code&gt; says the &lt;code&gt;pos&lt;/code&gt; array "must have size of n_tokens", and that if you pass &lt;code&gt;NULL&lt;/code&gt; "the token position will be tracked automatically". For a model that uses multiple positions per embedding — M-RoPE, which is Qwen2.5-VL and Qwen2.5-Omni — the automatic path sizes its own vector to &lt;code&gt;n_tokens&lt;/code&gt; and the batch splitter then reads &lt;code&gt;4 × n_tokens&lt;/code&gt; from it. On master as of today, AddressSanitizer reports a &lt;code&gt;heap-buffer-overflow&lt;/code&gt;, &lt;code&gt;READ of size 4&lt;/code&gt;, &lt;code&gt;0 bytes after 8-byte region&lt;/code&gt;, at &lt;code&gt;llama-batch.cpp:787&lt;/code&gt;, on a vector allocated at &lt;code&gt;llama-batch.cpp:91&lt;/code&gt;. In a normal build nothing fires; the bytes that follow the allocation become positions and the model runs on them. An open pull request fixes the &lt;code&gt;NULL&lt;/code&gt; path and I have verified it does; the caller-allocated path is fixed only by changing what the header asks for.&lt;/p&gt;

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

&lt;p&gt;This one did not present as a symptom to me. It presented as a code-read on the llama.cpp tracker, &lt;a href="https://github.com/ggml-org/llama.cpp/issues/28902" rel="noopener noreferrer"&gt;&lt;code&gt;#28902&lt;/code&gt;&lt;/a&gt;, where two people had converged on the same diagnosis from the source and a third, running Qwen2.5-Omni under Metal, had the field data: a 750-token audio prefill that "allocated 3,000 bytes and read 12,000", output that was intermittently incoherent, and a failure rate that moved with what else was running on the machine — the signature of a read past the end of an allocation, where the extra bytes depend on what happens to be next to it.&lt;/p&gt;

&lt;p&gt;What the thread did not have was a sanitiser trace. "The ASan build would help," one of them wrote. "A heap-buffer-overflow trace turns two code reads into a reproducible report." So that is what this is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;Two lines from &lt;code&gt;include/llama.h&lt;/code&gt;, as shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The provided arrays (i.e. token, embd, pos, etc.) must have size of n_tokens          (line 248)&lt;/span&gt;
&lt;span class="c1"&gt;// - pos : the positions of the respective token in the sequence&lt;/span&gt;
&lt;span class="c1"&gt;//         (if set to NULL, the token position will be tracked automatically ...)         (line 253)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The automatic path, &lt;code&gt;src/llama-batch.cpp&lt;/code&gt;, line 91:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the batch splitter, lines 780–787, which for an embeddings batch (&lt;code&gt;batch.token == NULL&lt;/code&gt;) reads one position per RoPE section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;n_pos_per_embd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// if we are using M-RoPE&lt;/span&gt;
    &lt;span class="c1"&gt;//     if the current batch is text, we need to broadcast the same position across all RoPE sections&lt;/span&gt;
    &lt;span class="c1"&gt;//     otherwise, the input batch is image embeddings, we copy the positions as-is&lt;/span&gt;
    &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;src_off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_tokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;udata&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;n_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src_off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;idxs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n_pos_per_embd&lt;/code&gt; is 4 for M-RoPE. The loop reads &lt;code&gt;batch.pos[0 … 4·n_tokens − 1]&lt;/code&gt;. The vector it reads from has &lt;code&gt;n_tokens&lt;/code&gt; entries. Both halves of the header are wrong for this model family: a caller who allocates &lt;code&gt;n_tokens&lt;/code&gt; gets overread, and a caller who passes &lt;code&gt;NULL&lt;/code&gt; gets overread by the library's own fallback. The comment on the loop describes the intent correctly; the intent was never matched by an allocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trace
&lt;/h2&gt;

&lt;p&gt;The fix PR, &lt;a href="https://github.com/ggml-org/llama.cpp/pull/28910" rel="noopener noreferrer"&gt;&lt;code&gt;#28910&lt;/code&gt;&lt;/a&gt;, adds a 24-line test to the existing &lt;code&gt;tests/test-batch-alloc.cpp&lt;/code&gt;: a two-token embeddings batch with &lt;code&gt;pos = NULL&lt;/code&gt; against a mock vocab with &lt;code&gt;n_pos_per_embd = 4&lt;/code&gt;. That test does not need a model, a GPU, or a Linux box, so I ran it on a Mac mini — master &lt;code&gt;4c9233c&lt;/code&gt;, Apple clang 21, Metal off, &lt;code&gt;-DLLAMA_SANITIZE_ADDRESS=ON&lt;/code&gt; — first with only the test applied and the fix left out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;==8100==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000007c18
READ of size 4 at 0x602000007c18 thread T0
    #0 llama_batch_allocr::ubatch_add(...)      llama-batch.cpp:787
    #1 llama_batch_allocr::split_simple(...)    llama-batch.cpp:507
    #2 test_mrope(...) auto_pos_broadcast_for_embd   test-batch-alloc.cpp:640

0x602000007c18 is located 0 bytes after 8-byte region [0x602000007c10,0x602000007c18)
allocated by thread T0 here:
    ...
    #8 llama_batch_allocr::init(...)             llama-batch.cpp:91

SUMMARY: AddressSanitizer: heap-buffer-overflow llama-batch.cpp:787
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight bytes is two &lt;code&gt;int32&lt;/code&gt; positions — &lt;code&gt;n_tokens = 2&lt;/code&gt;. The first read past the end is &lt;code&gt;pos[2]&lt;/code&gt;, the start of the second M-RoPE section. That is the diagnosis from the thread, with an address on it.&lt;/p&gt;

&lt;p&gt;Then with the PR's &lt;code&gt;src/&lt;/code&gt; and &lt;code&gt;include/&lt;/code&gt; changes applied, same build flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  auto_pos_broadcast_for_embd (11 assertion(s))                                 [PASS]
failures   : 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix resizes the fallback vector to &lt;code&gt;n_tokens × n_pos_per_embd&lt;/code&gt; and broadcasts each auto-generated position across the sections. Eleven assertions, no sanitiser output, exit 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;READ&lt;/code&gt; past the end of a heap allocation does not crash. It reads whatever is there. In a release build the next eight, sixteen, or several thousand bytes of the heap become RoPE positions, the model computes attention against them, and the output is a little wrong, or a lot wrong, or fine, depending on what the allocator happened to place there. That is exactly the field report: intermittent, load-dependent, silent. A bug that presents as "the model is flaky on long audio" is not one anybody goes looking for in the batch allocator.&lt;/p&gt;

&lt;p&gt;It also survives the obvious defence. Someone who reads the header and allocates &lt;code&gt;pos&lt;/code&gt; to exactly &lt;code&gt;n_tokens&lt;/code&gt; has done the documented thing. Someone who reads the header and passes &lt;code&gt;NULL&lt;/code&gt; has done the other documented thing. Neither one is a bug in their code, and neither one can be fixed in their code without knowing the layout the library actually wants — four section-major planes of &lt;code&gt;n_tokens&lt;/code&gt; each, which the header did not say until this PR.&lt;/p&gt;

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

&lt;p&gt;Upstream, when &lt;code&gt;#28910&lt;/code&gt; merges: the &lt;code&gt;NULL&lt;/code&gt; path is repaired, and &lt;code&gt;llama.h&lt;/code&gt; gains a note that embeddings batches on &lt;code&gt;n_pos_per_embd &amp;gt; 1&lt;/code&gt; models must supply &lt;code&gt;n_tokens × n_pos_per_embd&lt;/code&gt; positions in consecutive sections. Note what that second half is: a documentation change. &lt;code&gt;llama_batch_init()&lt;/code&gt; still allocates &lt;code&gt;pos&lt;/code&gt; at &lt;code&gt;n_tokens&lt;/code&gt;; the caller-side overread is closed by telling callers to allocate more, not by the library allocating more. The thread calls this an API question for the maintainers, and it is.&lt;/p&gt;

&lt;p&gt;Until then, if you feed embeddings to an M-RoPE model through the batch API directly — not through &lt;code&gt;mtmd&lt;/code&gt;, which already lays positions out correctly — allocate &lt;code&gt;pos&lt;/code&gt; yourself at &lt;code&gt;n_tokens × 4&lt;/code&gt; and fill the four planes, as &lt;code&gt;tools/mtmd/mtmd-helper-common.h&lt;/code&gt; does. That is what the reporter of the field data did, and it is what turned four intermittently broken runs into four coherent ones.&lt;/p&gt;

&lt;p&gt;If you want to know whether your build has it, the test is the check. Apply the PR's &lt;code&gt;tests/&lt;/code&gt; hunk to a checkout, build &lt;code&gt;test-batch-alloc&lt;/code&gt; with &lt;code&gt;-DLLAMA_SANITIZE_ADDRESS=ON&lt;/code&gt;, and run it. Under a minute after the build, no model required.&lt;/p&gt;

&lt;p&gt;There is no toolkit script for this one. The detection is "build with a sanitiser and run the test", and that is not something a script would improve on.&lt;/p&gt;

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

&lt;p&gt;The narrow one: &lt;strong&gt;when a header documents a size, find the loop that reads the array and check its bound against the same expression.&lt;/strong&gt; Here they were &lt;code&gt;n_tokens&lt;/code&gt; and &lt;code&gt;n_pos_per_embd × n_tokens&lt;/code&gt;, in files a few hundred lines apart, and both were individually reasonable.&lt;/p&gt;

&lt;p&gt;The wider one is about what a code-read is worth. Two competent people agreed on this bug from reading the source, and they were right — but agreement is not a reproduction, and the thread knew it. A sanitiser trace took twenty minutes and settled it with an address and a line number. When a finding is "we read the code and it looks like it overreads", the next step is not a third reader. It is &lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;the same move as removing a constraint to see whether the bytes change&lt;/a&gt;: arrange for the thing to be observed rather than inferred, then observe it. The same fifteen lines had a second over-read waiting: &lt;a href="https://homelabpostmortem.com/2026/09/16/llama-cpp-reads-past-your-pos-array-for-embedding-batches-on-mrope-models/" rel="noopener noreferrer"&gt;when the caller supplies &lt;code&gt;pos&lt;/code&gt;, the library reads four times what the header told the caller to allocate&lt;/a&gt;, and it took a heap-layout wild-goose chase and a valgrind line to find it.&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>ai</category>
      <category>cpp</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Ollama's Responses API accepts previous_response_id, returns 200, and forgets the whole conversation</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 14 Sep 2026 02:10:04 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollamas-responses-api-accepts-previousresponseid-returns-200-and-forgets-the-whole-conversation-1kp8</link>
      <guid>https://dev.to/homelabpm/ollamas-responses-api-accepts-previousresponseid-returns-200-and-forgets-the-whole-conversation-1kp8</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: send &lt;code&gt;previous_response_id&lt;/code&gt; to Ollama's &lt;code&gt;/v1/responses&lt;/code&gt; and it is accepted, the reply is HTTP 200 with &lt;code&gt;"status": "completed"&lt;/code&gt; and &lt;code&gt;"error": null&lt;/code&gt;, and the model has never seen the previous turn. A request that carries &lt;code&gt;previous_response_id&lt;/code&gt; costs the same number of input tokens as a request that carries no history — 41 and 41 on this box — and produces the same kind of answer. Sending the full history instead costs 68 and works. The reason is in the source: the request struct has no &lt;code&gt;previous_response_id&lt;/code&gt; field, so the JSON decoder throws it away, and the response struct sets it to &lt;code&gt;nil&lt;/code&gt; with the comment &lt;code&gt;// Not supported&lt;/code&gt;. The bug report that led here, an empty completion from a hosted model after a tool call, is what a bare &lt;code&gt;function_call_output&lt;/code&gt; with no context looks like when the backend has nothing to say about it.&lt;/p&gt;

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

&lt;p&gt;Ollama 0.34.0, Debian 13, CPU only, &lt;code&gt;qwen2.5:1.5b&lt;/code&gt;. The Responses API, which is the OpenAI-compatible endpoint Codex and similar clients use. Turn 1 plants something the model could not guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/responses
{"model": "qwen2.5:1.5b",
 "input": "My secret word is PINEAPPLE. Remember it. Reply with just OK."}

→ 200  status: completed   input_tokens: 45   "OK"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn 2 continues the conversation the way the API is documented to work, by referencing the previous response:&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="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;"qwen2.5:1.5b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"previous_response_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resp_578667"&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;"What is my secret word? Reply with just the word."&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="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;status:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;completed&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;error:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;input_tokens:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"password"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;password&lt;/code&gt;. Now two controls. The same question with the full history sent in the request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;word&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PINEAPPLE…"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;assistant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OK"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;my&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;word?…"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;→ 200   input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;68   "PINEAPPLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the same question with no history at all, just the bare turn-2 text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input: "What is my secret word? Reply with just the word."

→ 200   input_tokens: 41   "password"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;previous_response_id&lt;/code&gt; request and the no-history request cost the same 41 input tokens and produce the same guess. The model was shown the same thing both times: nothing but the question.&lt;/p&gt;

&lt;p&gt;The same holds through &lt;code&gt;/api/codex/v1/responses&lt;/code&gt;, the proxy path that &lt;code&gt;ollama launch codex&lt;/code&gt; sets up for Codex Desktop. 41 tokens, a guess (&lt;code&gt;Qwen&lt;/code&gt;, this time), &lt;code&gt;completed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;Nothing fails. The response is not an error, not &lt;code&gt;incomplete&lt;/code&gt;, not empty. It is a well-formed completed response with a plausible answer in it. A conversation client that sends &lt;code&gt;previous_response_id&lt;/code&gt; gets a reply to every turn; the replies are just to a different conversation than the one it thinks it is having — one that starts fresh every time.&lt;/p&gt;

&lt;p&gt;With a tool-calling loop the shape is worse and more specific. Turn 1: the model emits a &lt;code&gt;function_call&lt;/code&gt;. The client runs the tool and sends back only what the protocol says to send back — &lt;code&gt;previous_response_id&lt;/code&gt; plus a &lt;code&gt;function_call_output&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;turn 1   "Call the check tool once, then reply exactly DONE."   → function_call    input_tokens 144
turn 2   previous_response_id + function_call_output "test passed"
         → 200  completed  "The test passed successfully! Is there anything
           else you need help with?"                              input_tokens 144
control  full history, no previous_response_id
         → 200  completed  "DONE"                                 input_tokens 179
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The turn-2 reply is friendly, on-topic, and wrong: the instruction was to say &lt;code&gt;DONE&lt;/code&gt;, and the model never saw it. It saw a tool result arrive from nowhere and did its best. That is the local-model version. The &lt;a href="https://github.com/ollama/ollama/issues/18419" rel="noopener noreferrer"&gt;upstream report&lt;/a&gt; that started this used a hosted &lt;code&gt;:cloud&lt;/code&gt; model through the same Codex proxy and got, for the same bare &lt;code&gt;function_call_output&lt;/code&gt;, an empty &lt;code&gt;output_text&lt;/code&gt; with &lt;code&gt;input_tokens: 0&lt;/code&gt; — which Codex Desktop then read as a cleanly finished turn. &lt;strong&gt;That exact symptom did not reproduce here with a local model&lt;/strong&gt;, and I did not test a hosted one. What did reproduce is the thing underneath it: the context is gone, and what the backend does with a context-free tool result is up to the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;openai/responses.go&lt;/code&gt;, Ollama 0.34.0. The request type:&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;type&lt;/span&gt; &lt;span class="n"&gt;ResponsesRequest&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Input&lt;/span&gt;        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;
    &lt;span class="n"&gt;Instructions&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Tools&lt;/span&gt;        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ResponsesTool&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="c"&gt;// no PreviousResponseID field&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go's &lt;code&gt;encoding/json&lt;/code&gt; ignores keys that have no matching field unless &lt;code&gt;DisallowUnknownFields&lt;/code&gt; is set. It is not set. &lt;code&gt;previous_response_id&lt;/code&gt; is discarded at decode, before any handler sees it.&lt;/p&gt;

&lt;p&gt;The response type, on the other hand, does know the field exists:&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="n"&gt;PreviousResponseID&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"previous_response_id"`&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;PreviousResponseID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// Not supported&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So every response carries &lt;code&gt;"previous_response_id": null&lt;/code&gt;. That is the one honest signal in the whole exchange, and it is a field no client reads back — OpenAI's implementation echoes the id you sent, so there is nothing to check. The feature request to implement it, &lt;a href="https://github.com/ollama/ollama/issues/15954" rel="noopener noreferrer"&gt;&lt;code&gt;ollama/ollama#15954&lt;/code&gt;&lt;/a&gt;, has been open since May.&lt;/p&gt;

&lt;p&gt;A note on the Codex path, because it is easy to get wrong. &lt;code&gt;/api/codex/v1/responses&lt;/code&gt; is not a separate implementation. It is a router: a request whose model appears in &lt;code&gt;~/.codex/ollama-launch-codex-routing.json&lt;/code&gt; is forwarded to Ollama's own &lt;code&gt;/v1/responses&lt;/code&gt;; any other model is forwarded to OpenAI. On a fresh install with no routing file the endpoint answers &lt;code&gt;503 read Codex Ollama model catalog … no such file&lt;/code&gt;, which is not the bug, it is just &lt;code&gt;ollama launch codex&lt;/code&gt; not having run. Write a one-line catalog by hand and the proxy routes a local model exactly as it would a hosted one — and drops &lt;code&gt;previous_response_id&lt;/code&gt; exactly the same way, because the drop happens downstream.&lt;/p&gt;

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

&lt;p&gt;Nothing in Ollama restores the state, so the client has to stop expecting it to. Send the full conversation on every turn:&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="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;"qwen2.5:1.5b"&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="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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…turn 1…"&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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…turn 1 reply…"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function_call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"call_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call_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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{}"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function_call_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"call_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call_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;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test passed"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the 68-token / 179-token control above, and it works on both paths. Most OpenAI-compatible clients already do this; the ones that do not are the ones built against the Responses API's stateful mode — Codex being the prominent case, which is why the upstream report came from Codex Desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check before you trust a Responses server with state.&lt;/strong&gt; It takes four requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:11434/v1/responses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen2.5:1.5b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BCDFGHJKLMNPQRSTVWXZ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My secret word is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Reply with just OK.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is my secret word? Reply with just the word.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;r1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;previous_response_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;r4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;| via id:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;| no history:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r4&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r4&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LNLPFVF | via id: 41 'Unlimited possibilities' | no history: 41 'Password'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the id-referencing request costs the same as the bare one and does not know the word, the server is not keeping state, whatever the status field says. The toolkit's &lt;code&gt;check-responses-state.sh&lt;/code&gt; runs this with a full-history control as well, so a model too small to remember the word is reported as unknown rather than as a pass or a fail.&lt;/p&gt;

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

&lt;p&gt;The narrow one: &lt;strong&gt;a status of &lt;code&gt;completed&lt;/code&gt; describes the request the server processed, which may not be the request you sent.&lt;/strong&gt; Here the server processed "what is my secret word?" and completed it perfectly. The &lt;code&gt;previous_response_id&lt;/code&gt; was not rejected, not warned about, not &lt;code&gt;incomplete_details&lt;/code&gt;-ed. It was not there by the time anything could have complained. The same server does it one layer down, too: its &lt;a href="https://homelabpostmortem.com/2026/09/16/ollama-gemma4-drops-tool-parameters-named-type/" rel="noopener noreferrer"&gt;gemma4 renderer drops any tool parameter named &lt;code&gt;type&lt;/code&gt; or &lt;code&gt;description&lt;/code&gt; from what the model sees&lt;/a&gt; while still listing it as required, and the model fills the gap with a guess — same 200, same silence, a name dropped in the prompt instead of in the request.&lt;/p&gt;

&lt;p&gt;The wider one is about controls that cost nothing. The token count did all the work in this diagnosis. Two requests that should have carried different amounts of context cost the same, and one that should have carried the same as the first cost more. That number is in every response, it is exact, and it does not depend on interpreting model output. When a stateful API might be stateless, count the tokens before reading the words. It is the same move as &lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;holding the prompt fixed and removing the constraint to see whether the bytes change&lt;/a&gt;: find the one number that has to move if the feature is real, and watch whether it moves.&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>devops</category>
    </item>
    <item>
      <title>docker logs stops at a NUL byte, exits 0, and --since says nothing happened</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 12 Sep 2026 00:46:48 +0000</pubDate>
      <link>https://dev.to/homelabpm/docker-logs-stops-at-a-nul-byte-exits-0-and-since-says-nothing-happened-5b66</link>
      <guid>https://dev.to/homelabpm/docker-logs-stops-at-a-nul-byte-exits-0-and-since-says-nothing-happened-5b66</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: with the default &lt;code&gt;json-file&lt;/code&gt; log driver, a run of NUL bytes anywhere in the log file makes &lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt; stop at that point and report success. On Docker 29.8.0 with one of 50 records zeroed, the full read returned 17 lines, exit 0, stderr empty. &lt;code&gt;--tail N&lt;/code&gt; works only when its window begins after the corruption. &lt;code&gt;--since &amp;lt;time&amp;gt;&lt;/code&gt; reads forward from the start, hits the NUL, and returns &lt;strong&gt;zero lines&lt;/strong&gt; for any timestamp before the end of the file — which after a crash reads as "nothing happened since then." The daemon logs exactly one warning that never reaches the client. The reader code returns EOF on a decode error with a comment saying this is so it can move to the next file; when the corruption is in the last or only file, there is no next file, and the read just ends.&lt;/p&gt;

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

&lt;p&gt;Docker Engine 29.8.0, Debian 13, a throwaway LXC container, &lt;code&gt;json-file&lt;/code&gt; driver with no options — the default. A busybox container printing numbered lines every 100 ms, stopped after 50:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs logtest | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;50
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs logtest | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="go"&gt;line 49
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then one record in the middle of the log file — number 17 of 51 — overwritten with NUL bytes of the same length, so the file size does not change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record index 17 of 51: b'{"log":"line 17\n","stream":"stdout","time":"2026-09-12T00:1'
size before: 3889 bytes   size after: 3889 bytes
NUL bytes in file: 77
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same read, with stdout, stderr and the exit code captured separately rather than through a pipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs logtest &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out 2&amp;gt; err&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; out&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; err
&lt;span class="go"&gt;17
0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; out
&lt;span class="go"&gt;line 16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventeen lines, exit zero, nothing on stderr. The other thirty-three records are intact on disk, and the client has no idea they exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--tail&lt;/code&gt; looks like it works, and that is the trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker logs --tail 5    logtest    exit 0   5 lines    line 45 … line 49
docker logs --tail 30   logtest    exit 0   30 lines   line 20 … line 49
docker logs --tail 40   logtest    exit 0   7 lines    line 10 … line 16
docker logs --tail 100  logtest    exit 0   17 lines   line 0  … line 16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A window that starts after record 17 is fine. A window that includes it stops at it. You do not know where the corruption is, so you do not know which of those you are getting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;Two things make this worse than a truncated file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tail-vs-full disagreement points at the wrong thing.&lt;/strong&gt; &lt;code&gt;--tail 50&lt;/code&gt; shows fresh lines. The plain read ends in the past. The natural conclusion is that something is wrong with the &lt;em&gt;reader&lt;/em&gt; — a client version mismatch, a buffering problem, a terminal issue — because the file is evidently still being written. It is not a reader problem. The file has a hole in it, and only one of the two commands walks through the hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--since&lt;/code&gt; gives an answer that is wrong in the most plausible direction.&lt;/strong&gt; This is the one the upstream report did not mention. Take the timestamp of record 20, three records past the corruption, and ask for everything since:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 2026-09-12T00:11:23.772318762Z logtest &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; out
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero lines, exit zero. There are thirty records after that timestamp on disk. &lt;code&gt;--since&lt;/code&gt; still reads forward from the beginning of the file, hits the NUL region at record 17, and stops before it reaches anything new enough to match. The control, with the corrupt record removed and the same timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs &lt;span class="nt"&gt;--since&lt;/span&gt; 2026-09-12T00:11:23.772318762Z logtest | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;30
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the exact command you would run after a power loss — "what was this container doing in the minutes before it died?" — returns the same output as "it was doing nothing." Not an error. Not a truncated list. Empty.&lt;/p&gt;

&lt;p&gt;The daemon knows. &lt;code&gt;journalctl -u docker&lt;/code&gt; on the host has one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;level&lt;/span&gt;=&lt;span class="n"&gt;warning&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;=&lt;span class="s2"&gt;"Error decoding log file"&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;=&lt;span class="s2"&gt;"invalid character '\x00' looking for beginning of value"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing carries that to the client, and a warning-level line in the daemon journal is not where anyone looks when &lt;code&gt;docker logs&lt;/code&gt; returns cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;daemon/logger/loggerutils/logfile.go&lt;/code&gt;, in the function that tails the log files, as of master on 2026-09-11:&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="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fwd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EOF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// We have an error decoding the stream, but we don't want to error out&lt;/span&gt;
        &lt;span class="c"&gt;// the whole log reader.&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
        &lt;span class="c"&gt;// Instead just log the error here and return an EOF so we can move to&lt;/span&gt;
        &lt;span class="c"&gt;// the next file.&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Error decoding log file"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EOF&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment describes the design: a decode error is converted into EOF so that the reader can give up on this file and continue with the next one in the rotation. That is reasonable when there &lt;em&gt;is&lt;/em&gt; a next file. When the corruption is in the last file — or the only file, which is every container that has not rotated — "move to the next file" means "finish." The warning is logged, the EOF is returned, the read completes, and completion is success.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--tail&lt;/code&gt; escapes this because it seeks backward from the end of the file and only decodes forward from there. If the seek lands after the NUL region, the decoder never sees it. &lt;code&gt;--since&lt;/code&gt; does not seek; it filters, and the filter never gets input past the hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the NUL bytes come from is not something I reproduced.&lt;/strong&gt; I wrote them into the file with Python. What I can say is what the upstream tracker says: the issue this came from, &lt;a href="https://github.com/moby/moby/issues/53631" rel="noopener noreferrer"&gt;&lt;code&gt;moby/moby#53631&lt;/code&gt;&lt;/a&gt;, got its zeroed region from a Docker Desktop VM stopping uncleanly. The eight-year-old sibling issue about half-written records, &lt;a href="https://github.com/moby/moby/issues/29511" rel="noopener noreferrer"&gt;&lt;code&gt;moby/moby#29511&lt;/code&gt;&lt;/a&gt;, has a maintainer comment from 2024 that is the part relevant here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are other reports of the exact behavior with the 'local' log driver on rpi's after abrupt power loss … The fact that it is writing null bytes when this occurs … makes it extra suspicious&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A filesystem that has extended the file but not yet written the data — ext4 with delayed allocation, power gone before the flush — leaves exactly this: the right length, zeros where the bytes should be. On a Raspberry Pi on a wall adapter with no UPS, that is not an exotic scenario. It is the scenario.&lt;/p&gt;

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

&lt;p&gt;Upstream has not fixed it; the decode-error path has looked like this for years and &lt;code&gt;#53631&lt;/code&gt; is a day old with no comments. Two things you can do on your own machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repair a stopped container's log by dropping the records that contain NUL bytes.&lt;/strong&gt; Those records were never recoverable — the bytes are zeros — but everything after them is, and this is what brings it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.LogPath}}'&lt;/span&gt; logtest&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;.bak-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import sys
p = sys.argv[1]
lines = open(p, 'rb').read().split(b'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;')
kept = [l for l in lines if b'&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="sh"&gt;' not in l]
open(p, 'wb').write(b'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'.join(kept))
print(f"removed {len(lines) - len(kept)}, kept {len(kept)}")
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;removed 1, kept 50
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs logtest | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;49
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restoring the corrupt file brings the 17-line read straight back, so the repair and the fault are cleanly attributable to that one record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check before you trust a read, especially after a crash.&lt;/strong&gt; The file is the ground truth and it is readable without going through the daemon at all:&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;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.LogPath}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NUL bytes: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-cd&lt;/span&gt; &lt;span class="s1"&gt;'\000'&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"last on disk: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-avP&lt;/span&gt; &lt;span class="s1"&gt;'\x00'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import sys,json; print(json.loads(sys.stdin.read())["log"].rstrip())'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"last from docker: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker logs &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the corrupted container above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NUL bytes: 77
last on disk: line 49
last from docker: line 16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;grep -P '\x00'&lt;/code&gt; rather than a shell &lt;code&gt;$'\0'&lt;/code&gt; — a NUL cannot be passed as a shell argument, so the latter silently matches the empty string and excludes everything. That version was wrong the first time I wrote it.)&lt;/p&gt;

&lt;p&gt;If the NUL count is nonzero, or the two last lines disagree on a stopped container, the full read is stopping short. The toolkit's &lt;code&gt;check-docker-log-integrity.sh&lt;/code&gt; is this comparison across rotated files with the exit code read directly rather than through a pipe, and the repair printed for you.&lt;/p&gt;

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

&lt;p&gt;The narrow lesson is that &lt;code&gt;docker logs&lt;/code&gt; reports whether the &lt;em&gt;read finished&lt;/em&gt;, not whether it &lt;em&gt;read everything&lt;/em&gt;, and those diverge exactly when you most need them not to. After a power loss, treat the log file as the record and &lt;code&gt;docker logs&lt;/code&gt; as one view of it that may end early.&lt;/p&gt;

&lt;p&gt;The wider one is about comments that describe a design and code that runs outside it. The EOF-on-decode-error path is correct for the case its author had in mind — rotated files, skip the bad one, keep going. Nobody wrote a lie. The code simply also runs in the case where there is nothing to keep going &lt;em&gt;to&lt;/em&gt;, and in that case the same three lines mean "stop and say nothing." The same shape as &lt;a href="https://homelabpostmortem.com/2026/09/11/cloud-init-never-reads-the-instance-id-you-set/" rel="noopener noreferrer"&gt;a cloud-init module that reports SUCCESS for the run it skipped&lt;/a&gt;: the status word is true about the control flow and says nothing about your intent. And it sits next to &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;a journal that is discarded on every reboot&lt;/a&gt; as a second way to find, after the crash, that the logs you were counting on are not there — this time with the bytes still on disk.&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>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>llama-server ignores the response_format its own README shows, and returns 200</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Fri, 11 Sep 2026 17:16:33 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-server-ignores-the-responseformat-its-own-readme-shows-and-returns-200-41b5</link>
      <guid>https://dev.to/homelabpm/llama-server-ignores-the-responseformat-its-own-readme-shows-and-returns-200-41b5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;tools/server/README.md&lt;/code&gt; gives &lt;code&gt;{"type": "json_schema", "schema": {...}}&lt;/code&gt; as an example of schema-constrained output. On a build from this week the reply to that form is &lt;strong&gt;byte-identical&lt;/strong&gt; to the reply when you send no &lt;code&gt;response_format&lt;/code&gt; at all — same SHA-256, same 248 characters of prose. HTTP 200, nothing in the log. The &lt;code&gt;json_schema&lt;/code&gt; branch of the request parser reads only &lt;code&gt;response_format.json_schema.schema&lt;/code&gt;; a &lt;code&gt;schema&lt;/code&gt; sitting directly under &lt;code&gt;response_format&lt;/code&gt; is never consulted in that branch. The March 2025 fix that people cite for this fixed the &lt;em&gt;nested&lt;/em&gt; form and never touched this one, and the README example was already there when it merged. Two other forms constrain correctly and are the workaround.&lt;/p&gt;

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

&lt;p&gt;llama.cpp &lt;code&gt;b10868&lt;/code&gt; (commit &lt;code&gt;304665fe7&lt;/code&gt;, released 2026-09-09), the official Linux x64 binary, on Debian 13, CPU only. Model is &lt;code&gt;gemma-3-1b-it-Q4_K_M&lt;/code&gt;, small enough that the whole experiment takes a minute. One prompt, one schema, &lt;code&gt;temperature 0&lt;/code&gt;, &lt;code&gt;seed 42&lt;/code&gt;, four ways of asking:&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;prompt&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;What&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="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Explain&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reasoning&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&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;few&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sentences.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;schema&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&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 prompt is chosen to produce prose if nothing stops it. The schema allows exactly one integer field and nothing else. Here is what came back, hashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                                         sha256        result
no response_format                                       5dc606573325  free text
{"type":"json_object","schema":{...}}                    72241d50124b  {"answer": 4}
{"type":"json_schema","schema":{...}}         [README]   5dc606573325  free text
{"type":"json_schema","json_schema":{"schema":{...}}}    72241d50124b  {"answer": 4}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hashes for four requests. The README form did not produce &lt;em&gt;worse&lt;/em&gt; structured output, or partially constrained output. It produced the control, to the byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 + 2 equals 4. This is a fundamental mathematical concept that relies on
basic addition – combining two individual units to create a combined unit
with a value of four. It's a core principle used in …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request returned HTTP 200. The server log for the four of them is four ordinary &lt;code&gt;launch_slot_&lt;/code&gt; / &lt;code&gt;print_timing&lt;/code&gt; / &lt;code&gt;release&lt;/code&gt; triples with nothing about a schema, a grammar, or an empty anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;You would not notice, because models cooperate. Ask a capable model for JSON in the prompt and it usually gives you JSON, so a &lt;code&gt;response_format&lt;/code&gt; that does nothing looks like a &lt;code&gt;response_format&lt;/code&gt; that works — right up to the request where the model decides to add a sentence of explanation, or a trailing comma, or a field you did not ask for, and something downstream falls over. At that point the natural question is "why is the model misbehaving?" and not "was the constraint ever applied?", because the server has been answering 200 the whole time.&lt;/p&gt;

&lt;p&gt;The second false trail is the issue tracker. Search for this and you find &lt;a href="https://github.com/ggml-org/llama.cpp/issues/10732" rel="noopener noreferrer"&gt;&lt;code&gt;#10732&lt;/code&gt;&lt;/a&gt; ("server provides structured output for &lt;code&gt;json_object&lt;/code&gt;, but not for &lt;code&gt;json_schema&lt;/code&gt;", December 2024) and, next to it, &lt;a href="https://github.com/ggml-org/llama.cpp/issues/11988" rel="noopener noreferrer"&gt;&lt;code&gt;#11988&lt;/code&gt;&lt;/a&gt;, closed as completed in March 2025 with a merged fix, &lt;a href="https://github.com/ggml-org/llama.cpp/pull/12168" rel="noopener noreferrer"&gt;&lt;code&gt;#12168&lt;/code&gt;&lt;/a&gt;, and a reporter confirming "the issue is not present in the new &lt;code&gt;b4820&lt;/code&gt;." That reads like the end of the story. It is not, and the reason is in the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;This is the request parser, &lt;code&gt;tools/server/server-common.cpp&lt;/code&gt;, as shipped in &lt;code&gt;b10868&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"json_object"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;json_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"json_schema"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;schema_wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"json_schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;json_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema_wrapper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;response_type&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;invalid_argument&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;json_object&lt;/code&gt; branch reads &lt;code&gt;response_format.schema&lt;/code&gt;. The &lt;code&gt;json_schema&lt;/code&gt; branch reads &lt;code&gt;response_format.json_schema.schema&lt;/code&gt; and only that. Send &lt;code&gt;{"type":"json_schema","schema":{...}}&lt;/code&gt; and &lt;code&gt;schema_wrapper&lt;/code&gt; defaults to &lt;code&gt;{}&lt;/code&gt;, &lt;code&gt;json_schema&lt;/code&gt; defaults to &lt;code&gt;{}&lt;/code&gt;, and generation proceeds with an empty schema — which is to say, no grammar. No branch throws, because the type was recognised.&lt;/p&gt;

&lt;p&gt;Now the 2025 fix. This is the whole of what &lt;a href="https://github.com/ggml-org/llama.cpp/pull/12168" rel="noopener noreferrer"&gt;&lt;code&gt;#12168&lt;/code&gt;&lt;/a&gt; changed in that function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-            json json_schema = json_value(response_format, "json_schema", json::object());
-            json_schema = json_value(json_schema, "schema", json::object());
&lt;/span&gt;&lt;span class="gi"&gt;+            auto schema_wrapper = json_value(response_format, "json_schema", json::object());
+            json_schema = json_value(schema_wrapper, "schema", json::object());
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shadowed variable. The inner &lt;code&gt;json_schema&lt;/code&gt; was hiding the outer one, so the nested wrapper form silently did nothing. The fix un-shadowed it, the nested form started working, the test it added uses the nested form, and the issue closed. &lt;strong&gt;The top-level form was not part of that bug and not part of that fix.&lt;/strong&gt; It has not regressed; on this code path it has never worked.&lt;/p&gt;

&lt;p&gt;And the README already showed it. At the merge commit of &lt;code&gt;#12168&lt;/code&gt;, &lt;code&gt;examples/server/README.md&lt;/code&gt; line 1076 read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;response_format&lt;/code&gt; parameter supports both plain JSON output (e.g. &lt;code&gt;{"type": "json_object"}&lt;/code&gt;) and schema-constrained JSON (e.g. &lt;code&gt;{"type": "json_object", "schema": {...}}&lt;/code&gt; or &lt;code&gt;{"type": "json_schema", "schema": {...}}&lt;/code&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same sentence is at &lt;code&gt;tools/server/README.md&lt;/code&gt; line 1316 today. Eighteen months of a documented example that the parser does not read, sitting one clause away from a documented example that it does.&lt;/p&gt;

&lt;p&gt;There is a pull request that fixes it, &lt;a href="https://github.com/ggml-org/llama.cpp/pull/28697" rel="noopener noreferrer"&gt;&lt;code&gt;#28697&lt;/code&gt;&lt;/a&gt;, which adds a fallback to &lt;code&gt;response_format.schema&lt;/code&gt; when the wrapper is absent. As of this writing it is open with no reviews. I built its branch (&lt;code&gt;48f9bfd&lt;/code&gt;) on the same machine and ran the same four requests: the README form now hashes &lt;code&gt;72241d50124b&lt;/code&gt;, identical to the two forms that already worked, and those two are unchanged. The patch does what it says.&lt;/p&gt;

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

&lt;p&gt;Until that merges, use either of the two forms that do constrain on this build. Both were verified above with the same hash:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json_object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schema"&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="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json_schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"json_schema"&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;"name"&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;"schema"&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="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&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;The second is the OpenAI wrapper shape and is what most client libraries emit, which is probably why this has survived so long — the people who would hit it are the ones who read the README and wrote the request by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the server you actually run, not the one in the docs.&lt;/strong&gt; Four requests, compared by hash, is the whole test, and it takes seconds against any running &lt;code&gt;llama-server&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;forms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is 2+2? Explain your reasoning in a few sentences.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rf&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8080/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output on &lt;code&gt;b10868&lt;/code&gt;, gemma-3-1b:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;none         5dc606573325  '2 + 2 equals 4. This is a fundamental ma'
json_object  72241d50124b  '{\n \t \t \t \t \t \t \t \t \t \t"answer": 4\n \t \t \t'
readme       5dc606573325  '2 + 2 equals 4. This is a fundamental ma'
openai       72241d50124b  '{\n \t \t \t \t \t \t \t \t \t \t"answer": 4\n \t \t \t'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any form with a schema hashes the same as &lt;code&gt;null&lt;/code&gt;, that form is not being enforced on your build. The toolkit's &lt;code&gt;check-llama-response-format.sh&lt;/code&gt; does exactly this, with the control checked first so a model that happens to answer in JSON unprompted cannot fake a pass.&lt;/p&gt;

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

&lt;p&gt;The narrow one: &lt;strong&gt;a fix for a sibling form is not a fix for your form.&lt;/strong&gt; &lt;code&gt;#12168&lt;/code&gt; was real, was merged, and was confirmed by the person who reported it. It also had nothing to do with the request shape the README shows. When a closed issue seems to cover your case, read the diff and find your exact input in it; if it is not there, the issue was about something else that happened to share a title.&lt;/p&gt;

&lt;p&gt;The wider one is about what "it worked" is evidence of. A structured response from a model can mean the server constrained the output, or it can mean the model produced it unconstrained because you asked nicely — and from the outside those are the same bytes. The only way to tell them apart is to hold the prompt fixed and &lt;em&gt;remove&lt;/em&gt; the constraint: if the output does not change, there was no constraint. That is the same move as &lt;a href="https://homelabpostmortem.com/2026/09/07/ollama-library-quant-is-broken-not-the-quant-level/" rel="noopener noreferrer"&gt;running the control before the suspect&lt;/a&gt; and the same shape as &lt;a href="https://homelabpostmortem.com/2026/09/07/vllm-accepts-a-lora-it-will-never-apply/" rel="noopener noreferrer"&gt;an adapter that is accepted and applied to zero layers&lt;/a&gt;: the system reports that it did what you asked, and the output looks right, and neither of those is the thing you needed to know.&lt;/p&gt;

&lt;p&gt;Two days later the same shape showed up one layer over: &lt;a href="https://homelabpostmortem.com/2026/09/14/ollama-responses-api-drops-previous-response-id/" rel="noopener noreferrer"&gt;Ollama's Responses API accepts &lt;code&gt;previous_response_id&lt;/code&gt;, returns 200 and "completed", and starts every turn from nothing&lt;/a&gt;. There the number that has to move is the input token count, and it does not.&lt;/p&gt;

&lt;p&gt;And three days after that, the layer below: &lt;a href="https://homelabpostmortem.com/2026/09/15/llama-cpp-batch-reads-past-its-own-pos-buffer-for-mrope/" rel="noopener noreferrer"&gt;llama.cpp's batch API says pass &lt;code&gt;pos&lt;/code&gt; as NULL and positions are tracked automatically; for M-RoPE embeddings it reads past its own buffer&lt;/a&gt;. No number to watch that time — a sanitiser, and an address.&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>ai</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>cloud-init never reads the instance-id Raspberry Pi OS sets, so your second user-data is skipped</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:56:30 +0000</pubDate>
      <link>https://dev.to/homelabpm/cloud-init-never-reads-the-instance-id-raspberry-pi-os-sets-so-your-second-user-data-is-skipped-2li9</link>
      <guid>https://dev.to/homelabpm/cloud-init-never-reads-the-instance-id-raspberry-pi-os-sets-so-your-second-user-data-is-skipped-2li9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: write a plain Raspberry Pi OS image to a disk, boot it once, then add a &lt;code&gt;user-data&lt;/code&gt; with a &lt;code&gt;users:&lt;/code&gt; block and boot again. The user is not created. The file is not the problem: cloud-init parses it, merges it, and writes the merged result to disk with your block in it. It is skipped because &lt;code&gt;users_groups&lt;/code&gt; is a &lt;strong&gt;once-per-instance&lt;/strong&gt; module and the first boot already ran it — against the stock &lt;code&gt;user-data&lt;/code&gt;, which is 3,277 characters of comments and parses to nothing. The instance never changes, because the stock &lt;code&gt;meta-data&lt;/code&gt; writes &lt;strong&gt;&lt;code&gt;instance_id&lt;/code&gt;&lt;/strong&gt; and cloud-init reads &lt;strong&gt;&lt;code&gt;instance-id&lt;/code&gt;&lt;/strong&gt;. The skipped modules are logged as &lt;code&gt;SUCCESS&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;A USB SSD, freshly written with Raspberry Pi OS Lite (Trixie, arm64) and booted once to confirm it came up. Then a &lt;code&gt;user-data&lt;/code&gt; placed on the boot partition, by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mitoyosh-pi4b-ssd&lt;/span&gt;
&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mito&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users,adm,dialout,audio,netdev,video,plugdev,cdrom,games,input,gpio,spi,i2c,render,sudo&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;lock_passwd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere&lt;/span&gt;
&lt;span class="na"&gt;ssh_pwauth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boot. No SSH. Mount the disk read-only from another machine and the reason is plain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^mito:"&lt;/span&gt; /mnt/ssdroot/etc/passwd
&lt;span class="go"&gt;  mito は存在しない
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /mnt/ssdroot/home/mito/.ssh/authorized_keys
&lt;span class="go"&gt;  ls: cannot access: No such file or directory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obvious next move is to suspect the file. It survives every check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;file        Unicode text, UTF-8 text      (no BOM)&lt;/span&gt;
&lt;span class="s"&gt;CR count    0                             (no CRLF)&lt;/span&gt;
&lt;span class="s"&gt;line &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;      &lt;span class="c1"&gt;#cloud-config$                (cat -A — nothing before it)&lt;/span&gt;
&lt;span class="na"&gt;yaml        type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dict&lt;/span&gt;
            &lt;span class="s"&gt;keys&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;hostname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;packages'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ssh_pwauth'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;users'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And cloud-init agrees. This is the merged config it wrote to &lt;code&gt;/var/lib/cloud/instances/&amp;lt;id&amp;gt;/cloud-config.txt&lt;/code&gt; on that boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="c1"&gt;# from 1 files&lt;/span&gt;
&lt;span class="c1"&gt;# part-001&lt;/span&gt;
&lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mitoyosh-pi4b-ssd&lt;/span&gt;
&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt;   &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mito&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
&lt;span class="nn"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The block is there.&lt;/strong&gt; It was read, parsed, merged, and written back out. And the user does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;Two things point you away from the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hostname changes.&lt;/strong&gt; &lt;code&gt;/etc/hostname&lt;/code&gt; really does become &lt;code&gt;mitoyosh-pi4b-ssd&lt;/code&gt;. &lt;code&gt;update_hostname&lt;/code&gt; runs with frequency &lt;code&gt;always&lt;/code&gt;, so it applies on every boot regardless of instance. &lt;code&gt;users_groups&lt;/code&gt; runs &lt;code&gt;once-per-instance&lt;/code&gt;, so it does not. &lt;strong&gt;Half your user-data takes effect&lt;/strong&gt;, which is a strong signal that the file is being read — and it is, just not acted on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure surfaces as a network problem.&lt;/strong&gt; No SSH looks like no network, and if the machine has no route out, you will find plenty of networking to be wrong. On this disk &lt;code&gt;/etc/netplan/&lt;/code&gt; was empty and &lt;code&gt;NetworkManager.state&lt;/code&gt; had &lt;code&gt;WirelessEnabled=false&lt;/code&gt;, both true and both irrelevant to the missing user. I spent an hour building a cloud-init explanation for the &lt;em&gt;network&lt;/em&gt; symptom before checking whether an ethernet cable was plugged in. It was not.&lt;/p&gt;

&lt;p&gt;The log is no help either, because it reports success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;09:32:20  Attempting to load yaml from string of length 773
09:32:20  Merging by applying [('dict', ['replace']), ('list', []), ('str', [])]
09:32:20  config-users_groups already ran (freq=once-per-instance)
09:32:20  finish: init-local/config-users_groups: SUCCESS: config-users_groups previously ran
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It loads your YAML. It merges your YAML. Then it declines to act on it and records &lt;code&gt;SUCCESS&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is really going on
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;once-per-instance&lt;/code&gt; module leaves a semaphore. Theirs are all dated from the &lt;strong&gt;first&lt;/strong&gt; boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/lib/cloud/instances/nocloud/sem/
&lt;span class="go"&gt;config_locale          Jun 18 09:27
config_mounts          Jun 18 09:27
config_seed_random     Jun 18 09:27
config_set_hostname    Jun 18 09:27
config_set_passwords   Jun 18 09:27
config_ssh             Jun 18 09:27
config_ssh_import_id   Jun 18 09:27
config_users_groups    Jun 18 09:27
consume_data           Jun 18 09:27
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first boot had the stock &lt;code&gt;user-data&lt;/code&gt;, which ships as pure comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;09:27:16  Attempting to load yaml from string of length 3277
09:27:16  loaded blob returned None, returning default.
09:27:16  WARNING: Failed at merging in cloud config part from part-001: empty cloud config
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Nine modules ran against an empty configuration and marked themselves done.&lt;/strong&gt; The seats were taken before you sat down.&lt;/p&gt;

&lt;p&gt;Normally that is fine, because a new instance gets a new id and a fresh set of semaphores. Here the id never changes. This is the stock &lt;code&gt;meta-data&lt;/code&gt;, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Specifies the "unique" identifier of the instance. Typically in cloud-init&lt;/span&gt;
&lt;span class="c1"&gt;# this is generated by the owning cloud and is actually unique (to some&lt;/span&gt;
&lt;span class="c1"&gt;# degree). Here our data-source is local, so this is just a fixed string.&lt;/span&gt;
&lt;span class="c1"&gt;# Warning: changing this will cause cloud-init to assume it is running on a&lt;/span&gt;
&lt;span class="c1"&gt;# "new" instance, and to go through first time setup again (the value is&lt;/span&gt;
&lt;span class="c1"&gt;# compared to a cached copy).&lt;/span&gt;
&lt;span class="na"&gt;instance_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rpios-image&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The comment is correct. The key below it is not.&lt;/strong&gt; cloud-init reads &lt;code&gt;instance-id&lt;/code&gt;, with a hyphen. From &lt;code&gt;cloudinit/sources/DataSourceNoCloud.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# line 65 — the default when nothing supplies one
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instance-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nocloud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;# line 284
&lt;/span&gt;    &lt;span class="n"&gt;iid_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instance-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;instance_id: rpios-image&lt;/code&gt; is never seen, the id becomes the literal string &lt;code&gt;nocloud&lt;/code&gt;, and the instance directory is &lt;code&gt;/var/lib/cloud/instances/nocloud&lt;/code&gt; on every such disk. Change the value the comment tells you to change and nothing happens, because the line it is attached to was never read in the first place.&lt;/p&gt;

&lt;p&gt;The control is the same OS on a card written by Raspberry Pi Imager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /boot/firmware/meta-data
&lt;span class="go"&gt;instance-id: rpi-imager-1787993916805
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hyphen, and unique. Imager also passes it on the kernel command line, which is a second route to the same field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /boot/firmware/cmdline.txt
&lt;span class="gp"&gt;... rootwait ds=nocloud;&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rpi-imager-1787993916805 cfg80211.ieee80211_regdom&lt;span class="o"&gt;=&lt;/span&gt;JP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;i=&lt;/code&gt; maps to &lt;code&gt;instance-id&lt;/code&gt; too — &lt;code&gt;s2l = {"h": "local-hostname", "i": "instance-id", "s": "seedfrom"}&lt;/code&gt;, line 360 of the same file. &lt;strong&gt;A customised image has two ways to set the id. A plain image write has none.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Change the key, and give it a value that has not been used before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /boot/firmware/meta-data&lt;/span&gt;
&lt;span class="na"&gt;dsmode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;span class="na"&gt;instance-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ssd-boot-test-20260909&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix. &lt;code&gt;/var/lib/cloud&lt;/code&gt; was deliberately &lt;strong&gt;not&lt;/strong&gt; deleted, so that the result attributes cleanly to the one character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before                          after
instances/nocloud               instances/nocloud
                                instances/ssd-boot-test-20260909
sem: 9 files                    sem: 18 files
no mito                         mito:x:1000:1000::/home/mito:/bin/bash
no authorized_keys              /home/mito/.ssh/authorized_keys  0600 mito:mito
no boot-finished                boot-finished
                                cloud-init status: done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine semaphores became eighteen, the user was created, the key was installed, and cloud-init ran through &lt;code&gt;modules-final&lt;/code&gt; to completion. The old instance directory is still sitting next to the new one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check before you boot, not after.&lt;/strong&gt; Both of these are cheap:&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="c"&gt;# 1. Is the key name one cloud-init will read?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^instance[-_]id:'&lt;/span&gt; /boot/firmware/meta-data
&lt;span class="c"&gt;# instance_id: rpios-image   &amp;lt;- underscore. Never read.&lt;/span&gt;

&lt;span class="c"&gt;# 2. Has this disk already burned its once-per-instance modules?&lt;/span&gt;
&lt;span class="c"&gt;#    (mount the rootfs read-only somewhere first)&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/root/var/lib/cloud/instances/
&lt;span class="c"&gt;# nocloud   &amp;lt;- the generic id, so yes, and a new user-data will not run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second one lists anything, a fresh &lt;code&gt;user-data&lt;/code&gt; will be read and ignored unless you change the instance id.&lt;/p&gt;

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

&lt;p&gt;The narrow lesson is to check the key name. The wider one is about which artifact you are trusting.&lt;/p&gt;

&lt;p&gt;This file documents its own mechanism correctly, in a comment written by someone who understood it, and then names the key wrong on the next line. &lt;strong&gt;Following the instructions in a file is not the same as the file working.&lt;/strong&gt; The prose and the code in a config template are maintained by the same hand but validated by different things — the prose by nobody, the key name by whatever reads it, silently, at boot.&lt;/p&gt;

&lt;p&gt;That is the same shape as &lt;a href="https://homelabpostmortem.com/2026/08/29/cloud-init-validates-the-key-it-never-reads/" rel="noopener noreferrer"&gt;a cloud-init key whose valid placement is exactly opposite between two documented forms&lt;/a&gt;, where &lt;code&gt;cloud-init schema&lt;/code&gt; calls the file valid either way. Both cases pass every check that looks at the file, because the thing that is wrong is the relationship between the file and the reader, and no validator holds both.&lt;/p&gt;

&lt;p&gt;And the operational habit, which cost the most time here: &lt;strong&gt;when a tool reports &lt;code&gt;SUCCESS&lt;/code&gt; for work it skipped, the word is describing the module's control flow, not your intent.&lt;/strong&gt; &lt;code&gt;config-users_groups previously ran&lt;/code&gt; is a true statement. It is also the only notice you will get that the configuration you just wrote is never going to execute.&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>raspberrypi</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>vLLM accepts a LoRA it will never apply, and answers with the base model instead</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:26:11 +0000</pubDate>
      <link>https://dev.to/homelabpm/vllm-accepts-a-lora-it-will-never-apply-and-answers-with-the-base-model-instead-2kpo</link>
      <guid>https://dev.to/homelabpm/vllm-accepts-a-lora-it-will-never-apply-and-answers-with-the-base-model-instead-2kpo</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update (2026-09-13)&lt;/strong&gt;: the fix has landed. &lt;a href="https://github.com/vllm-project/vllm/pull/55310" rel="noopener noreferrer"&gt;&lt;code&gt;vllm-project/vllm#55310&lt;/code&gt;&lt;/a&gt; merged on 2026-09-09 and closed the issue, so vLLM built from &lt;code&gt;main&lt;/code&gt; after that commit rejects an adapter whose modules fall entirely outside &lt;code&gt;--lora-target-modules&lt;/code&gt; instead of accepting it and applying it to nothing. Released versions up to and including 0.28.0 — the one this post reproduced on — still have the behaviour described below. The toolkit's &lt;code&gt;check-lora-target-overlap.sh&lt;/code&gt; remains the way to catch it on those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;vllm serve --lora-target-modules qkv_proj&lt;/code&gt; plus an adapter that only touches &lt;code&gt;down_proj&lt;/code&gt; is a combination vLLM accepts. It loads the adapter, compiles the LoRA kernels, and then wraps zero layers with it — every request comes back as the unmodified base model, byte for byte, with no error, no rejection, and no warning that mentions &lt;code&gt;target_modules&lt;/code&gt;. The acceptance path reads &lt;code&gt;supported_lora_modules&lt;/code&gt;; the application path reads that &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;lora_config.target_modules&lt;/code&gt;. The second list is the one you set on the command line, and only one of the two checks has heard of it.&lt;/p&gt;

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

&lt;p&gt;Same adapter file, same prompt, &lt;code&gt;temperature 0&lt;/code&gt;, &lt;code&gt;seed 42&lt;/code&gt;. The only thing that changes between these two runs is the deployment's allow-list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--lora-target-modules down_proj      (overlaps the adapter)

  base : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
  lora : ":\nA. Paris\nB. London\nC. Rome\nD. Moscow\nAnswer:\n\nA\n\nWhich of the"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--lora-target-modules qkv_proj       (disjoint from the adapter)

  base : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
  lora : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second pair is byte-identical. The request carried a &lt;code&gt;LoRARequest&lt;/code&gt;, it returned 200, and it was served by the base model.&lt;/p&gt;

&lt;p&gt;Verified on vLLM 0.28.0, &lt;code&gt;Qwen/Qwen2.5-0.5B-Instruct&lt;/code&gt;, an RTX 2070 under WSL2.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not that the adapter failed to load
&lt;/h2&gt;

&lt;p&gt;That was the first hypothesis and it is wrong, which matters, because "my adapter path was bad" sends you looking in a place where there is nothing to find. vLLM stands up the entire LoRA machinery for a request it is about to serve without any LoRA in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;WARNING [jit_monitor.py:141] Triton kernel JIT compilation during inference: _lora_shrink_kernel
WARNING [jit_monitor.py:141] Triton kernel JIT compilation during inference: _lora_expand_kernel
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two kernels &lt;em&gt;are&lt;/em&gt; LoRA. A LoRA layer computes &lt;code&gt;y = Wx + B(Ax)&lt;/code&gt;: shrink is &lt;code&gt;Ax&lt;/code&gt;, projecting down to the adapter's rank, and expand is &lt;code&gt;B(...)&lt;/code&gt;, projecting back up. Both were compiled, for this request, on the run that returned base-model output. The adapter was read, registered and activated. It was simply attached to nothing.&lt;/p&gt;

&lt;p&gt;So the search space is not "why was my adapter rejected". Nothing was rejected.&lt;/p&gt;

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

&lt;p&gt;Two checks, and they do not read the same thing. From the installed 0.28.0, not from the report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supported_lora_modules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WorkerLoRAManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_load_adapter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_modules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WorkerLoRAManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_load_adapter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The acceptance path validates the checkpoint against &lt;code&gt;supported_lora_modules&lt;/code&gt; — the set of module types vLLM can wrap for this architecture at all. &lt;code&gt;qkv_proj&lt;/code&gt;, &lt;code&gt;o_proj&lt;/code&gt;, &lt;code&gt;gate_up_proj&lt;/code&gt;, &lt;code&gt;down_proj&lt;/code&gt; for a Qwen2 model. An adapter targeting &lt;code&gt;down_proj&lt;/code&gt; passes, because &lt;code&gt;down_proj&lt;/code&gt; is a thing vLLM knows how to wrap.&lt;/p&gt;

&lt;p&gt;The application path asks a second question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_match_target_modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;module_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_supported_lora_module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supported_lora_modules&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;is_in_target_modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;module_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packed_modules_mapping&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;&lt;code&gt;lora_config.target_modules&lt;/code&gt; is &lt;code&gt;--lora-target-modules&lt;/code&gt;. It is consulted here and nowhere upstream of here. So the adapter is judged twice, against a permissive list and then a restrictive one, and &lt;strong&gt;only the permissive judgement can produce a message.&lt;/strong&gt; By the time the restrictive one runs, the answer is not "reject this adapter" — it is "wrap this module: no", asked once per module, and every answer is no.&lt;/p&gt;

&lt;p&gt;Zero wrapped modules is not an error state anywhere. It is just a loop that did nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this reaches, and who it does not
&lt;/h2&gt;

&lt;p&gt;Not everyone, and the shape of who matters, because it explains why a bug this loud in its consequences is this quiet in the wild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you never pass &lt;code&gt;--lora-target-modules&lt;/code&gt;, you cannot hit this.&lt;/strong&gt; The allow-list then defaults to everything the architecture supports, so the restrictive list and the permissive list are the same list and the two checks agree. Most single-adapter deployments look like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You hit it by deliberately narrowing the deployment&lt;/strong&gt; — restricting to &lt;code&gt;qkv_proj&lt;/code&gt; to bound memory, say, or to keep a serving profile stable across adapters — and then loading an adapter someone else trained, whose modules you did not check against your own restriction. That is a multi-tenant, many-adapters shape. It is also exactly the shape where nobody is reading individual responses closely enough to notice they got the base model.&lt;/p&gt;

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

&lt;p&gt;Upstream has fixed it: &lt;a href="https://github.com/vllm-project/vllm/pull/55310" rel="noopener noreferrer"&gt;&lt;code&gt;vllm-project/vllm#55310&lt;/code&gt;&lt;/a&gt;, "Reject adapters with no matching target modules", was open when this was written and merged on 2026-09-09. On any release up to 0.28.0, nothing in vLLM will tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the two lists against each other before you deploy.&lt;/strong&gt; The adapter states its own in &lt;code&gt;adapter_config.json&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;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
import json,sys
cfg = json.load(open(sys.argv[1] + "/adapter_config.json"))
adapter = set(cfg["target_modules"])
deployed = set(sys.argv[2].split(","))
print("adapter :", sorted(adapter))
print("deployed:", sorted(deployed))
print("overlap :", sorted(adapter &amp;amp; deployed) or "NONE — this adapter will be ignored")
'&lt;/span&gt; /path/to/adapter qkv_proj,o_proj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that packed modules make the comparison less obvious than it looks: an adapter naming &lt;code&gt;q_proj&lt;/code&gt;, &lt;code&gt;k_proj&lt;/code&gt; and &lt;code&gt;v_proj&lt;/code&gt; overlaps a deployment naming &lt;code&gt;qkv_proj&lt;/code&gt;, because vLLM fuses them. A plain string comparison will report a false alarm there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And confirm it end to end, once, with a prompt you know the answer to.&lt;/strong&gt; Send the same prompt with and without the &lt;code&gt;LoRARequest&lt;/code&gt; at &lt;code&gt;temperature 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;identical output  -&amp;gt;  the adapter is doing nothing
different output  -&amp;gt;  it is applied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the only signal that cannot be faked by a mechanism that reports success. It costs two requests.&lt;/p&gt;

&lt;p&gt;If you are building the adapter yourself to test this, know that &lt;code&gt;peft&lt;/code&gt; initialises &lt;code&gt;lora_B&lt;/code&gt; to zeros, so a freshly created, untrained adapter produces identical output &lt;strong&gt;whether or not it is applied&lt;/strong&gt;. Mine had to be given non-zero &lt;code&gt;lora_B&lt;/code&gt; deliberately, or the control would have proved nothing.&lt;/p&gt;

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

&lt;p&gt;The narrow lesson is about validation that runs before the decision it is supposed to guard. Accepting an adapter and applying an adapter were separated by a config field that only the second step could see, and only the first step had a voice. Any time a system says yes in one place and acts in another, the interesting question is not "does it validate?" but &lt;strong&gt;"does the thing that validates know everything the thing that acts knows?"&lt;/strong&gt; Here it did not, by one field.&lt;/p&gt;

&lt;p&gt;The wider one is about what counts as a control. My first instinct was to load the adapter, see base-model output, and call it reproduced. That would have been worthless: an adapter that does nothing when ignored also does nothing when applied, if it was never trained. The result only means something because the &lt;em&gt;same adapter file&lt;/em&gt; changed the output under a different allow-list. &lt;strong&gt;A negative result is evidence only after the positive one has been shown on the same setup&lt;/strong&gt; — otherwise you have measured your own test rig.&lt;/p&gt;

&lt;p&gt;It is the same trap as &lt;a href="https://homelabpostmortem.com/2026/09/07/ollama-library-quant-is-broken-not-the-quant-level/" rel="noopener noreferrer"&gt;an Ollama tag that pulls and runs and has no working code in&lt;br&gt;
it&lt;/a&gt;,&lt;br&gt;
where the control had to run first to catch a broken harness. Both times the failing observation was&lt;br&gt;
available immediately and agreed with the report, and both times it would have been the wrong reason.&lt;/p&gt;

&lt;p&gt;The same "accepted, then not applied" shape turned up in llama.cpp a few days later:&lt;br&gt;
&lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;llama-server takes the &lt;code&gt;json_schema&lt;/code&gt; form its own README documents, returns 200, and&lt;br&gt;
generates as if no &lt;code&gt;response_format&lt;/code&gt; was sent&lt;/a&gt;.&lt;br&gt;
There the control is even simpler — remove the constraint and see whether the bytes change —&lt;br&gt;
and it is the only way to tell a server that enforced from a model that cooperated.&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>ai</category>
      <category>vllm</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>An Ollama tag that pulls and runs and has no working code in it. The same model at the same quantisation, elsewhere, is fine.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sun, 06 Sep 2026 23:08:12 +0000</pubDate>
      <link>https://dev.to/homelabpm/an-ollama-tag-that-pulls-and-runs-and-has-no-working-code-in-it-the-same-model-at-the-same-4lhd</link>
      <guid>https://dev.to/homelabpm/an-ollama-tag-that-pulls-and-runs-and-has-no-working-code-in-it-the-same-model-at-the-same-4lhd</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update (2026-09-09)&lt;/strong&gt;: the reporter has published the full audit as a&lt;br&gt;
preprint — &lt;a href="https://arxiv.org/abs/2609.05881" rel="noopener noreferrer"&gt;&lt;em&gt;Broken on Arrival: Silently Defective LLM Artifacts in Public Model&lt;br&gt;
Registries and How to Catch Them&lt;/em&gt;&lt;/a&gt; (Aditi&lt;br&gt;
Patodiya, 2026), with the dataset and tooling at&lt;br&gt;
&lt;a href="https://github.com/aditi-p31/quantcheck" rel="noopener noreferrer"&gt;aditi-p31/quantcheck&lt;/a&gt;. It executed 327&lt;br&gt;
quantised code-capable artifacts, 305 of them from the official Ollama library,&lt;br&gt;
and confirmed silently defective artifacts in the official library, including&lt;br&gt;
the batch of four Qwen2.5-Coder-3B conversions that the &lt;code&gt;q3_K_M&lt;/code&gt; below belongs&lt;br&gt;
to. &lt;strong&gt;This post only ever tested &lt;code&gt;q3_K_M&lt;/code&gt;&lt;/strong&gt; — the three siblings are the&lt;br&gt;
census's result, not mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction (2026-09-10)&lt;/strong&gt;: the paragraph above first said &lt;em&gt;five&lt;/em&gt; defects,&lt;br&gt;
including &lt;code&gt;phi3.5:3.8b-mini-instruct-q2_K&lt;/code&gt;. &lt;strong&gt;The author has since retracted the&lt;br&gt;
phi3.5 case and the count is four.&lt;/strong&gt; The retraction came out of the control this&lt;br&gt;
site asked for: an independent conversion of phi3.5 at &lt;code&gt;q2_K&lt;/code&gt; &lt;em&gt;with no&lt;br&gt;
importance matrix&lt;/em&gt; fails identically (0/15), which is genuine capability&lt;br&gt;
collapse at that quantisation, not a bad file. The original referee had been an&lt;br&gt;
imatrix build, and that is what made phi3.5 look like an outlier. The four&lt;br&gt;
Qwen artifacts came through the same re-run stronger, not weaker — the model&lt;br&gt;
author's own no-imatrix conversions score 14/15 at &lt;code&gt;q2_K&lt;/code&gt; and 15/15 at&lt;br&gt;
&lt;code&gt;q3_K_M&lt;/code&gt;, where the library artifacts score zero. A revised preprint is going&lt;br&gt;
to arXiv.&lt;/p&gt;

&lt;p&gt;Worth carrying away, because it is what caused the error: the replacement&lt;br&gt;
referee turned out to be &lt;strong&gt;the same file under a different name.&lt;/strong&gt; The library's&lt;br&gt;
phi3.5 &lt;code&gt;q2_K&lt;/code&gt; blob is byte-identical to uploads in two separate HuggingFace&lt;br&gt;
repositories — verified here without downloading anything, since Ollama's&lt;br&gt;
registry manifest gives the layer digest and HuggingFace returns the file's&lt;br&gt;
SHA-256 in the &lt;code&gt;x-linked-etag&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ollama library  phi3.5:3.8b-mini-instruct-q2_K   54d47caa8bf3…
QuantFactory/Phi-3.5-mini-instruct.Q2_K.gguf     54d47caa8bf3…
neopolita/phi-3.5-mini-instruct_q2_k.gguf        54d47caa8bf3…
bartowski/Phi-3.5-mini-instruct-Q2_K.gguf        7425cb5fec0d…   (imatrix build)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A different repository is not evidence of a different conversion.&lt;/strong&gt; Only the&lt;br&gt;
hash settles it, and you can get the hash without transferring the file.&lt;/p&gt;

&lt;p&gt;One of its findings is a caveat on my own advice, so it goes at the top rather&lt;br&gt;
than the bottom. &lt;strong&gt;Two of the five defects produce output whose surface&lt;br&gt;
statistics sit inside the healthy range.&lt;/strong&gt; The ten-second check further down&lt;br&gt;
this page — look for a missing &lt;code&gt;def&lt;/code&gt; and &lt;code&gt;return&lt;/code&gt; — is calibrated against the&lt;br&gt;
artifact I actually had, where the output was a stray &lt;code&gt;.&lt;/code&gt; and a stray &lt;code&gt;00&lt;/code&gt;. It&lt;br&gt;
would not catch those two. Reading the output is a filter, not a test; if you&lt;br&gt;
want a test, run what comes back, which is what &lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;the toolkit&lt;br&gt;
script&lt;/a&gt; does and what the census does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;ollama pull qwen2.5-coder:3b-instruct-q3_K_M&lt;/code&gt; succeeds, &lt;code&gt;ollama run&lt;/code&gt; streams at normal speed, and nothing anywhere reports a problem. On three trivial HumanEval-style tasks it produced &lt;strong&gt;no implementation at all&lt;/strong&gt; — 0/3. The obvious conclusion is that 3B at q3 is simply too degraded to code. That conclusion is wrong, and one control disproves it: the &lt;strong&gt;official Qwen GGUF at the same q3_K_M level&lt;/strong&gt;, imported with the library model's own template so only the weights differ, scored &lt;strong&gt;3/3&lt;/strong&gt; on the same tasks. The failure is in that conversion, not in the quantisation level.&lt;/p&gt;
&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Debian 13, Ollama 0.33.3, CPU only. Ask for the simplest function in HumanEval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Please&lt;/span&gt; &lt;span class="n"&gt;provide&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;contained&lt;/span&gt; &lt;span class="n"&gt;Python&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;solves&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;following&lt;/span&gt;
&lt;span class="n"&gt;problem&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sum_to_n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt; sum_to_n is a function that sums numbers from 1 to n. &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;qwen2.5-coder:3b-instruct-q3_K_M&lt;/code&gt; answers, deterministically, with 85 characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
00











 to_n: function to_n:










&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stray &lt;code&gt;.&lt;/code&gt;, a stray &lt;code&gt;00&lt;/code&gt;, two floods of newlines, and &lt;code&gt;to_n: function to_n:&lt;/code&gt; —&lt;br&gt;
the identifier from the prompt echoed back. No &lt;code&gt;def&lt;/code&gt;. No &lt;code&gt;return&lt;/code&gt;. Nothing to run.&lt;/p&gt;

&lt;p&gt;It is not a fluke or a sampling artefact. At &lt;code&gt;temperature 0&lt;/code&gt; and &lt;code&gt;seed 42&lt;/code&gt;, two&lt;br&gt;
runs produced byte-identical output (&lt;code&gt;sha256 ec98f76dfeeda2a3…&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything around it reported success.&lt;/strong&gt; The pull completed and verified, the&lt;br&gt;
model is 1.6 GB on disk as advertised, load time and token rate look normal, and&lt;br&gt;
the HTTP API returns 200 with a populated &lt;code&gt;response&lt;/code&gt; field. The only thing wrong&lt;br&gt;
is the content.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "q3 is just too small" is the wrong conclusion
&lt;/h2&gt;

&lt;p&gt;That was the first hypothesis here, and it is the one most people will reach,&lt;br&gt;
because it is usually right. Low-bit quantisation of a small model genuinely&lt;br&gt;
does fall apart — it is a known trade-off, it is discussed everywhere, and a&lt;br&gt;
3B model at 3 bits is exactly where you would expect it.&lt;/p&gt;

&lt;p&gt;Two controls, same machine, same prompts, same &lt;code&gt;temperature 0&lt;/code&gt; / &lt;code&gt;seed 42&lt;/code&gt;,&lt;br&gt;
generated code extracted and &lt;strong&gt;actually executed&lt;/strong&gt; against assertions rather&lt;br&gt;
than eyeballed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model                                                    result
qwen2.5-coder:3b-instruct-q3_K_M   (Ollama library)      0/3
qwen2.5-coder:3b-instruct-q4_K_M   (Ollama library)      3/3
Qwen official qwen2.5-coder-3b-instruct-q3_k_m.gguf      3/3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third row is the one that settles it. &lt;strong&gt;Same model, same quantisation&lt;br&gt;
level, a different conversion — and it works.&lt;/strong&gt; Here is what it returns for the&lt;br&gt;
prompt above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sum_to_n&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt; sum_to_n is a function that sums numbers from 1 to n. &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "3B at q3 cannot code" is false on this machine. Something specific to that&lt;br&gt;
artifact is broken.&lt;/p&gt;
&lt;h3&gt;
  
  
  The control had to be chosen carefully
&lt;/h3&gt;

&lt;p&gt;The obvious control is one of the popular community re-quantisations, and it is&lt;br&gt;
the wrong one. Those repositories ship an &lt;code&gt;.imatrix&lt;/code&gt; file — they use&lt;br&gt;
importance-matrix quantisation, which is a different process. If the working&lt;br&gt;
control is an imatrix quant and the broken one is not, then a difference between&lt;br&gt;
them supports two stories at once: "this conversion is broken", and "imatrix&lt;br&gt;
matters enormously at 3B/q3". You cannot tell which you are looking at.&lt;/p&gt;

&lt;p&gt;The official Qwen GGUF repository has no &lt;code&gt;.imatrix&lt;/code&gt;, which makes it the control&lt;br&gt;
that answers only one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://huggingface.co/api/models/bartowski/Qwen2.5-Coder-3B-Instruct-GGUF &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'imatrix'&lt;/span&gt;          &lt;span class="c"&gt;# present&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://huggingface.co/api/models/Qwen/Qwen2.5-Coder-3B-Instruct-GGUF &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'imatrix'&lt;/span&gt;          &lt;span class="c"&gt;# nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing had to be held constant. A raw GGUF imported into Ollama needs a&lt;br&gt;
chat template supplied by hand, and &lt;strong&gt;a wrong template produces garbage that&lt;br&gt;
looks exactly like a broken quantisation.&lt;/strong&gt; So the template was not written from&lt;br&gt;
scratch — it was lifted from the working library model, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama show &lt;span class="nt"&gt;--modelfile&lt;/span&gt; qwen2.5-coder:3b-instruct-q4_K_M &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^#'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|^FROM .*|FROM /path/to/qwen2.5-coder-3b-instruct-q3_k_m.gguf|'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; Modelfile
ollama create qwen-official-q3 &lt;span class="nt"&gt;-f&lt;/span&gt; Modelfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, the only difference between the 0/3 model and the 3/3 model is the&lt;br&gt;
weights.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you can check, and what it costs
&lt;/h2&gt;

&lt;p&gt;The artifact is unchanged since it was first reported. All the relevant tags&lt;br&gt;
still read "1 year ago" on the model's tag page, and the manifest being served&lt;br&gt;
right now hashes to the digest that page shows:&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; https://registry.ollama.ai/v2/library/qwen2.5-coder/manifests/3b-instruct-q3_K_M &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-12&lt;/span&gt;
65ff2bc170f3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ollama list&lt;/code&gt; shows the same &lt;code&gt;65ff2bc170f3&lt;/code&gt; after pulling, so what is described&lt;br&gt;
here is what you would get today.&lt;/p&gt;

&lt;p&gt;Reproduced on &lt;strong&gt;Ollama 0.33.3&lt;/strong&gt;, which is newer than the 0.32.6 in the original&lt;br&gt;
report — upgrading is not the fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;There is no flag for this. The artifact is what it is, and the repair belongs&lt;br&gt;
upstream. What you can do on your own machine is stop using that tag:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move up one level.&lt;/strong&gt; &lt;code&gt;q4_K_M&lt;/code&gt; from the same library works and costs about&lt;br&gt;
300 MB more. If you were on &lt;code&gt;q3&lt;/code&gt; for size reasons, check whether you actually&lt;br&gt;
needed to be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or convert from a source you can name.&lt;/strong&gt; The model author's own GGUF at the&lt;br&gt;
same level works. Import it with the library model's template as above, so you&lt;br&gt;
are not trading one silent failure for another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And check before you trust it.&lt;/strong&gt; The whole test is three prompts and running&lt;br&gt;
what comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "your-tag-here",
  "prompt": "Please provide a self-contained Python script that solves the following problem in a markdown code block:\n\ndef sum_to_n(n: int) -&amp;gt; int:\n    \"\"\" sums numbers from 1 to n \"\"\"\n",
  "stream": false,
  "options": {"temperature": 0, "seed": 42}
}'&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; print(json.load(sys.stdin)["response"])'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If what comes back has no &lt;code&gt;def&lt;/code&gt; and no &lt;code&gt;return&lt;/code&gt; in it, you have this. It takes&lt;br&gt;
about ten seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a filter, not a test.&lt;/strong&gt; It catches the shape of failure I had, where&lt;br&gt;
there was nothing resembling code in the response at all. It does not catch a&lt;br&gt;
defective artifact that emits a plausible-looking function which does not work&lt;br&gt;
— and per the census in the update at the top, two of the five confirmed&lt;br&gt;
defects are exactly that. The only check that separates them is to paste the&lt;br&gt;
returned block into a file and run it against assertions you wrote yourself.&lt;/p&gt;

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

&lt;p&gt;The narrow lesson is that a quantised model is a &lt;em&gt;build artifact&lt;/em&gt;, not a&lt;br&gt;
property of the model. "Qwen2.5-Coder-3B at q3_K_M" names a recipe; the file you&lt;br&gt;
downloaded is one execution of that recipe by one party, and it can be wrong on&lt;br&gt;
its own without the recipe being wrong. When a model underperforms, the question&lt;br&gt;
"is this quantisation level too low?" and the question "is this file bad?" feel&lt;br&gt;
like the same question and are not. &lt;strong&gt;Converting it yourself, or fetching&lt;br&gt;
another party's conversion at the same level, separates them for the price of&lt;br&gt;
one download.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The wider one cost me the most time today, and it is about the order you run&lt;br&gt;
things in.&lt;/p&gt;

&lt;p&gt;The first control run reported the &lt;em&gt;working&lt;/em&gt; model as 0/3. The harness was&lt;br&gt;
broken: this model emits its language tag on the line after the code fence&lt;br&gt;
rather than on the fence line, so the extractor was handing &lt;code&gt;python&lt;/code&gt; to the&lt;br&gt;
interpreter as if it were code. Twenty minutes of a perfectly good model looked&lt;br&gt;
like a second broken one.&lt;/p&gt;

&lt;p&gt;I only caught it because the control ran &lt;strong&gt;before&lt;/strong&gt; the suspect. Had I started&lt;br&gt;
with &lt;code&gt;q3_K_M&lt;/code&gt;, I would have seen 0/3, matched it against a report that predicted&lt;br&gt;
0/3, and stopped — with a broken measurement and a conclusion that happened to&lt;br&gt;
agree with it. The error would have survived, because the evidence for it looked&lt;br&gt;
exactly like the evidence for the truth.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;run the case you expect to pass first, and treat its failure as a bug in&lt;br&gt;
your instrument until proven otherwise.&lt;/strong&gt; A test that has never been seen to&lt;br&gt;
pass has not been shown to work — it has only been shown to produce the answer&lt;br&gt;
you were hoping for. That is the same trap as&lt;br&gt;
&lt;a href="https://homelabpostmortem.com/2026/09/05/llama-cpp-stamps-a-foreign-repos-commit/" rel="noopener noreferrer"&gt;a build that confidently reports a commit hash from a repository it has never&lt;br&gt;
heard of&lt;/a&gt;:&lt;br&gt;
the thing doing the reporting is not the thing you are trying to measure, and&lt;br&gt;
when they disagree, you will believe the wrong one unless you have arranged in&lt;br&gt;
advance to tell them apart.&lt;/p&gt;

&lt;p&gt;The same ordering saved a second finding the same day. &lt;a href="https://homelabpostmortem.com/2026/09/07/vllm-accepts-a-lora-it-will-never-apply/" rel="noopener noreferrer"&gt;vLLM accepts a LoRA&lt;br&gt;
adapter it has already decided not to apply&lt;br&gt;
anywhere&lt;/a&gt;&lt;br&gt;
returns base-model output for the ignored adapter — which is also what an&lt;br&gt;
untrained adapter returns when it &lt;em&gt;is&lt;/em&gt; applied. Only running the overlapping&lt;br&gt;
allow-list first, and watching the same file change the output, made the&lt;br&gt;
identical result mean anything.&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>ai</category>
      <category>ollama</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>llama-cli reports a commit hash that is not in llama.cpp. It belongs to whatever repository you unpacked the source inside.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 05 Sep 2026 03:10:28 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-cli-reports-a-commit-hash-that-is-not-in-llamacpp-it-belongs-to-whatever-repository-you-41ak</link>
      <guid>https://dev.to/homelabpm/llama-cli-reports-a-commit-hash-that-is-not-in-llamacpp-it-belongs-to-whatever-repository-you-41ak</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;cmake/build-info.cmake&lt;/code&gt; runs &lt;code&gt;git rev-parse --short HEAD&lt;/code&gt; with &lt;code&gt;WORKING_DIRECTORY&lt;/code&gt; set to the source directory and accepts the result if the command succeeded. Git searches &lt;em&gt;upward&lt;/em&gt;, so a release tarball unpacked anywhere inside an unrelated git work tree gets that repository's HEAD stamped into the binary, &lt;code&gt;llama-config.cmake&lt;/code&gt;, and &lt;code&gt;ggml-config.cmake&lt;/code&gt;. Nothing warns: configure exits 0, and the &lt;code&gt;-dirty&lt;/code&gt; suffix never fires because the entire source tree is untracked and untracked files are not diffs. &lt;code&gt;-DLLAMA_BUILD_COMMIT=&lt;/code&gt; looks like the fix and only half is — &lt;code&gt;ggml/CMakeLists.txt&lt;/code&gt; runs its own unguarded probe and overwrites it, and &lt;code&gt;-DGGML_BUILD_COMMIT=&lt;/code&gt; is ignored.&lt;/p&gt;

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

&lt;p&gt;Built from the &lt;code&gt;b10816&lt;/code&gt; release tarball, on Debian 13, CMake 3.31.6, GCC 14.2.0:&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-cli &lt;span class="nt"&gt;--version&lt;/span&gt;
version: 0.4.0-dev &lt;span class="o"&gt;(&lt;/span&gt;build 1, commit 00621a7&lt;span class="o"&gt;)&lt;/span&gt;
built with GNU 14.2.0 &lt;span class="k"&gt;for &lt;/span&gt;Linux x86_64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a well-formed seven-character hash in the field where a well-formed&lt;br&gt;
seven-character hash belongs. It is not a llama.cpp commit:&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;gh api repos/ggml-org/llama.cpp/commits/00621a7
No commit found &lt;span class="k"&gt;for &lt;/span&gt;SHA: 00621a7 &lt;span class="o"&gt;(&lt;/span&gt;HTTP 422&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;00621a7&lt;/code&gt; is the HEAD of a repository I created two directories up, with one&lt;br&gt;
empty commit, whose commit message is "totally unrelated repo, not llama.cpp".&lt;br&gt;
The build found it, believed it, and printed it with no hedging.&lt;/p&gt;

&lt;p&gt;The tag actually being built, &lt;code&gt;b10816&lt;/code&gt;, is commit &lt;code&gt;427291b&lt;/code&gt;. That value appears&lt;br&gt;
nowhere in the build.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproducing it takes three commands and no compiler
&lt;/h2&gt;

&lt;p&gt;The whole thing is decided during &lt;code&gt;cmake&lt;/code&gt; configure. You do not have to build&lt;br&gt;
anything to see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/demo/outer &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/demo/outer
git init &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"totally unrelated repo"&lt;/span&gt;
git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; HEAD          &lt;span class="c"&gt;# -&amp;gt; 00621a7&lt;/span&gt;

curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://github.com/ggml-org/llama.cpp/archive/refs/tags/b10816.tar.gz | &lt;span class="nb"&gt;tar &lt;/span&gt;xz
cmake &lt;span class="nt"&gt;-S&lt;/span&gt; llama.cpp-b10816 &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DGGML_CUDA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;OFF &lt;span class="nt"&gt;-DLLAMA_CURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;OFF &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;grep &lt;/span&gt;LLAMA_COMMIT build/src/llama-version.h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define LLAMA_COMMIT  "00621a7"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure exits 0. There is no warning in the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not that build info is unreliable in general
&lt;/h2&gt;

&lt;p&gt;That was worth checking before writing any of this down, because "the commit&lt;br&gt;
field is junk" is a much less useful thing to know than what is actually&lt;br&gt;
happening. Three builds, same tarball where applicable, same machine, same&lt;br&gt;
CMake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;source&lt;/span&gt;                                    &lt;span class="k"&gt;LLAMA&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;   &lt;span class="k"&gt;BUILD&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;NUMBER&lt;/span&gt;
&lt;span class="k"&gt;tarball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;inside&lt;/span&gt; &lt;span class="k"&gt;an&lt;/span&gt; &lt;span class="k"&gt;unrelated&lt;/span&gt; &lt;span class="k"&gt;git&lt;/span&gt; &lt;span class="k"&gt;repo&lt;/span&gt;     &lt;span class="s2"&gt;"00621a7"&lt;/span&gt;      &lt;span class="mf"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;tarball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;inside&lt;/span&gt; &lt;span class="k"&gt;any&lt;/span&gt; &lt;span class="k"&gt;git&lt;/span&gt; &lt;span class="k"&gt;repo&lt;/span&gt;          &lt;span class="s2"&gt;"unknown"&lt;/span&gt;      &lt;span class="mf"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;real&lt;/span&gt; &lt;span class="k"&gt;git&lt;/span&gt; &lt;span class="k"&gt;clone&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;the&lt;/span&gt; &lt;span class="k"&gt;b&lt;/span&gt;&lt;span class="mf"&gt;10816&lt;/span&gt; &lt;span class="k"&gt;tag&lt;/span&gt;          &lt;span class="s2"&gt;"427291b"&lt;/span&gt;      &lt;span class="mf"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The honest case and the correct case both work. Only the middle situation&lt;br&gt;
lies, and it is the only one of the three that lies confidently.&lt;/strong&gt; With no git&lt;br&gt;
repository anywhere above it, the build says &lt;code&gt;unknown&lt;/code&gt; — which is exactly the&lt;br&gt;
right answer and is impossible to misread. With a real clone it says&lt;br&gt;
&lt;code&gt;427291b&lt;/code&gt;, which is right. The failure needs a git repository that exists, is&lt;br&gt;
readable, and has nothing to do with the source.&lt;/p&gt;

&lt;p&gt;Note the third column, because it removes the tell you would hope for. A&lt;br&gt;
legitimate shallow clone reports &lt;code&gt;BUILD_NUMBER 1&lt;/code&gt; too — &lt;code&gt;git rev-list --count&lt;br&gt;
HEAD&lt;/code&gt; on a &lt;code&gt;--depth 1&lt;/code&gt; clone is 1. So "the build number looks too small" is not&lt;br&gt;
evidence of anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nothing catches it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The dirty flag is structurally blind to this case.&lt;/strong&gt; &lt;code&gt;ggml/CMakeLists.txt&lt;/code&gt;&lt;br&gt;
checks whether the tree is modified and appends &lt;code&gt;-dirty&lt;/code&gt; if so. That check is&lt;br&gt;
the one thing in the build that could plausibly notice that the source does not&lt;br&gt;
belong to the repository being credited. It cannot:&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;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt; llama.cpp-b10816
?? llama.cpp-b10816/

&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; llama.cpp-b10816 diff-index &lt;span class="nt"&gt;--quiet&lt;/span&gt; HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 0 means clean. The source tree shares &lt;strong&gt;zero&lt;/strong&gt; files with the repository&lt;br&gt;
whose commit is about to be stamped on it, and the modification check reports&lt;br&gt;
no modifications — because untracked files are not diffs. A tree with nothing&lt;br&gt;
in common reads as pristine, so the hash goes out unqualified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wrong value does not stay in the binary.&lt;/strong&gt; It is written into the CMake&lt;br&gt;
package files that downstream projects consume after &lt;code&gt;make install&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;&lt;span class="nb"&gt;grep &lt;/span&gt;BUILD_COMMIT build/llama-config.cmake build/ggml/ggml-config.cmake
build/llama-config.cmake:set&lt;span class="o"&gt;(&lt;/span&gt;LLAMA_BUILD_COMMIT 00621a7&lt;span class="o"&gt;)&lt;/span&gt;
build/ggml/ggml-config.cmake:set&lt;span class="o"&gt;(&lt;/span&gt;GGML_BUILD_COMMIT &lt;span class="s2"&gt;"00621a7"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that later does &lt;code&gt;find_package(llama)&lt;/code&gt; inherits the claim.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;cmake/build-info.cmake&lt;/code&gt; asks git a question from inside the source directory&lt;br&gt;
and accepts any answer that came back successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="nb"&gt;execute_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    COMMAND &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GIT_EXECUTABLE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; rev-parse --short HEAD
    WORKING_DIRECTORY &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CMAKE_CURRENT_SOURCE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    OUTPUT_VARIABLE HEAD
    OUTPUT_STRIP_TRAILING_WHITESPACE
    RESULT_VARIABLE RES
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;RES EQUAL 0&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;BUILD_COMMIT &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HEAD&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;endif&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard is &lt;code&gt;RES EQUAL 0&lt;/code&gt; — &lt;em&gt;did git succeed anywhere&lt;/em&gt; — and git's search for a&lt;br&gt;
repository walks up the directory tree until it finds one or hits a mount&lt;br&gt;
point. There is no check that the repository it found contains these files, is&lt;br&gt;
named llama.cpp, or has ever heard of them. Extracting a tarball two levels&lt;br&gt;
below someone else's &lt;code&gt;.git&lt;/code&gt; is enough.&lt;/p&gt;

&lt;p&gt;This is reported upstream as&lt;br&gt;
&lt;a href="https://github.com/ggml-org/llama.cpp/issues/28397" rel="noopener noreferrer"&gt;&lt;code&gt;ggml-org/llama.cpp#28397&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
opened 2026-09-04 with a real-world case: the Arch AUR &lt;code&gt;llama.cpp-cuda&lt;/code&gt; package&lt;br&gt;
builds inside the cloned packaging repository, so two published versions&lt;br&gt;
credited the &lt;em&gt;packaging&lt;/em&gt; repo's commits. As of this writing the issue is open&lt;br&gt;
with no comments and nothing referencing it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, and the half of it that looks like the whole
&lt;/h2&gt;

&lt;p&gt;The obvious move is the documented override. &lt;code&gt;CMakeLists.txt&lt;/code&gt; guards both&lt;br&gt;
values with &lt;code&gt;if (NOT DEFINED ...)&lt;/code&gt;, so passing them on the command line wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cmake &lt;span class="nt"&gt;-S&lt;/span&gt; llama.cpp-b10816 &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DLLAMA_BUILD_COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;427291b &lt;span class="nt"&gt;-DLLAMA_BUILD_NUMBER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10816
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it and it worked:&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;grep &lt;/span&gt;LLAMA_COMMIT build/src/llama-version.h
&lt;span class="c"&gt;#define LLAMA_COMMIT  "427291b"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;BUILD_COMMIT build/llama-config.cmake
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;LLAMA_BUILD_COMMIT 427291b&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Now check the other header.&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;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;GGML_COMMIT build/ggml/src/ggml-version.h
&lt;span class="c"&gt;#define GGML_COMMIT  "00621a7"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still the foreign repository. &lt;code&gt;CMakeLists.txt&lt;/code&gt; does pass the corrected value&lt;br&gt;
down as &lt;code&gt;GGML_BUILD_COMMIT&lt;/code&gt; immediately before &lt;code&gt;add_subdirectory(ggml)&lt;/code&gt; — and&lt;br&gt;
then &lt;code&gt;ggml/CMakeLists.txt&lt;/code&gt; runs its own probe, which has no &lt;code&gt;if (NOT DEFINED)&lt;/code&gt;&lt;br&gt;
guard at all, and assigns straight over the top of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="nb"&gt;find_program&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;GIT_EXE NAMES git git.exe NO_CMAKE_FIND_ROOT_PATH&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;GIT_EXE&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;execute_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;COMMAND &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GIT_EXE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; rev-parse --short HEAD
        WORKING_DIRECTORY &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CMAKE_CURRENT_SOURCE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        OUTPUT_VARIABLE GGML_BUILD_COMMIT
        OUTPUT_STRIP_TRAILING_WHITESPACE
        ERROR_QUIET
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no command-line escape from it either. Passing the variable directly&lt;br&gt;
is silently discarded, because the &lt;code&gt;execute_process&lt;/code&gt; result is a normal&lt;br&gt;
variable that shadows the cache entry:&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;cmake &lt;span class="nt"&gt;-S&lt;/span&gt; llama.cpp-b10816 &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DGGML_BUILD_COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;427291b 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'ggml commit'&lt;/span&gt;
&lt;span class="nt"&gt;--&lt;/span&gt; ggml commit:  00621a7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CMake prints the wrong value to the terminal, as a status line, in the middle&lt;br&gt;
of a configure run nobody reads to the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works is denying the upward search.&lt;/strong&gt; Give the extracted source its own&lt;br&gt;
repository, so git stops there instead of reaching the stranger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; llama.cpp-b10816 init &lt;span class="nt"&gt;-q&lt;/span&gt;
cmake &lt;span class="nt"&gt;-S&lt;/span&gt; llama.cpp-b10816 &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DGGML_CUDA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;OFF &lt;span class="nt"&gt;-DLLAMA_CURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;OFF &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define LLAMA_COMMIT  "unknown"
#define GGML_COMMIT   "unknown"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh repository has no &lt;code&gt;HEAD&lt;/code&gt; to resolve, so both probes fail and both fall&lt;br&gt;
back to their honest default. &lt;code&gt;unknown&lt;/code&gt; is not a cosmetic loss — it is the&lt;br&gt;
correct statement about a tarball, and it is the value you would have got by&lt;br&gt;
building in a directory that was not inside anyone's repository in the first&lt;br&gt;
place. Moving the source tree out from under the foreign &lt;code&gt;.git&lt;/code&gt; does the same&lt;br&gt;
thing and is better when you control the layout.&lt;/p&gt;

&lt;p&gt;If you package llama.cpp, use both: &lt;code&gt;git init&lt;/code&gt; the extracted tree &lt;em&gt;and&lt;/em&gt; pass&lt;br&gt;
&lt;code&gt;-DLLAMA_BUILD_COMMIT&lt;/code&gt; with the real hash. The first stops ggml lying, the&lt;br&gt;
second gives llama the true value.&lt;/p&gt;

&lt;p&gt;To find out whether any of this applies to a build you already shipped, ask&lt;br&gt;
your binary and then ask upstream:&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;COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./llama-cli &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/.*commit \([0-9a-f]*\).*/\1/p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
git ls-remote https://github.com/ggml-org/llama.cpp.git | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="nv"&gt;$COMMIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"reported commit &lt;/span&gt;&lt;span class="nv"&gt;$COMMIT&lt;/span&gt;&lt;span class="s2"&gt; is not in llama.cpp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The narrow lesson is about provenance derived from ambient state. A build that&lt;br&gt;
reports &lt;em&gt;what it is&lt;/em&gt; by looking at its surroundings will report whatever the&lt;br&gt;
surroundings say, and directory nesting is not a statement of identity. The&lt;br&gt;
check that was missing is one line — does the repository git found actually&lt;br&gt;
track these files — and it is missing in both probes.&lt;/p&gt;

&lt;p&gt;The wider one is the reason this took longer than the reproduction did.&lt;br&gt;
Overriding &lt;code&gt;LLAMA_BUILD_COMMIT&lt;/code&gt; produces two pieces of evidence that the fix&lt;br&gt;
worked, in the two places you would naturally look, and a third file that is&lt;br&gt;
still wrong. If the habit is "apply the fix, check the field, move on", this&lt;br&gt;
ends with a build that is half-corrected and believed to be corrected —&lt;br&gt;
which is worse than the original, because now there is a memory of having&lt;br&gt;
handled it. The same shape as &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;a journald drop-in that is correct and still&lt;br&gt;
does nothing until you flush&lt;/a&gt;:&lt;br&gt;
&lt;strong&gt;the fix being real is not the same as the fix being complete, and only the&lt;br&gt;
second one is worth anything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And it is the second time in a week that this project's build and CLI have&lt;br&gt;
reported success in a field that was not true —&lt;br&gt;
&lt;a href="https://homelabpostmortem.com/2026/09/01/llama-cli-exits-0-when-it-cannot-read-your-file/" rel="noopener noreferrer"&gt;&lt;code&gt;llama-cli&lt;/code&gt; prints a file error and exits 0&lt;/a&gt;&lt;br&gt;
is the same failure at runtime. When you report a bug upstream, the first thing&lt;br&gt;
you are asked for is &lt;code&gt;--version&lt;/code&gt; output. It is worth knowing that the line can&lt;br&gt;
be confidently, specifically wrong.&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>cmake</category>
      <category>ai</category>
      <category>linux</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
