<?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>Pi pwm-ir-tx on kernel 6.18 sends nothing, and ir-ctl still reports success</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:11:12 +0000</pubDate>
      <link>https://dev.to/homelabpm/pi-pwm-ir-tx-on-kernel-618-sends-nothing-and-ir-ctl-still-reports-success-5gc5</link>
      <guid>https://dev.to/homelabpm/pi-pwm-ir-tx-on-kernel-618-sends-nothing-and-ir-ctl-still-reports-success-5gc5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On Raspberry Pi OS's 6.18 kernel, an IR transmitter on &lt;code&gt;dtoverlay=pwm-ir-tx&lt;/code&gt; sends nothing usable. A test code of 100 ms pulse, 100 ms space, 100 ms pulse was applied to the PWM as four state changes within 7 microseconds, all from one timer interrupt, so the LED was on for a few microseconds in total. &lt;code&gt;ir-ctl --send&lt;/code&gt; still exited 0, and it still took about 350 ms, because the LIRC core waits for the length of the signal before returning. So neither the exit code nor the timing gives it away. The cause is a 6.18 cleanup, described as switching to "an equivalent higher-level helper", that inverted the loop condition in the driver's timer callback. Switching to &lt;code&gt;dtoverlay=gpio-ir-tx&lt;/code&gt; on the same pin sent correctly timed 100 ms bursts. The toolkit's &lt;code&gt;check-ir-tx.sh&lt;/code&gt; finds the affected transmitters and can measure one. Upstream report: &lt;a href="https://github.com/raspberrypi/linux/issues/7678" rel="noopener noreferrer"&gt;raspberrypi/linux#7678&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Raspberry Pi 4B, Raspberry Pi OS (Debian 13, arm64), kernel &lt;code&gt;6.18.34+rpt-rpi-v8&lt;/code&gt;. One line added to &lt;code&gt;/boot/firmware/config.txt&lt;/code&gt;:&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;dtoverlay&lt;/span&gt;=&lt;span class="n"&gt;pwm&lt;/span&gt;-&lt;span class="n"&gt;ir&lt;/span&gt;-&lt;span class="n"&gt;tx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a reboot everything looks right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rc rc0: PWM IR Transmitter as /devices/platform/pwm-ir-transmitter/rc/rc0
rc rc0: lirc_dev: driver pwm-ir-tx registered at minor = 0, no receiver, raw IR transmitter

$ pinctrl get 18
18: a5    pd | lo // GPIO18 = PWM0_0

$ ir-ctl -d /dev/lirc0 -f
Send features /dev/lirc0:
 - Device can send raw IR
 - IR scancode encoder
 - Set carrier
 - Set duty cycle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test signal of 100 ms on, 100 ms off, 100 ms on, at 38 kHz:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'pulse 100000\nspace 100000\npulse 100000\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ir300.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;ir-ctl &lt;span class="nt"&gt;-d&lt;/span&gt; /dev/lirc0 &lt;span class="nt"&gt;--carrier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;38000 &lt;span class="nt"&gt;--send&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ir300.txt
send 1: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;elapsed_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;359
send 2: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;elapsed_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;345
send 3: &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;elapsed_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;349
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 0, about the length of the signal, nothing in &lt;code&gt;dmesg&lt;/code&gt;. The kernel's &lt;code&gt;pwm_apply&lt;/code&gt; trace event shows what the PWM was actually told to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ir-ctl-1230 [002] d.h1.  42.847753: pwm_apply: pwmchip0.0: period=26316 duty_cycle=13158 polarity=0 enabled=1 err=0
ir-ctl-1230 [002] d.h1.  42.847758: pwm_apply: pwmchip0.0: period=26316 duty_cycle=13158 polarity=0 enabled=0 err=0
ir-ctl-1230 [002] d.h1.  42.847759: pwm_apply: pwmchip0.0: period=26316 duty_cycle=13158 polarity=0 enabled=1 err=0
ir-ctl-1230 [002] d.h1.  42.847760: pwm_apply: pwmchip0.0: period=26316 duty_cycle=13158 polarity=0 enabled=0 err=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The carrier settings are right (a 26,316 ns period is 38 kHz, at 50% duty). The timing is not: the two 100 ms pulses were on for 5 µs and 1 µs, and the whole code was over in 7 µs. The &lt;code&gt;h&lt;/code&gt; in &lt;code&gt;d.h1.&lt;/code&gt; means all four were applied in hard interrupt context, from the same callback. After a reboot the same test gave the same four edges within 6 µs.&lt;/p&gt;

&lt;p&gt;The report upstream is from a Pi 3B+ on the 32-bit kernel (&lt;code&gt;6.18.50+rpt-rpi-v7&lt;/code&gt;), with an LED that stayed dark. Here it is a Pi 4B on the 64-bit kernel, so it isn't limited to one model or architecture.&lt;/p&gt;

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

&lt;p&gt;Every check you'd normally do passes. The overlay loads, &lt;code&gt;/dev/lirc0&lt;/code&gt; appears with the right features, the pin is in PWM mode, and &lt;code&gt;ir-ctl&lt;/code&gt; returns 0.&lt;/p&gt;

&lt;p&gt;Timing doesn't help either. It is tempting to think a transmission that collapses into microseconds would make &lt;code&gt;ir-ctl&lt;/code&gt; return instantly, and that measuring it would catch the problem. It doesn't: the LIRC core (&lt;code&gt;lirc_transmit()&lt;/code&gt; in &lt;code&gt;lirc_dev.c&lt;/code&gt;) sleeps until the full duration of the signal has passed before the &lt;code&gt;write&lt;/code&gt; returns, whatever the driver did. &lt;code&gt;ir-ctl&lt;/code&gt; took 345 to 359 ms for a 300 ms signal, which looks perfectly healthy.&lt;/p&gt;

&lt;p&gt;And IR is invisible. Unless you point a phone camera at the LED, the only symptom is that the TV or amplifier doesn't react, which looks like a wrong code, a weak LED, or a bad angle. People who have had a working IR blaster for years will update the kernel and start debugging their codes.&lt;/p&gt;

&lt;p&gt;One more trap if you test with long signals: a 600 ms test code (three 200 ms segments) was refused outright with &lt;code&gt;failed to send: Invalid argument&lt;/code&gt; (exit 74), because LIRC limits one transmission to 500 ms in total (&lt;code&gt;IR_MAX_DURATION&lt;/code&gt;). That's an honest error, but it sends you looking in the wrong place.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;pwm-ir-tx&lt;/code&gt; has two ways of sending. If the PWM driver can be called from atomic context, it programs each edge from an hrtimer callback; otherwise it sleeps between edges. The Pi's PWM driver is atomic, and &lt;code&gt;dmesg&lt;/code&gt; shows no "TX will not be accurate as PWM device might sleep" line, so the Pi uses the timer path.&lt;/p&gt;

&lt;p&gt;The timer callback applies one edge, schedules the next expiry, and loops if it has fallen behind, to catch up after a latency spike. Up to 6.17 the loop condition was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;        &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;get_time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hrtimer_get_expires_tv64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that is, keep going while the next expiry is already in the past. Commit &lt;a href="https://github.com/torvalds/linux/commit/44d7fb8acdf8" rel="noopener noreferrer"&gt;&lt;code&gt;44d7fb8acdf8&lt;/code&gt;&lt;/a&gt; ("media: pwm-ir-tx: Avoid direct access to hrtimer clockbase", first in v6.18) replaced it with what its message calls "an equivalent higher-level helper":&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="err"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hrtimer_expires_remaining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hrtimer_expires_remaining()&lt;/code&gt; is the expiry time minus now. It is positive when the next expiry is in the future, which is always the case right after adding the next edge's duration. So the loop that used to run only when behind now runs whenever it is on time: the first callback applies every edge, one after another, completes the transmission and stops the timer. That is exactly the trace above. The same line is in &lt;code&gt;rpi-6.18.y&lt;/code&gt; and in mainline today.&lt;/p&gt;

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

&lt;p&gt;Until the condition is put back, use the bit-banged driver on the same pin:&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;dtoverlay&lt;/span&gt;=&lt;span class="n"&gt;gpio&lt;/span&gt;-&lt;span class="n"&gt;ir&lt;/span&gt;-&lt;span class="n"&gt;tx&lt;/span&gt;,&lt;span class="n"&gt;gpio_pin&lt;/span&gt;=&lt;span class="m"&gt;18&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of &lt;code&gt;dtoverlay=pwm-ir-tx&lt;/code&gt;, and reboot. Same test signal, traced through the &lt;code&gt;gpio_value&lt;/code&gt; event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rc=0 elapsed_ms=331
burst length_ms=105.98 toggles=7604
burst length_ms=99.99  toggles=7600
gap between bursts ms=100.01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two bursts of about 100 ms, 100 ms apart, each with 7,600 toggles, which is 38 kHz for 100 ms. &lt;code&gt;gpio-ir-tx&lt;/code&gt; generates the carrier by busy-waiting on the CPU, so it costs CPU time while a code is being sent and its timing can be disturbed by other load. For an IR blaster that sends a code now and then, that is a fair trade against sending nothing.&lt;/p&gt;

&lt;p&gt;To check a machine, &lt;code&gt;check-ir-tx.sh&lt;/code&gt; lists the IR transmitters, flags &lt;code&gt;pwm-ir-tx&lt;/code&gt; on a 6.18 or later kernel, and with &lt;code&gt;--probe&lt;/code&gt; (as root) sends a 30 ms test code and measures the spread of its PWM edges in the trace. On this Pi with &lt;code&gt;pwm-ir-tx&lt;/code&gt; it reported 4 edges in 7 µs. With &lt;code&gt;gpio-ir-tx&lt;/code&gt; it passed.&lt;/p&gt;

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

&lt;p&gt;When a change is described as equivalent, check the one thing that would make it not equivalent. Here it was the direction of a comparison: "expiry is before now" and "time remaining is positive" sound like the same question and are opposites. A refactor that only swaps how a value is obtained can still flip what the condition means, and the commit message and the review both read it as a no-op.&lt;/p&gt;

&lt;p&gt;The other habit is about what counts as a test. An exit code and a duration are both claims the software makes about itself. For anything that leaves the machine, an IR code, a packet, a write to a disk, measure what actually happened: a trace event, a camera, a capture on the other end.&lt;/p&gt;

&lt;p&gt;This is the second time the Pi's 6.18 kernel has quietly taken away something that worked on 6.12: &lt;a href="https://homelabpostmortem.com/2026/08/27/iptables-legacy-modules-gone-from-pi-kernel/" rel="noopener noreferrer"&gt;legacy iptables modules stopped being built&lt;/a&gt; while the config still said they were enabled.&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>kernel</category>
      <category>homeautomation</category>
    </item>
    <item>
      <title>Rootful Podman restarts a --uidmap container once on an AppArmor host, then gives up</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sun, 11 Oct 2026 01:09:12 +0000</pubDate>
      <link>https://dev.to/homelabpm/rootful-podman-restarts-a-uidmap-container-once-on-an-apparmor-host-then-gives-up-57nj</link>
      <guid>https://dev.to/homelabpm/rootful-podman-restarts-a-uidmap-container-once-on-an-apparmor-host-then-gives-up-57nj</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On a host with AppArmor enabled, a rootful Podman container that has its own ID mapping (&lt;code&gt;--uidmap&lt;/code&gt;/&lt;code&gt;--gidmap&lt;/code&gt;) and a restart policy is restarted once at most. When it exits the second time it stays &lt;code&gt;exited&lt;/code&gt;. &lt;code&gt;podman events&lt;/code&gt; shows &lt;code&gt;died&lt;/code&gt;, &lt;code&gt;restart&lt;/code&gt;, and then nothing, and &lt;code&gt;RestartCount&lt;/code&gt; stops at 1. The process that should restart it, &lt;code&gt;podman container cleanup&lt;/code&gt;, fails with &lt;code&gt;profile "containers-default-0.62.2" specified but AppArmor is disabled on the host&lt;/code&gt;. AppArmor is enabled, and the error is logged only if the container was created with &lt;code&gt;podman --syslog&lt;/code&gt;. Reproduced on Debian 13 with Debian's podman 5.4.2. The upstream report is on Ubuntu 26.04 with 5.7.0 and 6.1.3, so it isn't Ubuntu-specific or new. The same container without the mapping, or with &lt;code&gt;--security-opt apparmor=unconfined&lt;/code&gt;, kept restarting. A Quadlet unit with &lt;code&gt;UIDMap=&lt;/code&gt; and &lt;code&gt;Restart=always&lt;/code&gt; was restarted by systemd every time. The toolkit's &lt;code&gt;check-podman-restart-userns.sh&lt;/code&gt; lists containers that are stuck or will get stuck. Upstream report: &lt;a href="https://github.com/podman-container-tools/podman/issues/29925" rel="noopener noreferrer"&gt;podman-container-tools/podman#29925&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;A disposable Debian 13.7 VM (kernel 6.12.111, AppArmor enabled: &lt;code&gt;/sys/module/apparmor/parameters/enabled&lt;/code&gt; is &lt;code&gt;Y&lt;/code&gt;), &lt;code&gt;apt install podman&lt;/code&gt;: podman 5.4.2, crun 1.21, conmon 2.1.12, containers-common 0.62.2. All as root. Three containers that exit after four seconds, each with &lt;code&gt;--restart always&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;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rtest-userns &lt;span class="nt"&gt;--restart&lt;/span&gt; always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--uidmap&lt;/span&gt; 0:200000:65536 &lt;span class="nt"&gt;--gidmap&lt;/span&gt; 0:200000:65536 alpine:3.22 &lt;span class="nb"&gt;sleep &lt;/span&gt;4
podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rtest-unconfined &lt;span class="nt"&gt;--restart&lt;/span&gt; always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--uidmap&lt;/span&gt; 0:200000:65536 &lt;span class="nt"&gt;--gidmap&lt;/span&gt; 0:200000:65536 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; &lt;span class="nv"&gt;apparmor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unconfined alpine:3.22 &lt;span class="nb"&gt;sleep &lt;/span&gt;4
podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rtest-plain &lt;span class="nt"&gt;--restart&lt;/span&gt; always alpine:3.22 &lt;span class="nb"&gt;sleep &lt;/span&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-five seconds later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rtest-userns:     exited  restarts=1  apparmor=containers-default-0.62.2
rtest-unconfined: running restarts=5  apparmor=unconfined
rtest-plain:      running restarts=5  apparmor=containers-default-0.62.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The events for the mapped container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create init start died restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and nothing after that. &lt;code&gt;podman start rtest-userns&lt;/code&gt; from a shell started it without complaint. &lt;code&gt;--restart on-failure:3&lt;/code&gt; on a mapped container whose command exits 1 stopped the same way, at &lt;code&gt;restarts=1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Created with &lt;code&gt;podman --syslog run ...&lt;/code&gt; instead, the same container left this in the journal when it stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/bin/podman[5105]: level=error msg="Cleaning up container: failed to clean up container 75c416f9...:
  profile \"containers-default-0.62.2\" specified but AppArmor is disabled on the host"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from the process &lt;code&gt;podman ... --syslog container cleanup --stopped-only &amp;lt;id&amp;gt;&lt;/code&gt;, which conmon runs when the container exits.&lt;/p&gt;

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

&lt;p&gt;Everything you'd look at says the restart policy is working. The event stream has a &lt;code&gt;restart&lt;/code&gt; event. &lt;code&gt;podman inspect&lt;/code&gt; shows the policy as &lt;code&gt;always&lt;/code&gt;. The first crash is handled, so a quick test that kills the container once and watches it come back passes.&lt;/p&gt;

&lt;p&gt;The error itself goes nowhere by default. Without &lt;code&gt;--syslog&lt;/code&gt; on the command that created the container, the cleanup process has no place to report it, so it isn't in the journal, in &lt;code&gt;podman logs&lt;/code&gt;, or in the events. When you do find it, it says AppArmor is disabled on a host where it plainly isn't, which points at the host configuration rather than at Podman.&lt;/p&gt;

