Environment
- Image:
unified-cuda-2026-06-21
- Upstream in question is not an LLM: a ComfyUI container registered as a
swap-group member so llama-swap arbitrates a single GPU between it and the
chat models. It serves a browser SPA (aiohttp) rather than an OpenAI API.
- Config shape (trimmed):
models:
"comfyui":
cmd: /bin/sh /scripts/comfyui-start.sh # starts a container, blocks on docker /wait
cmdStop: /bin/sh /scripts/comfyui-stop.sh
proxy: "http://comfyui:8188"
checkEndpoint: "/system_stats"
ttl: 600
concurrencyLimit: 128
groups:
"chat":
swap: true
exclusive: false
members: ["qwen3.6-27b", "...", "comfyui"]
Both issues are independent of the container-based cmd; they are properties of
the proxy layer.
1. The proxy decodes %2F in the upstream path
An encoded slash in the path is decoded before the request is forwarded, so an
upstream route that matches a single path segment stops matching.
Concretely: ComfyUI's route is /userdata/{file}, and aiohttp's {file}
matches one segment. Its frontend requests
/userdata/workflows%2Fmy-workflow.json. Through llama-swap the upstream sees
/userdata/workflows/my-workflow.json and returns 404, so the UI can list
saved workflows but can never open one.
Isolation — same target, four paths:
| Request |
Result |
direct to the upstream container, %2F |
200 |
direct to the upstream container, literal / |
404 |
llama-swap on loopback (no other proxy in front), %2F |
404 |
via a reverse proxy → llama-swap, %2F |
404 |
via a reverse proxy → llama-swap, double-encoded %252F |
200 |
via a reverse proxy straight to the container (llama-swap bypassed), %2F |
200 |
The loopback row rules out anything in front of llama-swap. The %252F row
shows exactly one decode is being applied.
Ask: forward the raw RequestURI rather than a decoded/re-encoded path, so
percent-encoded separators survive. In Go's net/http/httputil, this is the
difference between assigning req.URL.Path and preserving
req.URL.RawPath / req.URL.Opaque when rewriting the director's target.
2. WebSocket connections are counted as permanently in-flight
A long-lived WebSocket to a group member appears to count as an open request for
the lifetime of the connection. Consequences observed:
- The member never drains, so
ttl never fires and the model is never unloaded.
- Because the group is
swap: true, every request for any other member of
the same group blocks until timeout and then returns HTTP 200 with an empty
body. From a client's perspective the whole endpoint is down, with a success
status code.
- Stopping the upstream out-of-band does not recover it: the browser reconnects
and llama-swap starts the member again within ~60 s.
Measured on this host:
| Observation |
Value |
Longest single held /ws (one forgotten browser tab) |
7h29m17s — logged only when it finally closed |
| Another, earlier |
1h58m |
POST /v1/chat/completions for a different group member while held |
200, body length 0, after 6m40s |
| Same request once the WebSocket was gone |
200 in 1.3s |
| Container stopped by hand, then left alone |
restarted by llama-swap in ~60 s, WS re-established |
Note for anyone debugging this: llama-swap logs a request on completion, so
an open WebSocket is invisible in the logs while it is the thing causing the
problem. The /ws line above only appeared at 7h29m17s once the socket died.
Counting established sockets on the upstream's port is the only reliable check.
Ask: either exclude upgraded connections from the in-flight count used for
drain/ttl decisions, or provide a configurable drain that force-closes
upgraded connections when another member of the group needs to load. A
ttl-exempt-but-evictable state would also solve it.
Why this matters together
The two combine badly for any non-LLM upstream that serves a browser UI: the UI
is unusable for its main purpose (issue 1), and merely leaving its tab open
takes down every other model in the group (issue 2). The workaround deployed
here was to route browser traffic around llama-swap entirely — a separate
reverse-proxy route straight to the container, with llama-swap keeping the
API path and remaining the only component that starts/stops the upstream. That
works, but it means llama-swap no longer sees the UI's traffic at all, so its
ttl had to be disabled for that member.
Environment
unified-cuda-2026-06-21swap-group member so llama-swap arbitrates a single GPU between it and the
chat models. It serves a browser SPA (aiohttp) rather than an OpenAI API.
Both issues are independent of the container-based
cmd; they are properties ofthe proxy layer.
1. The proxy decodes
%2Fin the upstream pathAn encoded slash in the path is decoded before the request is forwarded, so an
upstream route that matches a single path segment stops matching.
Concretely: ComfyUI's route is
/userdata/{file}, and aiohttp's{file}matches one segment. Its frontend requests
/userdata/workflows%2Fmy-workflow.json. Through llama-swap the upstream sees/userdata/workflows/my-workflow.jsonand returns 404, so the UI can listsaved workflows but can never open one.
Isolation — same target, four paths:
%2F/%2F%2F%252F%2FThe loopback row rules out anything in front of llama-swap. The
%252Frowshows exactly one decode is being applied.
Ask: forward the raw
RequestURIrather than a decoded/re-encoded path, sopercent-encoded separators survive. In Go's
net/http/httputil, this is thedifference between assigning
req.URL.Pathand preservingreq.URL.RawPath/req.URL.Opaquewhen rewriting the director's target.2. WebSocket connections are counted as permanently in-flight
A long-lived WebSocket to a group member appears to count as an open request for
the lifetime of the connection. Consequences observed:
ttlnever fires and the model is never unloaded.swap: true, every request for any other member ofthe same group blocks until timeout and then returns
HTTP 200with an emptybody. From a client's perspective the whole endpoint is down, with a success
status code.
and llama-swap starts the member again within ~60 s.
Measured on this host:
/ws(one forgotten browser tab)POST /v1/chat/completionsfor a different group member while held200, body length 0, after 6m40s200in 1.3sNote for anyone debugging this: llama-swap logs a request on completion, so
an open WebSocket is invisible in the logs while it is the thing causing the
problem. The
/wsline above only appeared at7h29m17sonce the socket died.Counting established sockets on the upstream's port is the only reliable check.
Ask: either exclude upgraded connections from the in-flight count used for
drain/
ttldecisions, or provide a configurable drain that force-closesupgraded connections when another member of the group needs to load. A
ttl-exempt-but-evictable state would also solve it.Why this matters together
The two combine badly for any non-LLM upstream that serves a browser UI: the UI
is unusable for its main purpose (issue 1), and merely leaving its tab open
takes down every other model in the group (issue 2). The workaround deployed
here was to route browser traffic around llama-swap entirely — a separate
reverse-proxy route straight to the container, with llama-swap keeping the
API path and remaining the only component that starts/stops the upstream. That
works, but it means llama-swap no longer sees the UI's traffic at all, so its
ttlhad to be disabled for that member.