2026-08-18·~8h

Local LLM Migration — WSL2 GPU Instability Forces a Pivot

WSL2GPULocal LLMvLLMOllamaHomelab

What happened

Set out to move Bob (the homelab's Hermes Agent instance) from Ollama/gpt-oss-20b to vLLM running Gemma 4 12B on an RTX 5080, chasing better tool-calling reliability. Got vLLM fully working after root-causing six separate platform bugs — then hit a service-killing SIGTERM every 20-40 seconds under real load that never got explained, despite ruling out every plausible cause. Abandoned vLLM/WSL2 entirely and shipped the same model (Gemma 4 12B) through Ollama running natively on Windows instead — which turned out to be the better architecture anyway, and along the way surfaced two pre-existing regressions in the gaming-safety system that had been silently broken for the entire session.

Root cause

vLLM inside WSL2 hit a chain of real, independently-diagnosable bugs: missing CUDA toolchain (FlashInfer JIT-compiles kernels at runtime, needs more than the driver), a `transformers` version too new for vLLM's Gemma4 support, CUDA UVA unsupported through WSL2's GPU-PV virtualization layer, and — the one that mattered most — [a confirmed, unpatched Microsoft bug](https://github.com/microsoft/WSL/issues/40732): `dxgkrnl`, the WSL2↔GPU bridge driver, corrupts its own virtualization state under repeated near-OOM allocation retries on Blackwell (RTX 5080/5090), eventually crashing the entire lightweight VM. Two more platform issues compounded it: WSL2's `vmIdleTimeout` doesn't count an in-VM systemd service as "in use," so the VM was tearing itself down on a timer; and WSL2's auto-DNS routed through Tailscale's MagicDNS, so a DNS hiccup there triggered WSL2's own network health-check to restart the VM. All of that got fixed. What didn't get explained: even after the dxgkrnl timing fix, the DNS fix, and a boot-race fix, `vllm.service` kept dying to an external SIGTERM on a near-fixed 20-40s cycle under real use. Ruled out, one at a time: the `systemctl` CLI (wrapped the binary, caught nothing), D-Bus `StopUnit`/`KillUnit` calls (monitored the systemd bus directly, caught nothing), cron, systemd timers, the gaming-safety agent, Windows sleep settings, `unattended-upgrades` auto-reboot, and systemd's own `TimeoutStartSec`. A full Windows reboot didn't fix it either. The trigger was never found. Separately — and only discovered because "does the gaming-safety system still work?" turned out to be worth actually checking instead of assuming — two real regressions had been live the whole time. The Task Scheduler entry for the GPU-monitoring agent was set to `LogonType: InteractiveToken`, which silently blocks *every* trigger (not just "at logon") unless an actual interactive desktop session exists; none of the session's several SSH-triggered reboots counted, so the agent hadn't run since before the session started. And once it was running again, its game-detection was flapping on transient GPU processes under heavy inference load — each flap is treated as a real state transition, and each transition force-recreates the Hermes container, killing whatever conversation was in progress. Confirmed: four container restarts in under three minutes, matching messages seen live mid-conversation.

Fix

Abandoned vLLM on WSL2 rather than keep chasing an unexplained SIGTERM against a platform with an already-confirmed upstream GPU-virtualization bug. Pivoted to the same model, Gemma 4 12B, served through Ollama running natively on Windows — no WSL2, no GPU-PV, none of the above applies. Wired through the existing `gpu-proxy` layer (model-alias mapping, gaming-safety integration, Wake-on-LAN) rather than pointing Hermes at Ollama directly, so none of that infrastructure had to be rebuilt. Two bugs specific to this path also needed fixing: Ollama's runtime context length defaults to a VRAM-scaled heuristic independent of what the model or the proxy layer report, so Hermes' 64K-context floor kept rejecting the model until `OLLAMA_CONTEXT_LENGTH` was set explicitly (and, after an initial miss, set *persistently*, not just for the one shell session that happened to launch it); and Gemma 4's "thinking" mode doesn't reliably emit a clean stop token, so a first tool-call test generated over 39,000 tokens for a two-word prompt before being killed by hand — fixed with a hard clamp on `max_tokens`/`num_predict` at the proxy layer, regardless of whatever value the client already sent (the first version of that clamp only filled in the field if it was absent, which didn't help since it wasn't). The two gaming-safety regressions got fixed independently of the vLLM work: the scheduled task's logon type moved to `S4U` (runs elevated, unattended, survives non-interactive reboots), and the detection agent now requires a candidate state to persist for three consecutive polls (~15s) before it's acted on — real, sustained gaming activity is still caught quickly; a single-poll blip no longer kills Hermes mid-conversation. Fully removed afterward: the vLLM service and its Python environment, the CUDA toolkit and build dependencies, a duplicate model cache, and every WSL2-specific network/firewall rule — about 20GB of WSL2 disk footprint down to 6GB. Left in place: the `vmIdleTimeout` and DNS fixes, since WSL2 itself is still installed and there's no reason to leave it fragile if it's ever used for something else.

Prevention

Not every platform bug is worth fixing forward through. Six root-caused bugs in, the seventh (the SIGTERM mystery) was a reasonable point to step back and ask whether the actual goal — reliable tool-calling — required *this specific* platform at all, rather than continuing to sink time into an environment with an already-confirmed, unpatched upstream instability. It didn't; native Ollama got the same model working with none of the fragility. The gaming-safety regressions are the sharper lesson: two failures that had nothing to do with the night's actual work were both live for the entire session, invisible until someone asked "does this still work?" instead of assuming it did. Both were silent-failure modes by design — a scheduled task that reports "Ready" while unable to ever fire, and a fallback-toggle mechanism that behaves exactly as designed on every individual flap while producing outright disruptive behavior in aggregate. Neither would have been caught by a status check that only looks at whether a service is nominally enabled; both needed someone to actually watch it run.

More postmortems