&lt;p&gt;And the combination is narrow enough that most setups don't have it. Rootless Podman doesn't use AppArmor profiles. Fedora and RHEL use SELinux. Containers without their own mapping restart fine. It takes rootful Podman, an AppArmor distribution, and &lt;code&gt;--uidmap&lt;/code&gt;/&lt;code&gt;--gidmap&lt;/code&gt;, which is exactly what people reach for when they want rootful containers to run as unprivileged host IDs. Other ways of giving a container its own mapping, such as &lt;code&gt;--userns=auto&lt;/code&gt;, weren't tested here.&lt;/p&gt;

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

&lt;p&gt;The message comes from &lt;code&gt;containers/common&lt;/code&gt;'s AppArmor package. &lt;code&gt;IsEnabled()&lt;/code&gt; there returns whether &lt;code&gt;IsSupported()&lt;/code&gt; succeeds, and &lt;code&gt;IsSupported()&lt;/code&gt; (v0.62.2, the version in Debian) checks three things in order: that the process isn't rootless, that the kernel reports AppArmor as enabled, and that the &lt;code&gt;apparmor_parser&lt;/code&gt; binary can be found.&lt;/p&gt;

&lt;p&gt;The upstream report doesn't explain the failure. The obvious guess is that the cleanup process thinks it is rootless. Here it isn't. Traced with &lt;code&gt;strace -f&lt;/code&gt; on conmon as the container exited, the cleanup process ran as root, in the host's user namespace, with no Podman user-namespace variable in its environment, and it didn't re-execute itself or call &lt;code&gt;unshare&lt;/code&gt;. conmon's capabilities were the same for the mapped and the plain container (&lt;code&gt;CapEff: 000001ffffffffff&lt;/code&gt;, host user namespace). So whichever check fails, it fails for a process that looks the same as the one that successfully restarts the plain container. Which of the other two checks fails in that path wasn't pinned down here.&lt;/p&gt;

&lt;p&gt;What is clear is the shape: the restart decision happens in the cleanup process, the cleanup process fails before it restarts the container, and the failure is reported only to syslog when asked for.&lt;/p&gt;

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

&lt;p&gt;Until Podman fixes it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Let systemd do the restarting.&lt;/strong&gt; A Quadlet unit with the same mapping:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;  &lt;span class="nn"&gt;[Container]&lt;/span&gt;
  &lt;span class="py"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;docker.io/library/alpine:3.22&lt;/span&gt;
  &lt;span class="py"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sleep 4&lt;/span&gt;
  &lt;span class="py"&gt;UIDMap&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0:200000:65536&lt;/span&gt;
  &lt;span class="py"&gt;GIDMap&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0:200000:65536&lt;/span&gt;

  &lt;span class="nn"&gt;[Service]&lt;/span&gt;
  &lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
  &lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;restarted 4 times in 25 seconds, with no AppArmor error in its journal. Quadlet runs the container with &lt;code&gt;--rm&lt;/code&gt; and no &lt;code&gt;--restart&lt;/code&gt;, so each restart is a new &lt;code&gt;podman run&lt;/code&gt; from systemd, and Podman's own restart path isn't used.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--security-opt apparmor=unconfined&lt;/code&gt;&lt;/strong&gt; also avoids it, as the table shows. That removes the container's AppArmor confinement, so it is a trade, not a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create containers with &lt;code&gt;podman --syslog&lt;/code&gt;&lt;/strong&gt; if you depend on Podman's restart policy. It doesn't fix anything, but the failure is then in the journal instead of nowhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To find containers already in this state, &lt;code&gt;check-podman-restart-userns.sh&lt;/code&gt; lists every container with a restart policy that is &lt;code&gt;exited&lt;/code&gt; without having been stopped by the user (STUCK), and every running one that is rootful, mapped, confined and set to restart on an AppArmor host (AT RISK, because it will stop the same way next time). On the test VM it reported the stuck and the at-risk containers, and passed one stopped by the user, one that exited 0 under &lt;code&gt;on-failure&lt;/code&gt;, and the &lt;code&gt;unconfined&lt;/code&gt; one.&lt;/p&gt;

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

&lt;p&gt;A restart policy is only tested when the thing has stopped more than once. The common test, kill it and see it come back, exercises the first restart, which here worked. The question to ask is "what does the second exit look like", and the place to look is the restart count after a few cycles, not whether the container is up right now.&lt;/p&gt;

&lt;p&gt;The same goes for a supervisor's error reporting. When the component that does the restarting is a short-lived helper process, find out where its errors go before you need them. Here they went to syslog only on request. &lt;a href="https://homelabpostmortem.com/2026/09/18/podman-compat-api-update-resets-the-restart-policy-to-no/" rel="noopener noreferrer"&gt;Podman's compat API resetting the restart policy to &lt;code&gt;no&lt;/code&gt;&lt;/a&gt; was the same kind of failure: the container looks configured to come back, and after the next crash it doesn't.&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>containers</category>
      <category>linux</category>
      <category>debian</category>
    </item>
    <item>
      <title>llama-server's prompt cache reuses KV computed under a different LoRA scale</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Fri, 09 Oct 2026 01:54:03 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-servers-prompt-cache-reuses-kv-computed-under-a-different-lora-scale-422g</link>
      <guid>https://dev.to/homelabpm/llama-servers-prompt-cache-reuses-kv-computed-under-a-different-lora-scale-422g</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;llama-server&lt;/code&gt;'s RAM prompt cache (&lt;code&gt;--cache-ram&lt;/code&gt;, on by default) saves a slot's KV when another request takes the slot, and restores it when the same prompt comes back. The saved entry doesn't record which LoRA adapters and scales the KV was computed with. The server does check for a LoRA change, but it compares the new request with the slot's previous request, not with the entry it has just restored. So: send a prompt with an adapter at scale 0, send something else that takes the slot, send the first prompt again at scale 1, and the answer is decoded on the scale-0 KV. On b11514, the current release, that happened on 10 of 10 prompts: &lt;code&gt;cache_n&lt;/code&gt; showed the whole prompt reused, &lt;code&gt;prompt_n&lt;/code&gt; was 1, and the answer differed from the same request without the cache. Changing the scale globally with &lt;code&gt;POST /lora-adapters&lt;/code&gt; did the same. b10703 from August behaved the same way. &lt;code&gt;--cache-ram 0&lt;/code&gt;, or &lt;code&gt;cache_prompt: false&lt;/code&gt; on every request, gave the right answers. The toolkit's &lt;code&gt;check-lora-cache.sh&lt;/code&gt; tests a running server. Upstream: &lt;a href="https://github.com/ggml-org/llama.cpp/issues/30129" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#30129&lt;/a&gt;, and &lt;a href="https://github.com/ggml-org/llama.cpp/issues/26207" rel="noopener noreferrer"&gt;#26207&lt;/a&gt; for the global path.&lt;/p&gt;

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

&lt;p&gt;Debian 13 LXC, CPU only, llama.cpp b11514 from the release tarball. The models are the ones llama.cpp's own server tests use for LoRA: &lt;code&gt;stories15M_MOE-F16.gguf&lt;/code&gt; (a 15M-parameter story model) and its &lt;code&gt;moe_shakespeare15M.gguf&lt;/code&gt; adapter, both checked against the SHA-256 on Hugging Face. The server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; stories15M_MOE-F16.gguf &lt;span class="nt"&gt;--lora&lt;/span&gt; moe_shakespeare15M.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lora-init-without-apply&lt;/span&gt; &lt;span class="nt"&gt;-np&lt;/span&gt; 1 &lt;span class="nt"&gt;-c&lt;/span&gt; 4096
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten prompts, each about 116 tokens, at temperature 0, 48 tokens out. First, the reference answers from a separate server started with &lt;code&gt;--cache-ram 0&lt;/code&gt;: each prompt at scale 1 and at scale 0. The two scales gave different answers on all ten. Then, on a fresh default server, the sequence from the report, for each prompt P:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. P  with "lora": [{"id": 0, "scale": 0}]
2. Q  with "lora": [{"id": 0, "scale": 1}]     (another prompt; it takes the only slot)
3. P  with "lora": [{"id": 0, "scale": 1}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 for the first prompt:&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;scale&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cache:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;loved&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;watch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;time.\nBut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;I&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;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bears&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;I&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;want&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;keep,\nThe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;neither&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;scale&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cache:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;very&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;far&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;away.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;farmer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;his&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;family&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;who&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;very&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;far&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;away.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;farmer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;his&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fami'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;step&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&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="err"&gt;had&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;many:\nThe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;men&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;who&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;had&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;used&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sell,\nThe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;men&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;men&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;who&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;had&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;gone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;se'&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="err"&gt;timings:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cache_n=&lt;/span&gt;&lt;span class="mi"&gt;116&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;prompt_n=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole prompt came from the cache and one token was evaluated. The answer is neither the scale-1 answer nor the scale-0 one: the prompt was read under scale 0 and the continuation generated under scale 1. Across the ten prompts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default server                                    differs from the scale-1 answer 10/10
--cache-ram 0                                     0/10
step 3 sent with cache_prompt: false              0/10
step 1 also at scale 1 (KV and request match)     1/10
scale changed with POST /lora-adapters instead    10/10
b10703 (August), default                          10/10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1 in the matching-scale row was the same prompt each time the ten ran in sequence. Re-run on its own three times, it matched. The KV there was built in a different order from the reference, so a floating-point difference is the likely cause, but that wasn't proven. It is a different thing from the 10 out of 10.&lt;/p&gt;

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

&lt;p&gt;The request is honoured on its face. It names an adapter and a scale, the server accepts it, and the generated tokens are produced with that adapter applied. Only the part of the context that came from the cache was computed differently, and nothing in the response says which part that was. &lt;code&gt;cache_n&lt;/code&gt; tells you that something was reused, not under what.&lt;/p&gt;

&lt;p&gt;It needs a particular pattern to show up: the same prompt prefix sent under two different LoRA settings, with another request in between that takes the slot. That is exactly what a server that switches adapters per request does all day, with a shared system prompt and different personas or tasks per adapter. A test that sends one request per setting, or sends the two settings back to back, never sees it, because then the slot still holds the KV and the slot-level check works. With &lt;code&gt;-np 4&lt;/code&gt; and the same ten-prompt sequence, every answer was right: each prompt kept its own slot.&lt;/p&gt;

&lt;p&gt;And the output from a contaminated request is plausible. On a 15M story model the difference is easy to see. On a real model with a style or persona adapter, a request that is half one adapter and half the other looks like a slightly off answer.&lt;/p&gt;

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

&lt;p&gt;The unit the cache stores, in &lt;code&gt;tools/server/server-task.h&lt;/code&gt; at b11514:&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;struct&lt;/span&gt; &lt;span class="nc"&gt;server_prompt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;server_tokens&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;;&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;list&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;common_prompt_checkpoint&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;checkpoints&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tokens and checkpoints, nothing about adapters. When a request is assigned a slot, &lt;code&gt;get_available_slot&lt;/code&gt; saves the slot's current prompt to the cache and loads the entry with the longest matching prefix for the new request (&lt;code&gt;prompt_save&lt;/code&gt;, then &lt;code&gt;prompt_load&lt;/code&gt;). Matching is by tokens only.&lt;/p&gt;

&lt;p&gt;The LoRA check comes after that, in &lt;code&gt;launch_slot_with_task&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;task_loras&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;construct_lora_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;are_lora_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_loras&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&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 lora has changed, check to see if the cache should be cleared&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lora_should_clear_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task_loras&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task_loras&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params_base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora_adapters&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;slot.lora&lt;/code&gt; is the LoRA setting of the slot's previous request. In the sequence above that is step 2 at scale 1, the new request is also scale 1, so nothing is cleared, and the KV that &lt;code&gt;prompt_load&lt;/code&gt; just put in the slot, computed at scale 0, stays. When the prompt keeps its own slot, &lt;code&gt;slot.lora&lt;/code&gt; really is the setting its KV was computed under, and the check clears it as intended. That is why &lt;code&gt;-np 4&lt;/code&gt; was fine here.&lt;/p&gt;

&lt;p&gt;Requests with no &lt;code&gt;lora&lt;/code&gt; field, which is what you send after changing the scale globally with &lt;code&gt;POST /lora-adapters&lt;/code&gt;, take the &lt;code&gt;else&lt;/code&gt; branch, which doesn't compare anything. That is the path in #26207, and it was 10 out of 10 here as well.&lt;/p&gt;

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

&lt;p&gt;Until the cache records the adapters it was computed with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start the server with &lt;code&gt;--cache-ram 0&lt;/code&gt;&lt;/strong&gt; if it serves more than one LoRA setting. All ten answers were right. You lose the RAM cache across slots, but each slot still reuses its own prefix, and the LoRA check works there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or send &lt;code&gt;"cache_prompt": false&lt;/code&gt; on every request.&lt;/strong&gt; Step 3 sent that way came back with &lt;code&gt;cache_n=0&lt;/code&gt; and the right answer on all ten. It has to be every request, because the entries are written whatever the flag says, and any request that doesn't opt out can read one computed under another setting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Changing the scale globally through &lt;code&gt;POST /lora-adapters&lt;/code&gt; is affected in the same way and is fixed the same way.&lt;/p&gt;

&lt;p&gt;To test a running server, &lt;code&gt;check-lora-cache.sh&lt;/code&gt; runs the same three steps with a probe prompt containing a random marker, after pushing it out of every slot, and compares the answer with an uncached request. On the default server it reported contamination with one slot and with four. With &lt;code&gt;--cache-ram 0&lt;/code&gt; it reported that the cache never restored the probe, which is the fixed state.&lt;/p&gt;

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

&lt;p&gt;A cache key has to include everything that went into computing the value. Here the value is KV, and it depends on the tokens and on which adapters were applied. The key has only the tokens. The server knew about the second input, but checked it against the wrong thing: the slot's history instead of the cached entry's. The question to ask of any cache is "what else did this value depend on, and is it in the key?".&lt;/p&gt;

&lt;p&gt;The test for it is the A, B, A sequence. Compute something under setting A, make the cache evict it, ask for it again under setting B, and compare with an uncached answer under B. Back-to-back requests won't find it, because nothing has been evicted yet.&lt;/p&gt;

&lt;p&gt;The same RAM cache also has &lt;a href="https://homelabpostmortem.com/2026/09/24/llama-server-cache-ram-minus-one-is-not-no-limit/" rel="noopener noreferrer"&gt;a "no limit" setting that keeps less than the default&lt;/a&gt;. In both cases the server gave no error, and the evidence was in &lt;code&gt;cache_n&lt;/code&gt;.&lt;/p&gt;

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

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

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

</description>
      <category>llamacpp</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>lora</category>
    </item>
    <item>
      <title>Ollama picks the Gemma 4 prompt template from the model's name</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:36:39 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollama-picks-the-gemma-4-prompt-template-from-the-models-name-3m95</link>
      <guid>https://dev.to/homelabpm/ollama-picks-the-gemma-4-prompt-template-from-the-models-name-3m95</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Gemma 4 models in Ollama carry &lt;code&gt;RENDERER gemma4&lt;/code&gt;, which Ollama 0.35.1 resolves per request to &lt;code&gt;gemma4-small&lt;/code&gt; (the e2b/e4b template) or &lt;code&gt;gemma4-large&lt;/code&gt; (12b, 26b, 31b). It decides by looking for &lt;code&gt;e2b&lt;/code&gt;/&lt;code&gt;e4b&lt;/code&gt; or &lt;code&gt;12b&lt;/code&gt;/&lt;code&gt;26b&lt;/code&gt;/&lt;code&gt;31b&lt;/code&gt; in the model's name, and if there is none, by parameter count against 12 billion. The 12B counts as 11.9B. So &lt;code&gt;gemma4:12b&lt;/code&gt; itself gets the large template, but &lt;code&gt;ollama cp gemma4:12b mygemma&lt;/code&gt;, or a Modelfile &lt;code&gt;FROM gemma4:12b&lt;/code&gt; with your system prompt saved as &lt;code&gt;assistant&lt;/code&gt;, gets the small one. The difference is an empty thought block the large template adds when thinking is off. &lt;code&gt;ollama show&lt;/code&gt; prints the same thing for both. On the 12B Q4_K_M we tested, the model wrote the missing block itself: four extra generated tokens, the same answers on five of six prompts, and the same answer with different formatting on the sixth. If you want the template the model was trained with, put &lt;code&gt;RENDERER gemma4-large&lt;/code&gt; in the Modelfile. The toolkit's &lt;code&gt;check-gemma4-renderer.sh&lt;/code&gt; lists which of your models resolve to which. Upstream report: &lt;a href="https://github.com/ollama/ollama/issues/18824" rel="noopener noreferrer"&gt;ollama/ollama#18824&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Debian 13 LXC, CPU only, Ollama 0.35.1 (the current release) from the release tarball, &lt;code&gt;gemma4:12b&lt;/code&gt; from the library (8.0 GB; &lt;code&gt;ollama show&lt;/code&gt; says 11.9B parameters, &lt;code&gt;RENDERER gemma4&lt;/code&gt;). The report used a third-party GGUF and the request below: "Hello", &lt;code&gt;think: false&lt;/code&gt;, &lt;code&gt;num_predict: 1&lt;/code&gt;, read &lt;code&gt;prompt_eval_count&lt;/code&gt;. The same weights under different names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemma4:12b                                        think:false 14   think:true 17
ollama cp gemma4:12b mygemma                      think:false 10   think:true 17
FROM gemma4:12b, saved as assistant               think:false 10   think:true 17
FROM gemma4:12b, saved as assistant-12b           think:false 14   think:true 17
FROM gemma4:12b, saved as assistant:12b           think:false 14   think:true 17
FROM gemma4:12b + RENDERER gemma4-large           think:false 14   think:true 17
FROM gemma4:12b + SYSTEM + num_ctx, as helper     think:false 21   think:true 23
same Modelfile, saved as helper-12b               think:false 25   think:true 23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four tokens go missing whenever the name has no size in it, including on a plain &lt;code&gt;ollama cp&lt;/code&gt;. With thinking on, every variant gets the same prompt. The report says the official library tag is fine because its name contains &lt;code&gt;12b&lt;/code&gt;. That is true of the tag, but not of anything derived from it under another name, which is the usual thing to do with a library model once you add a system prompt.&lt;/p&gt;

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

&lt;p&gt;Nothing you can look at shows it. &lt;code&gt;ollama show assistant&lt;/code&gt; and &lt;code&gt;ollama show gemma4:12b&lt;/code&gt; print the same model block, and &lt;code&gt;ollama show --modelfile&lt;/code&gt; says &lt;code&gt;RENDERER gemma4&lt;/code&gt; for both. The resolution happens per request and isn't logged.&lt;/p&gt;

&lt;p&gt;It only matters with thinking off. A request without a &lt;code&gt;think&lt;/code&gt; field, including everything through &lt;code&gt;/v1/chat/completions&lt;/code&gt;, ran with thinking on for this model, and both templates produced the same prompt there.&lt;/p&gt;

&lt;p&gt;And the answers are nearly the same, as below, so a quick look at the output won't show a difference.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;server/renderer_resolution.go&lt;/code&gt; in 0.35.1:&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="n"&gt;resolveGemma4Renderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&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;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gemma4RendererFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ShortName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&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;renderer&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;renderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gemma4RendererFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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;ok&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;renderer&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;parameterCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseHumanParameterCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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;ModelType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&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;gemma4RendererForParameterCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parameterCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gemma4RendererSmall&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;gemma4RendererFromName&lt;/code&gt; is a substring check: &lt;code&gt;e2b&lt;/code&gt; or &lt;code&gt;e4b&lt;/code&gt; gives small, &lt;code&gt;12b&lt;/code&gt;, &lt;code&gt;26b&lt;/code&gt; or &lt;code&gt;31b&lt;/code&gt; gives large. &lt;code&gt;ShortName&lt;/code&gt; and &lt;code&gt;Name&lt;/code&gt; are the name you asked for (&lt;code&gt;n.DisplayShortest()&lt;/code&gt; and &lt;code&gt;n.String()&lt;/code&gt; in &lt;code&gt;GetModel&lt;/code&gt;), tag included, so &lt;code&gt;assistant:12b&lt;/code&gt; counts. A copy or a derived model keeps the base model's config, &lt;code&gt;RENDERER gemma4&lt;/code&gt; and &lt;code&gt;model_type&lt;/code&gt; &lt;code&gt;11.9B&lt;/code&gt; included, under the new name. With no size in the name, the count decides, and &lt;code&gt;gemma4LargeMinParameterCount&lt;/code&gt; is exactly 12,000,000,000.&lt;/p&gt;

&lt;p&gt;The two renderers are the same code with one flag:&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;case&lt;/span&gt; &lt;span class="s"&gt;"gemma4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"gemma4-small"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;useImgTags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RenderImgTags&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"gemma4-large"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;useImgTags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RenderImgTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emptyBlockOnNothink&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;emptyBlockOnNothink&lt;/code&gt;, the generation prompt ends &lt;code&gt;&amp;lt;|turn&amp;gt;model\n&amp;lt;|channel&amp;gt;thought\n&amp;lt;channel|&amp;gt;&lt;/code&gt; when thinking is off: an empty thought block. The report compared 50 rendered prompts with Google's chat template for the 12B and found the large renderer identical on all 50 and the small one missing this block on all 50. Without it, the prompt stops at &lt;code&gt;&amp;lt;|turn&amp;gt;model\n&lt;/code&gt;. Those are the four tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to the answers
&lt;/h2&gt;

&lt;p&gt;The report says it did not measure the effect on outputs. Six prompts, &lt;code&gt;think: false&lt;/code&gt;, temperature 0, &lt;code&gt;assistant&lt;/code&gt; (small) against &lt;code&gt;assistant-12b&lt;/code&gt; (large):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17 * 23?                  small eval   8  '391'                 large eval   4  '391'
capital of Australia?     small eval   7  'Canberra'            large eval   3  'Canberra'
bat and ball              small eval 207  '...**$0.05**...'     large eval 205  '...**$0.05**...'
one sentence on autumn    small eval  25  identical             large eval  21
three primes over 50      small eval  34  identical             large eval  30
'good morning' in French  small eval  33  identical             large eval  29
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five answers are identical, with four more generated tokens on the small side. The bat-and-ball answer is the same answer with a bolded heading on one side and not the other. To see where the four tokens go, the two prompts, written out by hand as the renderers build them, were sent straight to the llama-server process Ollama runs for the model:&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;small&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;raw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tokens:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;lt;|channel&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'thought'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'\n'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;lt;channel|&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;large&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;raw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tokens:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&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 model writes the empty block itself, and Ollama's Gemma 4 parser removes it before the reply goes out. With a JSON schema in &lt;code&gt;format&lt;/code&gt; and thinking off, the model can't write the block, since the grammar applies from the first token. The answers still had the same values (&lt;code&gt;391&lt;/code&gt;, &lt;code&gt;Canberra&lt;/code&gt;, &lt;code&gt;0.05&lt;/code&gt;, &lt;code&gt;53, 59, and 61&lt;/code&gt;, &lt;code&gt;Bonjour&lt;/code&gt;), with different whitespace, and the bat-and-ball one came back as &lt;code&gt;"The ball costs $0.05."&lt;/code&gt; instead of &lt;code&gt;"0.05"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So on this model and these prompts, the cost is a few tokens and some formatting drift. That is one quantisation of one size on six prompts, not a quality evaluation. A smaller quantisation, a long multi-turn chat or a different task may react differently to a prompt the model wasn't trained on. The point is that whether it gets that prompt depends on what you named it.&lt;/p&gt;

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

&lt;p&gt;Name the renderer in the Modelfile. An explicit &lt;code&gt;gemma4-large&lt;/code&gt; is used as is, whatever the model is called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM helper
RENDERER gemma4-large
PARSER gemma4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-created that way, &lt;code&gt;helper&lt;/code&gt; (system prompt and &lt;code&gt;num_ctx 8192&lt;/code&gt; kept) went from 21 to 25 prompt tokens, the same as &lt;code&gt;helper-12b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Putting &lt;code&gt;12b&lt;/code&gt; in the name also works, but it is a substring match: a name containing both &lt;code&gt;e4b&lt;/code&gt; and &lt;code&gt;12b&lt;/code&gt; resolves to small, because &lt;code&gt;e2b&lt;/code&gt;/&lt;code&gt;e4b&lt;/code&gt; is checked first. Setting the renderer doesn't depend on that.&lt;/p&gt;

&lt;p&gt;To find affected models, &lt;code&gt;check-gemma4-renderer.sh&lt;/code&gt; reads &lt;code&gt;/api/tags&lt;/code&gt; and &lt;code&gt;/api/show&lt;/code&gt; and applies the same rules without loading anything. On the eight models above it marked &lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;helper&lt;/code&gt; and &lt;code&gt;mygemma&lt;/code&gt; as resolving to the small template and the others as large, which matches the measured token counts.&lt;/p&gt;

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

&lt;p&gt;When a tool picks behaviour from a name, a copy is a different thing. &lt;code&gt;ollama cp&lt;/code&gt; and &lt;code&gt;FROM&lt;/code&gt; look like they produce the same model under another label, and for the weights and the config they do. But anything resolved from the label at request time is decided again for the copy, and the closer the model sits to a threshold, here 11.9B against 12B, the more likely that decision changes. The way to see it is to measure something the decision affects, here the prompt token count, on the original and the copy.&lt;/p&gt;

&lt;p&gt;The template also decides &lt;a href="https://homelabpostmortem.com/2026/10/05/ollama-thinking-model-skips-your-json-schema-when-it-answers-directly/" rel="noopener noreferrer"&gt;what a Gemma 4 model does with your JSON schema when thinking is on&lt;/a&gt;, which is the other side of the same &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt; block.&lt;/p&gt;

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

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

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

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>gemma</category>
    </item>
    <item>
      <title>llama-server's logprobs are placeholders when speculative decoding is on</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:37:19 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-servers-logprobs-are-placeholders-when-speculative-decoding-is-on-484h</link>
      <guid>https://dev.to/homelabpm/llama-servers-logprobs-are-placeholders-when-speculative-decoding-is-on-484h</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: When &lt;code&gt;llama-server&lt;/code&gt; runs with speculative decoding (a draft model with &lt;code&gt;-md&lt;/code&gt;, MTP, or one of the n-gram types), every token that comes out of the speculative loop is sent with &lt;code&gt;logprob: 0.0&lt;/code&gt; and an empty &lt;code&gt;top_logprobs&lt;/code&gt; list. The code sets the probability to 1.0 with the comment &lt;code&gt;// set later&lt;/code&gt; and never sets it. With a draft model that is every token after the first. On b11430, the current release, the mean logprob of 64-token samples at temperature 1 was -0.48 without speculation and -0.0011 with it. The generated text looked normal, and nothing in the response or the log says the numbers are fill-ins. There is no per-request switch: the request fields that used to tune speculation are compiled out and ignored. Send logprob requests to an instance started without speculation. The tokens themselves are fine: on a small fixture, the server's speculative output had the same next-token distribution as plain sampling. The &lt;code&gt;llama-speculative&lt;/code&gt; example program did not. The toolkit's &lt;code&gt;check-spec-logprobs.sh&lt;/code&gt; checks a running server. Upstream: &lt;a href="https://github.com/ggml-org/llama.cpp/issues/27972" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#27972&lt;/a&gt; and &lt;a href="https://github.com/ggml-org/llama.cpp/issues/29975" rel="noopener noreferrer"&gt;#29975&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Debian 13 LXC, CPU only, llama.cpp b11430 from the release tarball (the current release, SHA-256 matching the release digest), &lt;code&gt;gemma-3-1b-it-Q4_K_M&lt;/code&gt;. A draft model normally has to be smaller than the target to save time; using the same file as its own draft is enough to put every token through the speculative path, and needs no extra download. The request is the one from the report:&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:8080/v1/chat/completions &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "messages": [{"role": "user", "content": "Explain how a CPU branch predictor works."}],
  "max_tokens": 24, "temperature": 1.0, "seed": 1, "logprobs": true, "top_logprobs": 3}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without speculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'Okay'    logprob=-0.00813608   top_logprobs=3
','       logprob=0             top_logprobs=3
' let'    logprob=-2.38419e-07  top_logprobs=3
'’'       logprob=-2.60903      top_logprobs=3
's'       logprob=0             top_logprobs=3
' break'  logprob=-0.0479959    top_logprobs=3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same server started with &lt;code&gt;-md gemma-3-1b-it-Q4_K_M.gguf --spec-type draft-simple&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;'Okay'    logprob=-0.00813608   top_logprobs=3
','       logprob=0             top_logprobs=0
' let'    logprob=0             top_logprobs=0
"'"       logprob=0             top_logprobs=0
's'       logprob=0             top_logprobs=0
' break'  logprob=0             top_logprobs=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 23 tokens after the first came back as 0 with no alternatives, at temperature 0 and at 1, on &lt;code&gt;/v1/chat/completions&lt;/code&gt; and on the native &lt;code&gt;/completion&lt;/code&gt; with &lt;code&gt;n_probs&lt;/code&gt;. With &lt;code&gt;post_sampling_probs: true&lt;/code&gt; the native endpoint reported &lt;code&gt;prob: 1.0&lt;/code&gt; and an empty &lt;code&gt;top_probs&lt;/code&gt; for every token after the first; the unspeculated run gave &lt;code&gt;What&lt;/code&gt; 0.048, for example.&lt;/p&gt;

&lt;p&gt;The effect on anything that aggregates logprobs is not subtle. Five seeds, 64 tokens each, "Write a short story about a lighthouse keeper." at temperature 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no speculation    -0.5158 -0.6775 -0.3832 -0.3360 -0.4898   mean -0.4805
draft model       -0.0055 -0.0    -0.0    -0.0    -0.0      mean -0.0011
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report saw the same shape on a Vulkan iGPU with Gemma 4 26B and an MTP drafter: a mean of -0.00107 with MTP against -0.38244 without, measured while trying to find out whether MTP changes the output. b10703, from August and before #27694, gave the same placeholders on the same probes.&lt;/p&gt;

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

&lt;p&gt;The fields are all there. &lt;code&gt;logprob&lt;/code&gt; is a number, &lt;code&gt;top_logprobs&lt;/code&gt; is a list, and the response validates against any schema you have for it.&lt;/p&gt;

&lt;p&gt;A logprob of exactly 0 is not suspicious either. In the run without speculation above, &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;s&lt;/code&gt; came back as 0 with three alternatives: a 1B model is that certain of them. So you can't spot the placeholders by looking for zeros. The giveaway is the empty &lt;code&gt;top_logprobs&lt;/code&gt; on a request that asked for three.&lt;/p&gt;

&lt;p&gt;The text looks normal, so nothing seems wrong to anyone reading the output. And the server's own description doesn't help: &lt;code&gt;/props&lt;/code&gt; reported &lt;code&gt;"speculative.types": "none"&lt;/code&gt; in its default generation settings on servers started with &lt;code&gt;-md&lt;/code&gt; and with &lt;code&gt;--spec-type ngram-mod&lt;/code&gt;. &lt;code&gt;/slots&lt;/code&gt; is the place that is right: &lt;code&gt;"speculative": true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With the n-gram types it is patchier still. Those only draft when the recent text repeats, so a single response mixes real values and placeholders. On a prompt asking the model to copy a repeated sentence, &lt;code&gt;--spec-type ngram-mod&lt;/code&gt; with its default settings left 27 of the 30 tokens after the first correct and 3 as placeholders. With a shorter match length it was 26 placeholders out of 30.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;tools/server/server-context.cpp&lt;/code&gt; at b11430 has two places that emit a generated token. The normal one:&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prob&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// TODO: set it here instead of doing inside populate_token_probs&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_probs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;populate_token_probs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_sampling_probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params_base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;special&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok_idx&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;and the one after the target has verified a draft, which loops over the accepted tokens plus the one the target sampled itself:&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;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;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;completion_token_output&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ids&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_to_send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;common_token_to_piece&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ctx_tgt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accept_special_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prob&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// set later&lt;/span&gt;

    &lt;span class="c1"&gt;// TODO: set result.probs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing sets it later. &lt;code&gt;populate_token_probs&lt;/code&gt; is called in one place, the normal path. The first token of a response always comes from the normal path, because there is nothing to draft from yet, which is why it alone has real values. After that, with a draft model, every step goes through the speculative loop. That includes steps where the target rejected the whole draft: on a two-token native request the draft got 0 of 3 accepted, and the second token, sampled by the target itself, still came back empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does speculation change the tokens too?
&lt;/h2&gt;

&lt;p&gt;If the logprobs are wrong, the obvious next question is whether the output is. Speculative decoding is meant to be lossless: the target's verification should leave the distribution of sampled tokens exactly as if there were no draft. &lt;a href="https://github.com/ggml-org/llama.cpp/pull/27694" rel="noopener noreferrer"&gt;#27694&lt;/a&gt;, merged on 2026-10-02 and in b11430, made the server verify draft-simple and MTP drafts by rejection sampling at temperature above 0, and its description says the output distribution is preserved exactly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ggml-org/llama.cpp/issues/29975" rel="noopener noreferrer"&gt;#29975&lt;/a&gt; reports that the &lt;code&gt;llama-speculative&lt;/code&gt; example program does not preserve it, and ships a fixture to show it: two 24 KB GGUFs with an eight-letter vocabulary, a target and a noised copy as draft. After the prompt &lt;code&gt;bcdbc&lt;/code&gt; and a first generated &lt;code&gt;f&lt;/code&gt;, the target's own top-3 distribution for the next token is &lt;code&gt;e&lt;/code&gt; 0.345, &lt;code&gt;g&lt;/code&gt; 0.452, &lt;code&gt;h&lt;/code&gt; 0.203. The archive's SHA-256 matched the one in the issue. Built from source at b11430 and run over seeds 1 to 1000 with the report's script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seeds 1..1000; runs with first token 'f' (5): 820
  next token 4 ('e'): 1.000   target: 0.345
  next token 6 ('g'): 0.000   target: 0.452
  next token 7 ('h'): 0.000   target: 0.203
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report doesn't test the server. The same fixture through &lt;code&gt;llama-server&lt;/code&gt; from the same build, same sampler settings (top-k 3, temperature 1), 1000 seeds, with and without &lt;code&gt;-md draft.gguf&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;no speculation    first token 'f': 840   next e 0.330  g 0.452  h 0.218
draft model       first token 'f': 840   next e 0.330  g 0.452  h 0.218   (2470 drafted, 884 accepted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both match the target distribution within sampling error (one standard deviation is about 0.016 at 840 samples). So the server's speculative path does what #27694 says: it changes which tokens it reports probabilities for, but not which tokens it picks. The example program, a natural place to look when learning how speculative decoding works or benchmarking it, always picks &lt;code&gt;e&lt;/code&gt;. The report points at two lines: the example takes the draft's top candidate instead of the one it sampled, and on rejection it subtracts the two distributions position by position after sorting both, which pairs up different tokens when top-k leaves different sets. Those reasons are the report's; only the output above was measured here.&lt;/p&gt;

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

&lt;p&gt;There is no way to turn speculation off for one request. The server used to accept &lt;code&gt;speculative.n_max&lt;/code&gt; and related fields per request; on b11430 they are inside an &lt;code&gt;#if 0&lt;/code&gt; block in &lt;code&gt;tools/server/server-schema.cpp&lt;/code&gt; ("we disable speculative parameter adjustments for now"), and unknown fields are ignored. Sending &lt;code&gt;"speculative.n_max": 0&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt; changed nothing: the same 17 tokens were drafted and the same 23 placeholders came back.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send requests that need logprobs to a server started without &lt;code&gt;-md&lt;/code&gt; / &lt;code&gt;--spec-type&lt;/code&gt;.&lt;/strong&gt; The unspeculated runs above returned real values on every token. If you want speculation for everything else, that means a second instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;/slots&lt;/code&gt;, not &lt;code&gt;/props&lt;/code&gt;, to see whether a server speculates.&lt;/strong&gt; &lt;code&gt;curl -s localhost:8080/slots | jq '.[].speculative'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat an empty &lt;code&gt;top_logprobs&lt;/code&gt; on a request that asked for alternatives as a missing value, not a certain one.&lt;/strong&gt; If you can't change the server, at least don't average over those tokens.&lt;/li&gt;
&lt;li&gt;If you benchmark distributions, use &lt;code&gt;llama-server&lt;/code&gt; rather than &lt;code&gt;llama-speculative&lt;/code&gt; until #29975 is fixed.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;When a measurement comes back too good, check the instrument before the thing you are measuring. The reporter's numbers said MTP made a model almost perfectly certain at temperature 1, which is physically implausible, and that is what made them look at the logprobs instead of the model. A cheap way to do that is to run the same measurement under a condition you know must differ, here with and without the draft, and see whether the instrument can tell them apart. Here it did, and the whole difference was the instrument's.&lt;/p&gt;

&lt;p&gt;The second check is the one people skip: when an optimisation is meant to be invisible, test that it is, with something small enough to count. Two 24 KB models and 1000 seeds were enough to show that the server's speculation leaves the distribution alone and the example's doesn't.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-spec-logprobs.sh&lt;/code&gt; reads &lt;code&gt;/slots&lt;/code&gt;, sends one logprob request, and reports whether the tokens after the first came back with alternatives.&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>llamacpp</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ollama skips your JSON schema when a thinking model answers without thinking</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 05 Oct 2026 02:34:15 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollama-skips-your-json-schema-when-a-thinking-model-answers-without-thinking-2916</link>
      <guid>https://dev.to/homelabpm/ollama-skips-your-json-schema-when-a-thinking-model-answers-without-thinking-2916</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Since 0.34.4, Ollama applies a &lt;code&gt;format&lt;/code&gt; schema on a thinking model as a single grammar: anything until the thinking block closes, then the schema. That grammar also allows the response to end before the closing, and it treats everything up to the closing as thinking. So when a model like Gemma 4 decides not to think and answers straight away, its answer is "text before the closing" and nothing constrains it. On &lt;code&gt;gemma4:e2b&lt;/code&gt; with &lt;code&gt;think: true&lt;/code&gt; and a one-field schema, every answer that skipped thinking came back as bare text (&lt;code&gt;391&lt;/code&gt;, &lt;code&gt;Paris&lt;/code&gt;) with HTTP 200 and nothing in the log. Every answer that thought first matched the schema. With &lt;code&gt;think: false&lt;/code&gt; the reply had the right shape every time. The open fix, &lt;a href="https://github.com/ollama/ollama/pull/18783" rel="noopener noreferrer"&gt;ollama/ollama#18783&lt;/a&gt;, made all 16 replayed requests match the schema, though not by constraining the direct answer: the model thought first every time instead. Until a release includes it, validate &lt;code&gt;format&lt;/code&gt; replies in the client. The toolkit's &lt;code&gt;check-ollama-format-think.sh&lt;/code&gt; tells you whether a model on your server does this. Upstream report: &lt;a href="https://github.com/ollama/ollama/issues/18774" rel="noopener noreferrer"&gt;ollama/ollama#18774&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Debian 13 LXC, CPU only, Ollama 0.35.1 (the current release) from the release tarball, &lt;code&gt;gemma4:e2b&lt;/code&gt; from the library. The report used &lt;code&gt;gemma4:e4b&lt;/code&gt;; the smaller model is what a CPU-only LXC runs at a usable speed. The schema asks for one string field:&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;"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;"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;"answer"&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;"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;"answer"&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 report's request on &lt;code&gt;/api/chat&lt;/code&gt; at temperature 0, with seeds 1 and 2, then with &lt;code&gt;think: false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"eval_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"thinking_chars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"391"&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;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"eval_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;248&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"thinking_chars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;607&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;"{&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;391&lt;/span&gt;&lt;span class="se"&gt;\"\n&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;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;span class="nl"&gt;"eval_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"thinking_chars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"{ &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"\n&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the bug. &lt;code&gt;391&lt;/code&gt; parses as JSON, but it is a number, not an object with an &lt;code&gt;answer&lt;/code&gt; field. The request returned HTTP 200 with &lt;code&gt;done_reason: "stop"&lt;/code&gt; after four tokens, so it didn't run out of tokens. The second line is the same request with a different seed: the model thought first, and the reply has the right shape. &lt;code&gt;/api/generate&lt;/code&gt; gave the same results.&lt;/p&gt;

&lt;p&gt;To see how often it happens, three prompts went out eight times each at the default temperature, seeds 0 to 7, on &lt;code&gt;/api/chat&lt;/code&gt;:&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="s2"&gt;"What is 17 * 23? Answer with the number."&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;thought&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;direct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"391"&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"What is the capital of France? One word."&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;thought&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;direct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Paris"&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"Is 91 prime? Answer yes or no."&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="err"&gt;thought&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;direct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How often the model skips thinking depends on the question, but the result once it does is the same: 16 of 16 replies that thought first matched the schema, and 8 of 8 that skipped thinking didn't. In the report's &lt;code&gt;think: false&lt;/code&gt; control, three requests on each endpoint at temperature 0, every reply had the right shape.&lt;/p&gt;

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

&lt;p&gt;Whether you see it depends on the question. A test prompt that makes the model reason, which is what most people reach for when trying out a thinking model, never shows it. The prime question above went through thinking all eight times. The questions that trigger it are short and easy, which is also what most structured-output calls look like: classify this, extract that, yes or no.&lt;/p&gt;

&lt;p&gt;Nothing reports it either. The status is 200, &lt;code&gt;done_reason&lt;/code&gt; is &lt;code&gt;stop&lt;/code&gt;, and the server log has no line about the format. A reply that is only a word or a number can even look like a parsing problem on the client side.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;think: false&lt;/code&gt; working makes it look like &lt;code&gt;think: true&lt;/code&gt; is just unsupported with &lt;code&gt;format&lt;/code&gt;. It isn't: with thinking enabled the schema is enforced, but only on the part of the reply after the thinking.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/ollama/ollama/pull/18479" rel="noopener noreferrer"&gt;#18479&lt;/a&gt; ("apply structured outputs in a single pass on thinking models", merged 2026-09-22, first released in 0.34.4) made the format and the thinking one grammar. In &lt;code&gt;llm/llama_server.go&lt;/code&gt; in 0.35.1, when the model's parser reports a thinking-close string and the request has a format, the schema is converted to a grammar and wrapped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ThinkingClose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lsReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Grammar&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;lsReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSchema&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="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;lsReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Grammar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;thinkingGrammar&lt;/span&gt;&lt;span class="p"&gt;(&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;ThinkingClose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lsReq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Grammar&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 docstring of &lt;code&gt;thinkingGrammar&lt;/code&gt; in &lt;code&gt;llm/gbnf.go&lt;/code&gt; says what that wrapper allows:&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="c"&gt;// thinkingGrammar returns a grammar that leaves the text before any of the&lt;/span&gt;
&lt;span class="c"&gt;// closings unconstrained, then constrains what follows the first complete&lt;/span&gt;
&lt;span class="c"&gt;// closing to the root of the format grammar. The response may end before any&lt;/span&gt;
&lt;span class="c"&gt;// closing.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemma 4 opens thinking with &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt; and closes it with &lt;code&gt;&amp;lt;channel|&amp;gt;&lt;/code&gt;, and its parser reports &lt;code&gt;&amp;lt;channel|&amp;gt;&lt;/code&gt; as the closing whenever thinking is enabled. The grammar assumes the response starts inside the thinking block. When the model starts with &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt;, that holds: everything up to &lt;code&gt;&amp;lt;channel|&amp;gt;&lt;/code&gt; is free, and the schema applies after it. When the model skips thinking and writes &lt;code&gt;391&lt;/code&gt; followed by end of sequence, the grammar sees text before any closing, followed by an end the grammar allows. Nothing in it says the answer itself has to match the schema.&lt;/p&gt;

&lt;p&gt;The #18479 description acknowledges the limitation: "A model that answers without thinking stays unconstrained."&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;think: false&lt;/code&gt; the parser reports no closing, so the schema grammar is applied on its own from the first token, which is why that control always has the right shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the open fix does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ollama/ollama/pull/18783" rel="noopener noreferrer"&gt;#18783&lt;/a&gt; adds the opening string. For a model whose response can begin in content, the grammar becomes "either the schema, or &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt;, then free thinking, then a required &lt;code&gt;&amp;lt;channel|&amp;gt;&lt;/code&gt;, then the schema". The thinking branch can no longer end before the closing.&lt;/p&gt;

&lt;p&gt;The PR head (&lt;code&gt;4121ed65&lt;/code&gt;) and its merge base (&lt;code&gt;42e911bc&lt;/code&gt;) were built in the same LXC, and each build got the eight prompt and seed pairs that had answered directly in the run above, on both endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         0.35.1      merge base    PR #18783
answered directly        15 of 16    15 of 16      0 of 16
  ... schema violated    15          15            -
thought first            1           1             16
  ... schema matched     1           1             16
eval_count, direct       2 to 4      2 to 4        -
eval_count, thought      155         155           112 to 267
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one request that thought on 0.35.1 and the merge base (17 × 23, seed 1, &lt;code&gt;/api/chat&lt;/code&gt;) had answered directly in the earlier run; at the default temperature the same seed doesn't always take the same branch. The merge base behaved exactly like the release.&lt;/p&gt;

&lt;p&gt;On the PR build every request matched the schema. None of them was a constrained direct answer, though. The grammar no longer allows &lt;code&gt;Paris&lt;/code&gt; as a first token, and of the two things it does allow, the start of the JSON object or &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt;, the model picked &lt;code&gt;&amp;lt;|channel&amp;gt;&lt;/code&gt; every time and thought for 260 to 634 characters. A capital-of-France call that took 2 tokens now takes about 113. That is a fair trade for getting the shape you asked for, but on a CPU box it is roughly fifty times the tokens for the questions that used to be the cheapest.&lt;/p&gt;

&lt;p&gt;The fix also does nothing for what goes inside the field. Across the builds, the 17 × 23 answers that went through thinking came back as &lt;code&gt;"$391"&lt;/code&gt;, &lt;code&gt;"&amp;gt;391"&lt;/code&gt;, &lt;code&gt;"))391"&lt;/code&gt; and &lt;code&gt;")), "&lt;/code&gt;. That is this small model under a grammar, not the bug, but it means a reply that matches the schema still needs checking.&lt;/p&gt;

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

&lt;p&gt;Until a release includes #18783:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validate the reply against the schema in the client.&lt;/strong&gt; A reply that fails validation and has an empty &lt;code&gt;thinking&lt;/code&gt; field is this case. On 0.35.1 every reply that thought first had the right shape, so a retry that lands in thinking will too; on prompts the model always answers directly, a retry won't help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send &lt;code&gt;think: false&lt;/code&gt; for calls where you need the shape and don't need the thinking.&lt;/strong&gt; It had the right shape every time here. On &lt;code&gt;gemma4:e2b&lt;/code&gt; the content was poor (&lt;code&gt;")"&lt;/code&gt;, and in the report &lt;code&gt;"\\text{391}"&lt;/code&gt;), so check whether your model's answers survive it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, don't assume that &lt;code&gt;format&lt;/code&gt; guarantees the shape just because the request succeeded. With thinking enabled, it only covers the part of the reply after the thinking.&lt;/p&gt;

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

&lt;p&gt;When a constraint is applied to "the part after X", find out what happens when X never appears. Here the constraint was written for responses that begin with thinking, and a response with no thinking at all fell through it. The test is the input that skips the step: a question easy enough that the model answers it straight away.&lt;/p&gt;

&lt;p&gt;It is the second structured-output gap in Ollama 0.35 here in a week. On one of its two chat paths, &lt;a href="https://homelabpostmortem.com/2026/10/03/ollama-native-chat-path-sorts-your-json-schema-keys/" rel="noopener noreferrer"&gt;your schema's keys are re-sorted alphabetically&lt;/a&gt; before the grammar sees them. Both times the reply was HTTP 200 and nothing in the log said anything was wrong.&lt;/p&gt;

&lt;p&gt;Which template a Gemma 4 model gets in the first place depends on &lt;a href="https://homelabpostmortem.com/2026/10/07/ollama-picks-the-gemma-4-template-from-the-model-name/" rel="noopener noreferrer"&gt;what the model is called&lt;/a&gt;: a copy of &lt;code&gt;gemma4:12b&lt;/code&gt; under a name without &lt;code&gt;12b&lt;/code&gt; is prompted with the e2b/e4b template.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-ollama-format-think.sh&lt;/code&gt; sends a few short prompts to a model with &lt;code&gt;think: true&lt;/code&gt; and a schema, and reports how many replies skipped thinking and how many of those broke the schema.&lt;/p&gt;

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

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

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

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>json</category>
    </item>
    <item>
      <title>vLLM's weight cache can serve another checkpoint's weights when the tensor layout matches</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 03 Oct 2026 07:45:07 +0000</pubDate>
      <link>https://dev.to/homelabpm/vllms-weight-cache-can-serve-another-checkpoints-weights-when-the-tensor-layout-matches-2a62</link>
      <guid>https://dev.to/homelabpm/vllms-weight-cache-can-serve-another-checkpoints-weights-when-the-tensor-layout-matches-2a62</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: vLLM 0.30.0 can keep a model's weights in a long-running daemon so that engines restart without reloading them (&lt;code&gt;load_format="ipc_cache"&lt;/code&gt;). Before an engine uses those weights, both sides compare a fingerprint, and the docs describe the checkpoint part of it as "checkpoint content". In the code it is a hash of each shard's file name and safetensors header: tensor names, shapes, dtypes and offsets, but no tensor values. Two checkpoints with the same layout, such as a base model and a fine-tune of it, or two training steps, get the same fingerprint. On an RTX 2070, a daemon holding Qwen3-0.6B served an engine started on a copy with &lt;code&gt;model.norm.weight&lt;/code&gt; zeroed. On its own that copy answers &lt;code&gt;'!!!!!!!!!!!!'&lt;/code&gt;. With the daemon running, it answered &lt;code&gt;' Paris. The capital of Italy is Rome. The capital of'&lt;/code&gt;, exactly like the original, logged &lt;code&gt;Mapped 339 tensors from the weight cache daemon&lt;/code&gt;, and warned about nothing. Upstream report: &lt;a href="https://github.com/vllm-project/vllm/issues/59647" rel="noopener noreferrer"&gt;vllm-project/vllm#59647&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;RTX 2070 (8 GB) on Windows 11, WSL2 Ubuntu, driver 591.86, vLLM 0.30.0 with torch 2.13.0+cu130, everything in fp16 since the card has no bf16. Two checkpoints, following the report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf download Qwen/Qwen3-0.6B &lt;span class="nt"&gt;--local-dir&lt;/span&gt; q
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; q q_mod
&lt;span class="c"&gt;# zero the 2,048 bytes of model.norm.weight in q_mod/model.safetensors, header untouched&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The files differ, and loaded normally each behaves like itself. Greedy decoding, 12 tokens, "The capital of France is":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight q"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="n"&gt;load_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auto&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Paris.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;capital&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Italy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Rome.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;capital&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="o"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;q_mod&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;load_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auto&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;'!!!!!!!!!!!!'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the daemon, preloaded with &lt;code&gt;q&lt;/code&gt;. The report used &lt;code&gt;vllm preload&lt;/code&gt;, which isn't in the 0.30.0 CLI. The module the docs point to is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; vllm.model_executor.model_loader.weight_cache.daemon &lt;span class="nt"&gt;--model&lt;/span&gt; q &lt;span class="nt"&gt;--dtype&lt;/span&gt; float16
&lt;span class="c"&gt;# ===== Weight cache daemon READY: node 0/1 serving 1 local rank(s) in the default socket dir =====&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and an engine on &lt;code&gt;q_mod&lt;/code&gt; with the cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q_mod  load_format=ipc_cache   ' Paris. The capital of Italy is Rome. The capital of'
  INFO [ipc_loader.py:203] Mapped 339 tensors from the weight cache daemon (zero_copy mode)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine was asked for &lt;code&gt;q_mod&lt;/code&gt; and ran &lt;code&gt;q&lt;/code&gt;, with no warning from either process. To confirm that the check itself works, an engine started with &lt;code&gt;ipc_cache&lt;/code&gt; on a different model (a small random Qwen3 with different shapes) was turned away and fell back to disk, as documented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING [ipc_loader.py:139] Weight cache unusable (WeightCacheKey mismatch on fields: ['checkpoint']); falling back to disk loading
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the fallback works when the layout differs, and doesn't trigger when only the values do.&lt;/p&gt;

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

&lt;p&gt;Everything you'd look at points at the checkpoint you asked for. The engine was started on &lt;code&gt;q_mod&lt;/code&gt;, the only log line about weights says they were mapped successfully, and the output is fluent text from a working model. Only the weights are someone else's. In the realistic case, a base model and its fine-tune, or the same fine-tune at two steps, the wrong answers are plausible ones. Nothing points at the cache.&lt;/p&gt;

&lt;p&gt;The documentation says this case can't happen. The feature's page on the main branch (&lt;code&gt;docs/features/preload.md&lt;/code&gt;, which isn't in the 0.30.0 tag) lists a "Safe fallback" for cached weights that "don't match the engine's configuration". It describes the fingerprint as covering "checkpoint content (hashed from safetensors metadata, ...)". That parenthesis gives it away: metadata is all it hashes, and different weights can have identical metadata.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;vllm/model_executor/model_loader/weight_cache/protocol.py&lt;/code&gt; in 0.30.0:&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;hash_checkpoint&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="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;str&lt;/span&gt; &lt;span class="o"&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;Fingerprint checkpoint content from local safetensors metadata.

    Hashes each shard&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s safetensors header so a daemon and an engine pointing
    at identical weights in different directories produce the same key. ...
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_safetensors_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hasher&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A safetensors header is a JSON index: for each tensor, its name, dtype, shape and byte offsets. Fine-tuning changes none of those. The design goal in the docstring, letting a copy of the same weights in another directory match, is met by hashing less than the content. The side effect is that different weights with the same layout match too. The rest of &lt;code&gt;WeightCacheKey&lt;/code&gt; (architecture, dtype, quantization, TP rank, vLLM version) is the same for a local base model and its fine-tune as well.&lt;/p&gt;

&lt;p&gt;A fix is proposed in &lt;a href="https://github.com/vllm-project/vllm/pull/59648" rel="noopener noreferrer"&gt;#59648&lt;/a&gt;, titled "Fingerprint sampled tensor bytes, not headers only". It is open, and 0.30.0 is the current release.&lt;/p&gt;

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

&lt;p&gt;Until that ships, the daemon's checkpoint and the engine's must be the same one, and nothing will tell you if they aren't. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run a weight-cache daemon only for the checkpoint you serve.&lt;/strong&gt; If you switch between a base model and fine-tunes of it, or between training checkpoints, either stop the daemon and start one for the new checkpoint, or load the others with the default &lt;code&gt;load_format&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "Mapped N tensors from the weight cache daemon" as a claim to check.&lt;/strong&gt; After any switch, compare one deterministic prompt between &lt;code&gt;load_format="ipc_cache"&lt;/code&gt; and &lt;code&gt;"auto"&lt;/code&gt; for the checkpoint you think you're serving. Here that took one prompt and twelve tokens to show the difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither of those was needed for a different-shaped model, which the existing check caught.&lt;/p&gt;

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

&lt;p&gt;A cache key decides what counts as the same thing. Whoever chose this one optimised for matching identical weights in different places, and in doing so it also matched different weights in the same layout. When a cache promises a safe fallback on mismatch, find out what the key covers before relying on it. The test is cheap: make two inputs that differ only in what the key leaves out, and see whether the cache tells them apart.&lt;/p&gt;

&lt;p&gt;The same version of vLLM also &lt;a href="https://homelabpostmortem.com/2026/10/03/vllm-ignores-lora-rank-pattern-and-alpha-pattern/" rel="noopener noreferrer"&gt;loads a LoRA adapter's per-module scaling as one number&lt;/a&gt; and serves it without a warning. In both cases vLLM read less of what it was given than it reported using.&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>vllm</category>
      <category>selfhosted</category>
      <category>debugging</category>
    </item>
    <item>
      <title>vLLM ignores LoRA rank_pattern and alpha_pattern and serves the adapter at the wrong scale</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 03 Oct 2026 07:45:05 +0000</pubDate>
      <link>https://dev.to/homelabpm/vllm-ignores-lora-rankpattern-and-alphapattern-and-serves-the-adapter-at-the-wrong-scale-4pek</link>
      <guid>https://dev.to/homelabpm/vllm-ignores-lora-rankpattern-and-alphapattern-and-serves-the-adapter-at-the-wrong-scale-4pek</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: A PEFT LoRA adapter can give individual modules their own rank and alpha through &lt;code&gt;rank_pattern&lt;/code&gt; and &lt;code&gt;alpha_pattern&lt;/code&gt; in &lt;code&gt;adapter_config.json&lt;/code&gt;, and PEFT scales each module by its own &lt;code&gt;alpha_m / r_m&lt;/code&gt;. vLLM 0.30.0 reads &lt;code&gt;r&lt;/code&gt; and &lt;code&gt;lora_alpha&lt;/code&gt; only and applies &lt;code&gt;lora_alpha / r&lt;/code&gt; to every module. The patterns are dropped without a warning. On an RTX 2070 under WSL2, with PEFT 0.21.2 and a small random Qwen3, an adapter with &lt;code&gt;rank_pattern = {"q_proj": 4}&lt;/code&gt; gave prompt log-probabilities 0.0211 away from PEFT's on average, against 0.0003 for the same setup without a pattern. An adapter with only &lt;code&gt;alpha_pattern&lt;/code&gt; set was off by 0.0168. Multiplying the affected modules' &lt;code&gt;lora_B&lt;/code&gt; by the missing factor and clearing the pattern brought it back to 0.0003. The toolkit's &lt;code&gt;check-lora-patterns.py&lt;/code&gt; reads an adapter's config and lists the modules vLLM will mis-scale, and by how much. Upstream report: &lt;a href="https://github.com/vllm-project/vllm/issues/59799" rel="noopener noreferrer"&gt;vllm-project/vllm#59799&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;RTX 2070 (8 GB) on Windows 11, WSL2 Ubuntu, driver 591.86. vLLM 0.30.0 with torch 2.13.0+cu130, PEFT 0.21.2. The model is a randomly initialised two-layer Qwen3 (hidden size 256), so nothing has to be downloaded. The RTX 2070 has no bf16, so everything ran in fp16 (the report used bf16).&lt;/p&gt;

&lt;p&gt;Four adapters on &lt;code&gt;q_proj&lt;/code&gt; and &lt;code&gt;v_proj&lt;/code&gt;, all &lt;code&gt;r=16&lt;/code&gt;, &lt;code&gt;lora_alpha=32&lt;/code&gt;, with &lt;code&gt;lora_B&lt;/code&gt; scaled up so the adapter's effect stands well clear of fp16 noise. For each, PEFT's own prompt log-probabilities on four 48-token prompts are the reference, then vLLM serves the same adapter on the same base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adapter                                   PEFT q_proj scale   mean |vLLM - PEFT|   max
none          no pattern                  2                   0.0003               0.0016
rank          rank_pattern {"q_proj": 4}  32/4   = 8          0.0211               0.1106
rank_folded   rank, q_proj lora_B x4,     2 (x4 in weights)   0.0003               0.0010
              rank_pattern {}
alpha         alpha_pattern {"q_proj":128} 128/16 = 8         0.0168               0.1046
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first row shows vLLM's LoRA path is fine: with no pattern it matches PEFT to within fp16 rounding. With a pattern on one module, the error is 70 times larger. The folded copy, which moves the factor PEFT applies into the weights themselves, matches again. &lt;code&gt;alpha_pattern&lt;/code&gt; alone produces the same error, so this is about the scaling, not about the module having a different rank.&lt;/p&gt;

&lt;p&gt;The vLLM log has nothing to say about any of it. No line in the run mentions &lt;code&gt;rank_pattern&lt;/code&gt; or &lt;code&gt;alpha_pattern&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;vLLM accepts the adapter. It already turns some adapters away: &lt;code&gt;_validate_features&lt;/code&gt; refuses DoRA and &lt;code&gt;modules_to_save&lt;/code&gt;, so an adapter that loads without complaint looks supported. The output is still the adapter's output, only weaker or stronger in the patterned modules than it was trained to be. Nothing about the generated text flags that. You would have to compare against PEFT, or measure the quality you trained for, to see it.&lt;/p&gt;

&lt;p&gt;The patterns aren't exotic, either. The report points out that PEFT's own LoRA documentation recommends &lt;code&gt;rank_pattern&lt;/code&gt; for mixture-of-experts models, giving each expert a smaller rank (&lt;code&gt;r // num_experts&lt;/code&gt;), and points to vLLM for serving. The reporter measured the consequence on OLMoE-1B-7B: a held-out perplexity of 10.06 in PEFT and 11.28 in vLLM, against 13.09 for the base model. That means vLLM gave up about 40% of what the adapter had gained. Those numbers are the reporter's, from hardware this lab doesn't have. The direction and size of the effect match the small model here.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;vllm/lora/peft_helper.py&lt;/code&gt; in 0.30.0 defines the fields vLLM reads from &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 python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;span class="n"&gt;lora_alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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;|&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;modules_to_save&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;use_rslora&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&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;use_dora&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&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;vllm_lora_scaling_factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and computes one scale from them:&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;if&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;use_rslora&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;vllm_lora_scaling_factor&lt;/span&gt; &lt;span class="o"&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_alpha&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vllm_lora_scaling_factor&lt;/span&gt; &lt;span class="o"&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_alpha&lt;/span&gt; &lt;span class="o"&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;r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no field for &lt;code&gt;rank_pattern&lt;/code&gt; or &lt;code&gt;alpha_pattern&lt;/code&gt;, and a code search of the repository finds no other reference to them. The rank of each module still comes out right, because it's taken from the tensor shapes in the safetensors file. Only the scale is shared, so any module whose pattern makes &lt;code&gt;alpha_m / r_m&lt;/code&gt; differ from &lt;code&gt;lora_alpha / r&lt;/code&gt; is scaled wrong by exactly that ratio. For &lt;code&gt;q_proj&lt;/code&gt; above that is 8 against 2, a factor of 4.&lt;/p&gt;

&lt;p&gt;Not every patterned adapter is affected. According to the report, PEFT's &lt;code&gt;save_as_lora&lt;/code&gt; with a dynamic rank writes &lt;code&gt;r=1&lt;/code&gt;, &lt;code&gt;lora_alpha=1&lt;/code&gt; and the same value in both patterns for each module. Every module's scale is then 1, and vLLM gets it right by coincidence.&lt;/p&gt;

&lt;p&gt;A fix is proposed in &lt;a href="https://github.com/vllm-project/vllm/pull/59801" rel="noopener noreferrer"&gt;#59801&lt;/a&gt;, which applies the scale per module. It is open and unmerged, and 0.30.0 is the current release.&lt;/p&gt;

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

&lt;p&gt;Until a release includes it, fold each affected module's scale correction into its &lt;code&gt;lora_B&lt;/code&gt; and remove the patterns. The factor is PEFT's scale for that module divided by vLLM's, and &lt;code&gt;check-lora-patterns.py&lt;/code&gt; prints it. For the adapter above it was 4 for &lt;code&gt;q_proj&lt;/code&gt;. This is the fold the &lt;code&gt;rank_folded&lt;/code&gt; row used, written to a new directory:&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;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;safetensors.torch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;save_file&lt;/span&gt;

&lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank/adapter_model.safetensors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_proj.lora_B&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;k&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sd&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="c1"&gt;# 4 = (32/4) / (32/16)
&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_folded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;save_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_folded/adapter_model.safetensors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cfg&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;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank/adapter_config.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;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_pattern&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="p"&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;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_folded/adapter_config.json&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;w&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;Two cautions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pattern keys are module-name patterns.&lt;/strong&gt; According to the report, PEFT matches a key against the end of the module path, so &lt;code&gt;q_proj&lt;/code&gt; applies to every layer's &lt;code&gt;q_proj&lt;/code&gt;, and a key like &lt;code&gt;layers.3.self_attn.q_proj&lt;/code&gt; applies to one. Fold the tensors the key actually covers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't just delete &lt;code&gt;alpha_pattern&lt;/code&gt;.&lt;/strong&gt; That changes what PEFT does as well, and leaves vLLM where it was.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;check-lora-patterns.py&lt;/code&gt; on the folded adapter afterwards. It should report every module as keeping &lt;code&gt;lora_alpha / r&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;An adapter config is a set of instructions, and a loader that recognises only some of them will quietly carry out a different set. vLLM rejects some unsupported adapter features outright, and that makes it easy to assume that whatever it accepts, it supports. It doesn't follow. Before you trust a server to reproduce a training setup, compare one batch of its outputs against the training framework's on the same inputs. Here that took four prompts and a single number.&lt;/p&gt;

&lt;p&gt;This is the second LoRA case on this site where vLLM accepts an adapter and serves something else. The first was &lt;a href="https://homelabpostmortem.com/2026/09/07/vllm-accepts-a-lora-it-will-never-apply/" rel="noopener noreferrer"&gt;an adapter whose target modules it will never apply&lt;/a&gt;. Its weight cache, which &lt;a href="https://homelabpostmortem.com/2026/10/03/vllm-weight-cache-serves-another-checkpoints-weights/" rel="noopener noreferrer"&gt;can serve another checkpoint's weights&lt;/a&gt;, is the same pattern one level down: a check that passes on less than it claims to check.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-lora-patterns.py&lt;/code&gt; reads &lt;code&gt;adapter_config.json&lt;/code&gt; files, or every one under a directory. For each module whose pattern changes its scale, it prints PEFT's scale, vLLM's, and the factor between them. It doesn't flag &lt;code&gt;save_as_lora&lt;/code&gt;-style adapters whose patterns keep every scale equal.&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>vllm</category>
      <category>lora</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>A failing Docker swarm service cut healthy containers off the overlay network</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:53:13 +0000</pubDate>
      <link>https://dev.to/homelabpm/a-failing-docker-swarm-service-cut-healthy-containers-off-the-overlay-network-dhj</link>
      <guid>https://dev.to/homelabpm/a-failing-docker-swarm-service-cut-healthy-containers-off-the-overlay-network-dhj</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On a Docker swarm node, the overlay driver keeps a count of how many endpoints have joined each network there, and destroys the network's sandbox on that node when the count reaches zero. When a container start fails after the driver's &lt;code&gt;Join&lt;/code&gt; (for example because the host port it publishes is taken), libnetwork rolls the join back by calling &lt;code&gt;Leave&lt;/code&gt; twice, so every such failure takes one more off the count than it added. On Docker 29.8.2, a node with two healthy containers on an overlay network lost that network within 20 seconds of creating a swarm service that published a host port another container already held. Nobody touched anything: the service's own retries did it. Both containers went &lt;code&gt;NO-CARRIER&lt;/code&gt;, could no longer reach anything on the overlay, and &lt;code&gt;docker ps&lt;/code&gt; showed them running throughout. Restarting the containers didn't bring them back. Restarting the daemon did. The toolkit's &lt;code&gt;check-overlay-sandbox.sh&lt;/code&gt; finds containers in that state. Upstream report: &lt;a href="https://github.com/moby/moby/issues/53834" rel="noopener noreferrer"&gt;moby/moby#53834&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;A Debian 13 VM on Proxmox, Docker 29.8.2 from Docker's apt repository (the current release; the report was against 29.8.1), &lt;code&gt;docker swarm init&lt;/code&gt; as a single-node swarm. An attachable overlay network with two long-running containers on it, and one more container on the default bridge holding host port 8080:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create &lt;span class="nt"&gt;-d&lt;/span&gt; overlay &lt;span class="nt"&gt;--attachable&lt;/span&gt; ovs
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c1 &lt;span class="nt"&gt;--network&lt;/span&gt; ovs alpine &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c2 &lt;span class="nt"&gt;--network&lt;/span&gt; ovs alpine &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; holder &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 alpine &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a service that wants port 8080 in host mode. Port conflicts happen easily, from a leftover container, a second compose project or a forgotten test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker service create &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;--network&lt;/span&gt; ovs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--publish&lt;/span&gt; &lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host,target&lt;span class="o"&gt;=&lt;/span&gt;80,published&lt;span class="o"&gt;=&lt;/span&gt;8080 alpine &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service's task fails, and swarm schedules a new one, again and again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;starting container failed: failed to set up container networking: ... endpoint join on GW Network
failed: driver failed programming external connectivity on endpoint gateway_8e90e46aa0fb (...):
Bind for 0.0.0.0:8080 failed: port is already allocated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile, on the containers that had nothing to do with it (&lt;code&gt;ping&lt;/code&gt; from each to the network's load-balancer endpoint):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseline   c1 ping=0  c2 ping=0  netns=present  c1/c2 running
t+10 s     c1 ping=0  c2 ping=0  netns=present  (2 failed tasks)
t+20 s     c1 ping=1  c2 ping=1  netns=MISSING  c1/c2 running  (4 failed tasks)
t+45 s     c1 ping=1  c2 ping=1  netns=MISSING  c1/c2 running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside them the overlay interface had lost its carrier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eth0: &amp;lt;NO-CARRIER,BROADCAST,MULTICAST,UP,M-DOWN&amp;gt; state DOWN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same happens without swarm services, with plain &lt;code&gt;docker run&lt;/code&gt;. With one container on the network, the second failed &lt;code&gt;docker run --network ovp -p 8080:80 ...&lt;/code&gt; against the taken port cut it off. The report used an invalid endpoint sysctl as the failing step, which reproduced here the same way: two failures with one container on the network, three with two.&lt;/p&gt;

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

&lt;p&gt;The damage lands on containers that did nothing wrong, and the error lands somewhere else. The failing service logs a port conflict, which looks like the whole story: fix the port, and the service starts. The containers that lost their network stay &lt;code&gt;running&lt;/code&gt; and healthy by every check that doesn't send traffic over the overlay. Health checks that only test the process inside the container still pass. The symptom other services see is "can't reach c1", and &lt;code&gt;docker ps&lt;/code&gt; and &lt;code&gt;docker inspect&lt;/code&gt; still show it running.&lt;/p&gt;

&lt;p&gt;The usual reflex doesn't work once it has gone far. After the service's retries had failed more times than there were joins, restarting &lt;code&gt;c1&lt;/code&gt; and &lt;code&gt;c2&lt;/code&gt; left the sandbox missing. Stopping both and starting them again recreated &lt;code&gt;/run/docker/netns/1-…&lt;/code&gt;, but the ping still failed. Only restarting the daemon brought them back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;restart c1 and c2               ping=1 ping=1  netns=MISSING
stop both, start both           ping=1 ping=1  netns=present
systemctl restart docker        ping=0 ping=0  netns=present
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(After only two manual failures on a one-container network, a plain &lt;code&gt;docker restart c1&lt;/code&gt; was enough. How much it takes depends on how far past zero the count went.)&lt;/p&gt;

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

&lt;p&gt;The overlay driver, &lt;code&gt;daemon/libnetwork/drivers/overlay/ov_network.go&lt;/code&gt; in 29.8.2:&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;incJoinCount&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="n"&gt;joinCnt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;func&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="n"&gt;network&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;leaveSandbox&lt;/span&gt;&lt;span class="p"&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="n"&gt;joinCnt&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;
    &lt;span class="k"&gt;if&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;joinCnt&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="k"&gt;return&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="n"&gt;destroySandbox&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 caller, &lt;code&gt;Endpoint.sbJoin&lt;/code&gt; in &lt;code&gt;daemon/libnetwork/endpoint.go&lt;/code&gt;, which sets up two rollbacks for one join:&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;defer&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retErr&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;ep&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sbLeave&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;sb&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="no"&gt;true&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="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c"&gt;// calls d.Leave(...)&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="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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&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;nid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;epid&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;Key&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ep&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generic&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;Labels&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="k"&gt;defer&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retErr&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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Leave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;epid&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="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;              &lt;span class="c"&gt;// and so does this&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;If anything after &lt;code&gt;d.Join&lt;/code&gt; fails, both deferred functions run, and the driver sees one &lt;code&gt;Join&lt;/code&gt; and two &lt;code&gt;Leave&lt;/code&gt;s. Each failed start therefore costs the network one join it never had. On a node, the count is the containers on the network plus the network's load-balancer endpoint, so a node with N containers on an overlay network loses it after N+1 such failures. These accumulate over the daemon's lifetime, so they don't have to come in a burst. Once the count reaches zero the sandbox is destroyed under the containers still attached. If it goes below zero, the bookkeeping is wrong for every later join on that network until the daemon restarts. That matches what the recovery attempts showed.&lt;/p&gt;

&lt;p&gt;A swarm service is the efficient way to get there. Its restart policy turns one bad port into a stream of failed starts, a few per ten seconds, and every one takes another join off the count.&lt;/p&gt;

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

&lt;p&gt;There is no release with a fix. The reporter has one ready, pending a maintainer's view. Until then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix the failing start first.&lt;/strong&gt; Anything that fails a container start on an overlay network after the driver has joined (a published host port in use, a bad endpoint option) counts against the healthy containers on that node. A service in a restart loop counts every few seconds. &lt;code&gt;docker service ps &amp;lt;service&amp;gt; --no-trunc&lt;/code&gt; shows the error. Scale the service to zero or remove it while you fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then restart the daemon on that node&lt;/strong&gt; if containers are already cut off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart docker
docker start &amp;lt;containers without a restart policy&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting the affected containers may be enough if the count only just reached zero. Here it wasn't, once a service had been retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check, rather than trust &lt;code&gt;docker ps&lt;/code&gt;.&lt;/strong&gt; On each node, the sandbox for a network with local containers should exist, and each container's interface on it should have a carrier:&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;NET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ovs
&lt;span class="nb"&gt;ls&lt;/span&gt; /run/docker/netns/1-&lt;span class="si"&gt;$(&lt;/span&gt;docker network inspect &lt;span class="nv"&gt;$NET&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'{{slice .Id 0 10}}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;c1 ip &lt;span class="nb"&gt;link &lt;/span&gt;show eth0      &lt;span class="c"&gt;# NO-CARRIER = cut off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;code&gt;running&lt;/code&gt; describes the process, not its connections, and the errors are on the container that failed rather than on the ones that paid for it. When a failure can have side effects on shared state, here a per-node reference count, look at the bystanders as well as the thing that failed. A cheap way to cover this is a check that sends real traffic over the network a container depends on, not one that only asks whether the process is alive.&lt;/p&gt;

&lt;p&gt;It is the second time on this site that Docker reported success over a broken state: &lt;a href="https://homelabpostmortem.com/2026/09/12/docker-logs-stops-at-a-nul-byte-and-exits-0/" rel="noopener noreferrer"&gt;&lt;code&gt;docker logs&lt;/code&gt; stops at a NUL byte in a json-file log and exits 0&lt;/a&gt;. Both times, the command whose job was to tell you said nothing was wrong.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-overlay-sandbox.sh&lt;/code&gt; (run as root on each swarm node) checks every overlay network with local containers: that its sandbox exists, and that each running container's interface on it has a carrier. It reads the interface flags from inside the container's network namespace. It also counts swarm tasks that failed during network setup and are still listed, since each of those may have taken a join off the count.&lt;/p&gt;

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

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

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

</description>
      <category>docker</category>
      <category>networking</category>
      <category>selfhosted</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Ollama's native chat path re-sorts your JSON schema keys alphabetically</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:24:17 +0000</pubDate>
      <link>https://dev.to/homelabpm/ollamas-native-chat-path-re-sorts-your-json-schema-keys-alphabetically-1hbp</link>
      <guid>https://dev.to/homelabpm/ollamas-native-chat-path-re-sorts-your-json-schema-keys-alphabetically-1hbp</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Ollama 0.35 runs chat through one of two paths. Models it renders with a Go &lt;code&gt;TEMPLATE&lt;/code&gt; hand your &lt;code&gt;format&lt;/code&gt; / &lt;code&gt;response_format&lt;/code&gt; schema to llama-server as written. Models on the "native" path go through &lt;code&gt;llamaServerChatResponseFormat()&lt;/code&gt;, which decodes the schema into a Go &lt;code&gt;map[string]any&lt;/code&gt; and encodes it again. Go writes map keys in alphabetical order, and llama-server's grammar enforces the order it receives. A model takes the native path when it has no renderer, parser or usable Go template, when its GGUF chat template is preferred over the Go one, or when the server runs with &lt;code&gt;OLLAMA_GO_TEMPLATE=false&lt;/code&gt;. With that setting, a schema declaring &lt;code&gt;zeta_colour&lt;/code&gt; then &lt;code&gt;alpha_animal&lt;/code&gt; came back as &lt;code&gt;{"alpha_animal": "blue", "zeta_colour": "zebra"}&lt;/code&gt; from Gemma 3 1B: valid against the schema, HTTP 200, and the colour in the animal field. The four models tested on default settings, including two &lt;code&gt;hf.co&lt;/code&gt; imports, kept the declared order. The open fix, &lt;a href="https://github.com/ollama/ollama/pull/18721" rel="noopener noreferrer"&gt;ollama/ollama#18721&lt;/a&gt;, kept it on the native path too. Until a release includes it, name keys so their alphabetical order is the order you want. The toolkit's &lt;code&gt;check-ollama-schema-order.sh&lt;/code&gt; tests each model on your server. Upstream report: &lt;a href="https://github.com/ollama/ollama/issues/18717" rel="noopener noreferrer"&gt;ollama/ollama#18717&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Debian 13 LXC, CPU only, Ollama 0.35.1 (the current release) from the release tarball. One schema, whose declared order is the reverse of alphabetical order:&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;"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;"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;"zeta_colour"&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;"alpha_animal"&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;"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;"zeta_colour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alpha_animal"&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;sent with "Name a colour and an animal." at temperature 0, through &lt;code&gt;/api/chat&lt;/code&gt; (&lt;code&gt;format&lt;/code&gt;) and &lt;code&gt;/v1/chat/completions&lt;/code&gt; (&lt;code&gt;response_format&lt;/code&gt;). Both endpoints gave the same answer wherever both returned one. &lt;code&gt;qwen3:0.6b&lt;/code&gt; is a thinking model and only reasoned within the token limit on &lt;code&gt;/v1&lt;/code&gt;, so its line is from &lt;code&gt;/api/chat&lt;/code&gt; with &lt;code&gt;think: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the default server, all four models kept the declared order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain (FROM gemma-3-1b-it-Q4_K_M.gguf)          {"zeta_colour": "blue", "alpha_animal": "zebra"}
hf.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF      { "zeta_colour": "blue", "alpha_animal": "lion" }
hf.co/LiquidAI/LFM2-350M-GGUF                   {"zeta_colour": "red", "alpha_animal": "lion"}
qwen3:0.6b (library)                            { "zeta_colour": "green", "alpha_animal": "lion" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same server restarted with &lt;code&gt;OLLAMA_GO_TEMPLATE=false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain (FROM gemma-3-1b-it-Q4_K_M.gguf)          { "alpha_animal" : "blue", "zeta_colour" : "zebra" }
hf.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF      { "alpha_animal": "red", "zeta_colour": "red" }
hf.co/LiquidAI/LFM2-350M-GGUF                   {   "alpha_animal": "Blue",   "zeta_colour": "Blue" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keys are in alphabetical order, and Gemma's answers are in the wrong fields. The model was answering "a colour and an animal" in the order the question asked, while the grammar made it fill in &lt;code&gt;alpha_animal&lt;/code&gt; first. So "blue" went into the animal field and "zebra" into the colour field. The JSON is valid against the schema, nothing reports an error, and a client that reads fields by name gets wrong data.&lt;/p&gt;

&lt;p&gt;The report itself came from 0.35.0 with a local model created &lt;code&gt;FROM&lt;/code&gt; another, and says 0.33.2 behaved the same way.&lt;/p&gt;

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

&lt;p&gt;Most of the time it doesn't happen. All four models above took the other path on a default install, including two pulled straight from Hugging Face. So the usual test, sending a schema and looking at the output, comes back clean, and the bug only shows up for a particular model or server setting.&lt;/p&gt;

&lt;p&gt;When it does happen, nothing looks wrong. JSON objects are unordered as far as most parsers care, and every field is present and valid. The cost is in what the order was for. A common structured-output pattern declares &lt;code&gt;reasoning&lt;/code&gt; before &lt;code&gt;answer&lt;/code&gt; so the model works something out before it commits to an answer. Sorted alphabetically, &lt;code&gt;answer&lt;/code&gt; comes first. The request and the response both look normal, and the only symptom is worse answers. The swapped fields above are the visible version of the same thing.&lt;/p&gt;

&lt;p&gt;It is also a regression of something fixed before. In December 2024, &lt;a href="https://github.com/ollama/ollama/pull/8002" rel="noopener noreferrer"&gt;#8002&lt;/a&gt; ("preserve field order in user-defined JSON schemas") closed the same complaint. The llama-server runner brought back the step that loses the order.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;llm/llama_server.go&lt;/code&gt; in 0.35.1 builds the native chat request's &lt;code&gt;response_format&lt;/code&gt; like this:&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="n"&gt;llamaServerChatResponseFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&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="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="o"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;schema&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="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;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format&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;schema&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="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&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="s"&gt;"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="s"&gt;"json_schema"&lt;/span&gt;&lt;span class="o"&gt;:&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="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"schema"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unmarshalling into a &lt;code&gt;map[string]any&lt;/code&gt; throws the order away, and &lt;code&gt;encoding/json&lt;/code&gt; writes map keys sorted. The same function and the same &lt;code&gt;var schema map[string]any&lt;/code&gt; are in v0.30.0 (released 2026-05-13) and v0.32.0. The other path, used for models with a Go template, keeps the schema as &lt;code&gt;json.RawMessage&lt;/code&gt; (&lt;code&gt;lsReq.JsonSchema = req.Format&lt;/code&gt;) and passes it on unchanged.&lt;/p&gt;

&lt;p&gt;Which path a model takes is decided in &lt;code&gt;server/routes.go&lt;/code&gt;:&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="n"&gt;chatModeForModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;chatExecutionMode&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;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsMLX&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;usesOllamaRenderedChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatExecutionModeRendered&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;chatExecutionModeNative&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;usesOllamaRenderedChat&lt;/code&gt; is true when the model has a renderer, a parser or the harmony format, or when &lt;code&gt;shouldUseGoTemplate&lt;/code&gt; is true. That in turn is true when the model has a Go template, &lt;code&gt;OLLAMA_GO_TEMPLATE&lt;/code&gt; is not false, and Ollama hasn't decided to prefer the GGUF's own chat template. It prefers that template when the GGUF template supports more than the Go one, tool calling for example. &lt;code&gt;ollama create&lt;/code&gt; from a bare GGUF and the &lt;code&gt;hf.co&lt;/code&gt; pulls tested here all came with a Go template, which is why they kept the order. That leaves three ways onto the native path: a model with no Go template at all, a model whose GGUF template wins, and a server running with &lt;code&gt;OLLAMA_GO_TEMPLATE=false&lt;/code&gt;. Only the last was reproduced here.&lt;/p&gt;

&lt;p&gt;The open fix, &lt;a href="https://github.com/ollama/ollama/pull/18721" rel="noopener noreferrer"&gt;#18721&lt;/a&gt;, passes the trimmed raw bytes through as &lt;code&gt;json.RawMessage&lt;/code&gt; instead of the decoded map. Built from 0.35.1 with that change, on the same &lt;code&gt;OLLAMA_GO_TEMPLATE=false&lt;/code&gt; server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain (FROM gemma-3-1b-it-Q4_K_M.gguf)          { "zeta_colour": "blue", "alpha_animal": "dolphin"}
hf.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF      { "zeta_colour": "Blue", "alpha_animal": "Bluebird" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The declared order is back, and Gemma's colour and animal are in the right fields.&lt;/p&gt;

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

&lt;p&gt;Until a release includes #18721, make the order you want and alphabetical order the same, and declare the keys in that order too. That way both paths produce 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;"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;"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;"a_colour"&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;"b_animal"&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;"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;"a_colour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b_animal"&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;On the stock native path that gave &lt;code&gt;{"a_colour": "blue", "b_animal": "lion"}&lt;/code&gt; from Gemma and &lt;code&gt;{"a_colour": "Red", "b_animal": "Red fox"}&lt;/code&gt; from Qwen2.5. For the reasoning pattern that means &lt;code&gt;a_reasoning&lt;/code&gt;, &lt;code&gt;b_answer&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;To find out whether a model is affected, send it a two-key schema in reverse-alphabetical order and see which key comes out first. That takes one request and doesn't depend on reading Ollama's routing rules correctly. If you have set &lt;code&gt;OLLAMA_GO_TEMPLATE=false&lt;/code&gt;, assume every model without a renderer is affected.&lt;/p&gt;

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

&lt;p&gt;When a layer sits between your client and the engine, check whether it passes your request through or rebuilds it. A layer that decodes a structure into its own types and encodes it again keeps only what those types can represent. Here a Go map can't represent key order, so the order was gone before llama-server saw the schema. The test for that is a request that only means something if a property survives the trip: two keys declared in reverse alphabetical order, compared against the output.&lt;/p&gt;

&lt;p&gt;This is the second time in a week that Ollama's translation into llama-server rewrote part of a request without saying so. The &lt;a href="https://homelabpostmortem.com/2026/09/29/ollama-openai-api-replaces-your-model-temperature-with-1/" rel="noopener noreferrer"&gt;OpenAI-compatible API fills in temperature and top_p as 1.0&lt;/a&gt; when the client leaves them out. Both times the reply was valid and well formed.&lt;/p&gt;

&lt;p&gt;A third turned up two days later: with &lt;code&gt;think&lt;/code&gt; enabled, &lt;a href="https://homelabpostmortem.com/2026/10/05/ollama-thinking-model-skips-your-json-schema-when-it-answers-directly/" rel="noopener noreferrer"&gt;a model that answers without thinking isn't held to the schema at all&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-ollama-schema-order.sh&lt;/code&gt; sends that two-key schema to each installed model, or the ones you name, and reports which kept the order. It also reads &lt;code&gt;OLLAMA_GO_TEMPLATE&lt;/code&gt; from the running server's environment.&lt;/p&gt;

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

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

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

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>json</category>
    </item>
    <item>
      <title>llama-server's sleep mode loses or crashes on a request that arrives just before it sleeps</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 03 Oct 2026 05:20:17 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-servers-sleep-mode-loses-or-crashes-on-a-request-that-arrives-just-before-it-sleeps-5e41</link>
      <guid>https://dev.to/homelabpm/llama-servers-sleep-mode-loses-or-crashes-on-a-request-that-arrives-just-before-it-sleeps-5e41</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;llama-server --sleep-idle-seconds N&lt;/code&gt; unloads the model after N idle seconds and is documented to reload it for "any new incoming task". A request handler checks that the server is awake when it starts, then tokenizes the prompt, then queues the task. If the idle timer fires in between, the server goes to sleep anyway. On llama.cpp b11368 (CPU, Gemma 3 1B), a 17,653-token prompt sent 46 to 34 ms before the server fell asleep sat in the queue with no reply until a second request woke the server, 32 times out of 32. Sent 34 to 3 ms before, it crashed the server with SIGSEGV inside the tokenizer, which was reading the vocabulary the sleep had just freed, 41 times out of 41. With a two-word prompt the window is too short to hit: 0 of 93. Sending a 1-token completion first and the real request right after it avoided both in 123 of 123 tries. The toolkit's &lt;code&gt;check-llama-sleep-race.sh&lt;/code&gt; finds servers with sleep mode on and says whether a crash would be restarted. Upstream report (the hang): &lt;a href="https://github.com/ggml-org/llama.cpp/issues/29689" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#29689&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;The report that led here was intermittent: a client that sends &lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;/props&lt;/code&gt;, &lt;code&gt;/tokenize&lt;/code&gt; and then &lt;code&gt;POST /completion&lt;/code&gt; right after the server starts, with &lt;code&gt;--sleep-idle-seconds 1&lt;/code&gt;, got no answer to the completion about one time in eight. The server log showed it going to sleep and never waking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I que    start_loop: entering sleeping state
I srv  handle_sleep: server is entering sleeping state
W srv          stop: cancel task, id_task = 0          &amp;lt;- the client giving up 10 s later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To find where the window is, a Debian 13 LXC with 6 CPU cores, the llama.cpp &lt;code&gt;b11368&lt;/code&gt; release binary, &lt;code&gt;gemma-3-1b-it-Q4_K_M&lt;/code&gt;, &lt;code&gt;-np 1 --sleep-idle-seconds 1&lt;/code&gt;. Each trial starts a fresh server, takes the moment &lt;code&gt;/health&lt;/code&gt; first returns 200 as zero, waits a chosen offset, sends one &lt;code&gt;POST /completion&lt;/code&gt;, and timestamps every server log line as it arrives. The server fell asleep 0.978 to 1.000 s after zero, so every send can be placed relative to the moment it slept.&lt;/p&gt;

&lt;p&gt;With the prompt &lt;code&gt;"Say OK"&lt;/code&gt;, 93 sends between 0.97 and 1.03 s gave 93 answers. The 61 that arrived after the server was asleep woke it as documented, at about 1.4 s latency for the reload.&lt;/p&gt;

&lt;p&gt;Then the same with a 17,653-token prompt. To keep each trial fast, the context was &lt;code&gt;-c 512&lt;/code&gt;, so a prompt that gets processed is rejected at once with HTTP 400 ("exceeds the available context size"). A 400 means the request reached a slot. Anything else means it didn't. 123 sends between 0.93 and 1.01 s, grouped by when they were sent relative to the moment the server fell asleep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent relative to sleep    result
early enough (&amp;gt; ~50 ms)    13  answered before the timer fired (400, ~45 ms)
46 .. 34 ms before         32  no answer; /props says is_sleeping: true
34 .. 3 ms before          41  connection closed with no response; server gone
2 ms before .. 29 ms after 37  woke the server, answered (~1.3 s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hung requests were still in the queue. After 3 s the harness checked &lt;code&gt;/props&lt;/code&gt; (&lt;code&gt;"is_sleeping": true&lt;/code&gt;) and sent a second, short completion. That woke the server, and the first request was answered right behind it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first sent      0.935 s   (server fell asleep at 0.979 s)
/props at 3 s   is_sleeping: true
second sent     3.938 s   -&amp;gt; "exiting sleeping state" at 3.938 s
first answered  5.172 s   (4.2 s after it was sent)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the second request the first would have waited until the client's own timeout. For a single client that sends one request after a quiet period, which is how cron jobs, home automations and agent turns tend to look, that means no reply at all.&lt;/p&gt;

&lt;p&gt;The other 41 were not hangs. The server process had exited with signal 11, and the client got &lt;code&gt;RemoteDisconnected&lt;/code&gt;. Ten more sends between 0.970 and 0.990 s crashed it all ten times. Run under &lt;code&gt;gdb&lt;/code&gt;, the faulting thread was an HTTP worker in the middle of tokenizing the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread 9 "llama-server" received signal SIGSEGV, Segmentation fault.
#1  llama_vocab::text_to_token(...)
#2  llm_tokenizer_spm_session::try_add_bigram(int, int)
#3  llama_vocab::impl::tokenize(...)
#6  common_tokenize(...)
#9  tokenize_input_prompts(...)
#10 server_routes::handle_completions_impl(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;It happens in a few dozen milliseconds out of every idle period, so most requests never see it, and the ones that do look like unrelated faults. The hang leaves no error anywhere: the log's last word is "entering sleeping state", which is what an idle server should say, and &lt;code&gt;/props&lt;/code&gt; correctly reports that the server is asleep. The crash leaves nothing in the server's own log either, because the process dies mid-request. If a supervisor restarts it, the outage can pass for a blip.&lt;/p&gt;

&lt;p&gt;The documentation describes the opposite: "When the server enters sleep mode, the model and its associated memory (including the KV cache) are unloaded from RAM to conserve resources. Any new incoming task will automatically trigger the model to reload." That holds for a request that arrives after the server is asleep. It doesn't hold for one that arrives slightly before.&lt;/p&gt;

&lt;p&gt;And it depends on the prompt. With a two-word prompt there was no window to hit in 93 tries. The reporter's server had a vision projector loaded (&lt;code&gt;--mmproj&lt;/code&gt;) and speculative decoding on, and saw it one time in eight. What widened their window isn't stated, but an image is preprocessed in the same place a prompt is tokenized, so either would do it.&lt;/p&gt;

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

&lt;p&gt;Every completion handler starts by building its response object, and that constructor is the wake check (&lt;code&gt;tools/server/server-context.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="n"&gt;server_res_generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_queue&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;queue_tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bypass_sleep&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;sleep_idle_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bypass_sleep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;queue_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_until_no_sleep&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wait_until_no_sleep()&lt;/code&gt; returns at once if the server is awake. Only if it is already asleep does it set &lt;code&gt;req_stop_sleeping&lt;/code&gt; and wait for the wake-up. After that, &lt;code&gt;handle_completions_impl&lt;/code&gt; tokenizes the prompt (or runs &lt;code&gt;process_mtmd_prompt&lt;/code&gt; for an image), builds the tasks, and only then calls &lt;code&gt;rd.post_tasks(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Meanwhile the queue's main loop (&lt;code&gt;tools/server/server-queue.cpp&lt;/code&gt;), finding the queue empty and the idle timer expired, does this while holding the queue lock:&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="n"&gt;QUE_INF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"entering sleeping state&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;sleeping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&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="k"&gt;auto&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;callback_sleeping_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// unloads the model, vocabulary included&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;req_stop_sleeping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;condition_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;]{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;req_stop_sleeping&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;That gives two outcomes, depending on where the sleep lands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;During tokenization.&lt;/strong&gt; The handler is still reading the vocabulary when &lt;code&gt;cb(true)&lt;/code&gt; frees it. That is the SIGSEGV in &lt;code&gt;text_to_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After tokenization, before &lt;code&gt;post_tasks&lt;/code&gt;.&lt;/strong&gt; The task is queued while the loop is asleep. The loop only wakes when &lt;code&gt;req_stop_sleeping&lt;/code&gt; is set, and a queued task doesn't set it. The task waits for the next request that arrives while the server is asleep, since that one goes through &lt;code&gt;wait_until_no_sleep()&lt;/code&gt;'s wake path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The window is the time between the wake check and the post, which is mostly tokenization. On this CPU a 17,653-token prompt took about 45 ms end to end, and the dangerous span measured 46 ms to 3 ms before the sleep. It grows with prompt length and with image preprocessing.&lt;/p&gt;

&lt;p&gt;The issue proposes two fixes. One is to also wake when the queue is not empty. As this code reads, that covers the hang but not the crash, which happens before anything is queued. The other is to count requests that are between the wake check and the post, and not sleep while that count is above zero. That would cover both.&lt;/p&gt;

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

&lt;p&gt;There is no release with a fix yet. These are the options that were measured or are plain configuration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a 1-token completion before the real request.&lt;/strong&gt; A request whose prompt is a couple of tokens has almost no window: 0 hits in 93. When it is queued it resets the idle timer, so the real request that follows gets a full idle period of headroom instead of whatever was left. The same 123-send sweep with that in front:&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; localhost:8080/completion &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"prompt":"hi","n_predict":1}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/completion &lt;span class="nt"&gt;-d&lt;/span&gt; @real-request.json
&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;123 sends, 0.93 .. 1.01 s:  123 answered, 0 hung, 0 crashed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two requests have to go out back to back, well within the idle period. With a real setting in minutes, that is easy. &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/props&lt;/code&gt; don't work as the warm-up: the README says they "do not reset the idle timer". The reporter's workaround goes the other way: wait until &lt;code&gt;/props&lt;/code&gt; reports &lt;code&gt;is_sleeping: true&lt;/code&gt;, then send. That relies on the wake path, which handled all 37 sends here that arrived after the sleep (and 61 of 61 with the short prompt), at the cost of a model reload on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give the client a timeout and a retry.&lt;/strong&gt; It doesn't prevent anything, but a hung request becomes a slow one. The retry also wakes the server, which releases the stuck request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make sure a crash is restarted.&lt;/strong&gt; Under systemd that means &lt;code&gt;Restart=on-failure&lt;/code&gt; on the unit. The request that triggered the crash is lost either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or leave sleep mode off&lt;/strong&gt; (the default, &lt;code&gt;-1&lt;/code&gt;) on servers that take long prompts or images from a single client.&lt;/p&gt;

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

&lt;p&gt;A "check, then act" sequence is only as good as what stops the state from changing between the two steps. Here the wake check and the queueing are separate steps with real work between them, and nothing keeps the sleep from happening in that gap. When a feature adds a background transition (sleep, eviction, reload, rotation), ask what a request that started just before the transition is still holding, and whether the transition can take it away. Then test the boundary on purpose. A sweep of send times against the moment of the transition found in an hour what the reporter saw one time in eight, and it also turned up the crash. Nobody had reported that one, because the process that could log it dies first.&lt;/p&gt;

&lt;p&gt;The same server has other places where what a request was promised and what it gets come apart: a &lt;a href="https://homelabpostmortem.com/2026/09/12/llama-server-ignores-the-response-format-its-readme-shows/" rel="noopener noreferrer"&gt;documented &lt;code&gt;response_format&lt;/code&gt; that is accepted and ignored&lt;/a&gt; is the earlier one on this site.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-llama-sleep-race.sh&lt;/code&gt; reads running &lt;code&gt;llama-server&lt;/code&gt; command lines (and router preset files) for &lt;code&gt;--sleep-idle-seconds&lt;/code&gt;. For a running process, it also asks systemd whether a crash would be restarted. It never sends a request to the server, because a probe of this race can crash 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>llamacpp</category>
      <category>selfhosted</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Rootless podman export of a keep-id container shifts every file owner, exit 0</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 30 Sep 2026 01:51:19 +0000</pubDate>
      <link>https://dev.to/homelabpm/rootless-podman-export-of-a-keep-id-container-shifts-every-file-owner-exit-0-o42</link>
      <guid>https://dev.to/homelabpm/rootless-podman-export-of-a-keep-id-container-shifts-every-file-owner-exit-0-o42</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update 2026-10-08.&lt;/strong&gt; Fixed upstream for &lt;code&gt;podman export&lt;/code&gt;, not yet released. &lt;a href="https://github.com/podman-container-tools/podman/pull/29899" rel="noopener noreferrer"&gt;podman-container-tools/podman#29899&lt;/a&gt; ("fix: preserve ownership when exporting keep-id containers") was merged into &lt;code&gt;main&lt;/code&gt; on 2026-10-06 and closed #29856. As the merged diff reads, the &lt;code&gt;nil&lt;/code&gt; tar options quoted below are replaced by the container's own UID and GID mappings, so the archive gets the in-container owners back. Its new test exports a created, never-started &lt;code&gt;--userns=keep-id&lt;/code&gt; container and checks that &lt;code&gt;home/&lt;/code&gt; comes out as &lt;code&gt;0/0&lt;/code&gt; and the user's home and files as &lt;code&gt;1000/1000&lt;/code&gt;. The latest releases, v6.1.3 and v5.8.8 (both 2026-09-29), predate the merge, so Debian's 5.4.2 and every current release still behave as described below, and the commit-based export is still the way to get a correct archive. The pull request changes only the export path: &lt;code&gt;podman cp ctr:/ -&lt;/code&gt;, which showed the same shift here, is not touched by it. This was read from the diff, not re-tested here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;podman export&lt;/code&gt; of a rootless container that was created with &lt;code&gt;--userns=keep-id&lt;/code&gt; writes a tarball in which every file owner is shifted by the container's ID mapping. Inside the container the user's home is &lt;code&gt;1000/1000&lt;/code&gt; and system files are &lt;code&gt;0/0&lt;/code&gt;. In the export, the home is &lt;code&gt;0/0&lt;/code&gt;, the 3257 root-owned files are &lt;code&gt;1/1&lt;/code&gt;, and &lt;code&gt;su&lt;/code&gt;, &lt;code&gt;passwd&lt;/code&gt; and the other setuid binaries are setuid to UID 1. The export exits 0 and prints nothing. Import that tarball and the user can't write to its own home directory. &lt;code&gt;podman cp ctr:/ -&lt;/code&gt; produced the same shift. &lt;code&gt;podman commit&lt;/code&gt; maps the owners back: commit the container, create a plain container from the image, and export that. The export code passes no ID mapping to its tar writer, in 5.4.2 and in current main. Reproduced on a Raspberry Pi with Debian's podman 5.4.2. The toolkit's &lt;code&gt;check-podman-export-idmap.sh&lt;/code&gt; lists the containers at risk and checks an export without writing it. Upstream report: &lt;a href="https://github.com/podman-container-tools/podman/issues/29856" rel="noopener noreferrer"&gt;podman-container-tools/podman#29856&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Raspberry Pi 4B, Raspberry Pi OS (Debian 13, arm64), &lt;code&gt;podman 5.4.2&lt;/code&gt; from Debian, rootless as the login user (UID 1000, subuids &lt;code&gt;100000:65536&lt;/code&gt;). An image with one ordinary user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; docker.io/library/debian:13-slim&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; user &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;hello &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /home/user/note.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown &lt;/span&gt;user:user /home/user/note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both ways of running it agree about who owns what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; exporttest &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lan&lt;/span&gt; /home/user
podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--userns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;keep-id exporttest &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lan&lt;/span&gt; /home/user
  drwx------ 1000 1000 &lt;span class="nb"&gt;.&lt;/span&gt;
  &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1000 1000 .bashrc
  &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1000 1000 note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then one container of each kind, exported:&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;c1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;podman create exporttest&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    podman &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; e1.tar   &lt;span class="c"&gt;# rc=0&lt;/span&gt;
&lt;span class="nv"&gt;c2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;podman create &lt;span class="nt"&gt;--userns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;keep-id exporttest&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   podman &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; e2.tar   &lt;span class="c"&gt;# rc=0&lt;/span&gt;

&lt;span class="nb"&gt;tar &lt;/span&gt;tvf e1.tar home                          &lt;span class="nb"&gt;tar &lt;/span&gt;tvf e2.tar home
drwxr-xr-x 0/0       home/                   drwxr-xr-x 1/1       home/
drwx------ 1000/1000 home/user/              drwx------ 0/0       home/user/
&lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1000/1000 home/user/.bashrc       &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 0/0       home/user/.bashrc
&lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1000/1000 home/user/note.txt      &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 0/0       home/user/note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report upstream showed the &lt;code&gt;home/&lt;/code&gt; lines, and the shift doesn't stop there. The owner counts across the whole archive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;e1 (plain):   3257 0/0      7 0/42     5 1000/1000   3 0/43   1 0/8
e2 (keep-id): 3257 1/1      7 1/43     5 0/0         3 1/44   1 1/9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every owner and every group is off by the same mapping. The setuid binaries go with them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;e1: -rwsr-xr-x 0/0 usr/bin/su     e2: -rwsr-xr-x 1/1 usr/bin/su
    -rwsr-xr-x 0/0 usr/bin/passwd     -rwsr-xr-x 1/1 usr/bin/passwd
    ... (8 setuid files, all 0/0)     ... (all 1/1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;podman import e2.tar e2img&lt;/code&gt; succeeds, and the damage shows up only when the image is used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; e2img &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"%u:%g %A %n"&lt;/span&gt; /etc/passwd /etc/shadow /usr/bin/su
  1:1 &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; /etc/passwd
  1:43 &lt;span class="nt"&gt;-rw-r-----&lt;/span&gt; /etc/shadow
  1:1 &lt;span class="nt"&gt;-rwsr-xr-x&lt;/span&gt; /usr/bin/su

podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; user e2img &lt;span class="nb"&gt;touch&lt;/span&gt; /home/user/x
  &lt;span class="nb"&gt;touch&lt;/span&gt;: cannot &lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s1"&gt;'/home/user/x'&lt;/span&gt;: Permission denied
podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; user exporttest &lt;span class="nb"&gt;touch&lt;/span&gt; /home/user/x      &lt;span class="c"&gt;# original image: works&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;su&lt;/code&gt; and &lt;code&gt;passwd&lt;/code&gt; are now setuid to UID 1, which is &lt;code&gt;daemon&lt;/code&gt;, so they no longer do what they are for.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;podman export&lt;/code&gt; is the documented way to get a container's filesystem out as a tarball, and &lt;code&gt;--userns=keep-id&lt;/code&gt; is the standard answer to "my rootless container writes files my host user can't read". Both are common, and nothing in their output hints at a problem when they're combined. The export exits 0 and prints nothing to stderr. The man page doesn't mention owners, user namespaces or ID mappings at all. The tarball has the right files, the right sizes and the right permission bits. Only the numbers in the owner column are wrong, and hardly anyone lists a backup tarball with &lt;code&gt;tar tv&lt;/code&gt; to read that column.&lt;/p&gt;

&lt;p&gt;The problem only surfaces when the archive is restored, in a different container or on a different day, as a permission error that looks like something the image itself got wrong.&lt;/p&gt;

&lt;p&gt;The upstream report came from podman running inside another container on an EL8 kernel, with an ID mapping unlike an ordinary host's. That made it easy to read as an edge case of nested containers. On an ordinary host, with podman as a normal user and the stock subuid range, it happens the same way.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;podman inspect&lt;/code&gt; shows the difference between the two containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain:   IDMappings null
keep-id: UsernsMode private
         UidMap ["0:1:1000", "1000:0:1", "1001:1001:64536"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A keep-id container gets its own user namespace inside rootless podman's namespace. Container UID 0 maps to 1 there, container UID 1000 maps to 0 there (which is the host user), and the rest follows. Its files are stored on disk under those intermediate IDs. You can see it by mounting the container's storage from inside &lt;code&gt;podman unshare&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;podman unshare sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'m=$(podman mount hpmkid); ls -ln $m/home/user/made-at-runtime.txt $m/root/made-by-root.txt'&lt;/span&gt;
  &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1 0 0 ... home/user/made-at-runtime.txt     &amp;lt;- written by UID 1000 &lt;span class="k"&gt;in &lt;/span&gt;the container
  &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1 1 1 ... root/made-by-root.txt             &amp;lt;- written by root &lt;span class="k"&gt;in &lt;/span&gt;the container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The export code (&lt;code&gt;libpod/container_internal.go&lt;/code&gt;) mounts that storage and tars it:&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;input&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;chrootarchive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mountPoint&lt;/span&gt;&lt;span class="p"&gt;,&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;mountPoint&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;nil&lt;/code&gt; is the tar options, which is where an ID mapping would go. None is given, so the archive gets the on-disk IDs, which are the container's IDs pushed through its mapping. For a container with no mapping of its own, on-disk and in-container IDs are the same and nothing is visible. The line is identical in 5.4.2, in 5.8.7 (the version in the report) and in current main.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;podman cp ctr:/ - | tar tv&lt;/code&gt; on the keep-id container showed the same shift: files the container's user owns came out as &lt;code&gt;root/root&lt;/code&gt;, and root's files as &lt;code&gt;daemon/daemon&lt;/code&gt;. That makes &lt;code&gt;podman cp&lt;/code&gt; no workaround here.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;podman commit&lt;/code&gt; does apply the container's mapping. Committing the keep-id container, creating a plain container from the resulting image and exporting that gave correct owners, including for files written while the container ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman commit mycontainer tmp-export
&lt;span class="nv"&gt;c&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;podman create tmp-export&lt;span class="si"&gt;)&lt;/span&gt;
podman &lt;span class="nb"&gt;export&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="o"&gt;&amp;gt;&lt;/span&gt; mycontainer.tar
podman &lt;span class="nb"&gt;rm&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="p"&gt;;&lt;/span&gt; podman rmi tmp-export
&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;(0) podman export hpmkid                      (a) commit -&amp;gt; create -&amp;gt; export
  0/0       home/user/made-at-runtime.txt       1000/1000 home/user/made-at-runtime.txt
  1/1       root/made-by-root.txt               0/0       root/made-by-root.txt
  1/1       usr/bin/su                          0/0       usr/bin/su
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check any export before trusting it. In a normal Linux image &lt;code&gt;etc/passwd&lt;/code&gt; belongs to root, so one line is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman &lt;span class="nb"&gt;export &lt;/span&gt;mycontainer | &lt;span class="nb"&gt;tar &lt;/span&gt;tvf - etc/passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that shows anything other than &lt;code&gt;0/0&lt;/code&gt;, the archive is shifted. An archive already made this way can't be repaired by adding a fixed offset to every owner, because the mapping isn't a single offset: 0 became 1, 1000 became 0, and IDs above 1000 stayed where they were. Re-export it through a commit instead.&lt;/p&gt;

&lt;p&gt;This was measured with &lt;code&gt;--userns=keep-id&lt;/code&gt;. Other options that give a container its own mapping (&lt;code&gt;--userns=auto&lt;/code&gt;, &lt;code&gt;--uidmap&lt;/code&gt;) go through the same export line, but they weren't tested here.&lt;/p&gt;

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

&lt;p&gt;A tarball has two layers, the data and the metadata, and most checks only look at the first. The files are there, the sizes match and the restore succeeds, so it looks like a backup. Check the metadata that matters for a restore — owners, groups, setuid bits — against the source before you rely on the archive. &lt;code&gt;tar tv&lt;/code&gt; on one file whose owner you know is cheap. Finding out from &lt;code&gt;Permission denied&lt;/code&gt; after the original is gone is not.&lt;/p&gt;

&lt;p&gt;The same program already had one case of a copy command that reads the wrong layer of storage without saying so: &lt;a href="https://homelabpostmortem.com/2026/09/26/podman-cp-ignores-volume-subpath-when-the-container-is-stopped/" rel="noopener noreferrer"&gt;&lt;code&gt;podman cp&lt;/code&gt; on a stopped container ignores a volume's &lt;code&gt;subpath=&lt;/code&gt;&lt;/a&gt;. In both cases the copy succeeds, and it copies the storage as it sits on disk, not the view the container has.&lt;/p&gt;

&lt;p&gt;The toolkit's &lt;code&gt;check-podman-export-idmap.sh&lt;/code&gt; lists the current user's containers that have their own ID mapping, and with &lt;code&gt;--verify &amp;lt;container&amp;gt;&lt;/code&gt; streams an export straight into &lt;code&gt;tar tv&lt;/code&gt; and checks that &lt;code&gt;etc/passwd&lt;/code&gt; and &lt;code&gt;usr/bin/su&lt;/code&gt; are root's, writing nothing to disk. It prints the commit-based export for any container that fails.&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>containers</category>
      <category>linux</category>
      <category>backup</category>
    </item>
  </channel>
</rss>
