Integration prompt — ahu-ocr-tidyup: unblock the gpu-server event loop
For a Claude Code session in ahu-ocr-tidyup (branch master).
Raised by the ahu-ai-chatbot session after the 2026-08-21 incident on ai-ahu.
Full analysis: https://x056.think.val.id/2026-08-21-gpu-server-close-wait-upstream.md
What happened
ahu-gpu-server ran for ~1 hour accepting connections and answering nobody:
122 consecutive healthcheck failures, 438 sockets stuck in CLOSE_WAIT on :8000,
CPU 0.00%, celery queue empty. Every client — in-container, bridge, and
host-published — timed out. A restart cleared it instantly. OCR akta/KTP
extraction was down for that hour.
Root cause
gpu-server/app/main.py — two async def handlers make a synchronous
OpenAI call:
| endpoint | async def |
client built | blocking call |
|---|---|---|---|
POST /summarize |
line 448 | 472 | 479 |
POST /cek-bukti |
line 763 | 787 | 794 |
@app.post("/summarize")
async def summarize(body: SummarizeRequest) -> dict[str, str]:
...
client = OpenAI(base_url=settings.vllm_base_url, api_key="not-needed", timeout=120.0)
response = client.chat.completions.create(...) # ← blocks the event loop
The Dockerfile runs uvicorn app.main:app with one process, one event loop,
no --workers. An async def handler runs on that loop, so a blocking
socket read inside it freezes the whole server: no other request is served, no
queued response is written, and no socket-close event is processed. With
timeout=120.0, one slow vLLM call holds the server for two minutes; a few
queued hold it for far longer.
That explains every symptom: CLOSE_WAIT means "peer closed, app never did"
(the loop never ran the close callback); only 27 fds were held by pid 1, so
sockets were orphaned kernel-side rather than leaked; CPU 0% because it was
blocked on I/O, not spinning; and the GET /health 200 OK lines in the log
are the backlog draining between stalls. /health is trivial and non-blocking
— it was collateral damage, not the fault.
The fix
Drop async from both handlers. FastAPI/Starlette runs a plain def endpoint
in an anyio worker thread, leaving the loop free:
@app.post("/summarize")
def summarize(body: SummarizeRequest) -> dict[str, str]: # was: async def
@app.post("/cek-bukti")
def cek_bukti(body: CekBuktiRequest) -> dict[str, object]: # was: async def
Verified safe before proposing: neither body contains a single await
(grep -c "await " → 0 across both), so nothing breaks in the conversion.
Two follow-ons worth doing in the same edit:
- Hoist the client.
OpenAI(...)is constructed per request, so every
call builds a fresh httpx pool + TCP connection to vLLM. Build once at
module scope, reuse. - Bound the wait. With
defhandlers the default anyio pool is 40
threads, so 40 concurrent 120s summaries still queue. Either lower
timeout(120s outlives any caller — the OCR backend gives up first) or
bound concurrency explicitly.
AsyncOpenAI + await is the more idiomatic alternative, but it is a larger
diff and changes error handling. The def conversion is one word per handler
and provably equivalent.
Repo facts (checked, so you do not have to)
gpu-server/app/main.pyin tidyup master is byte-identical to what runs
onai-ahu(837 lines). No reconciliation needed — fix master and it is the
same code that is live.- ⚠ The host has a stale checkout at
/home/efran/ahu-ai/repo/(784 lines,
missing/jual-beli/extractand/pendirian-pp/surat-pernyataan/extract).
sync-from-repo.sh gpu-serversyncsrepo/gpu-server/ → gpu-server/, so
running it today would revert two live endpoints. Refresh that checkout
(git -C /home/efran/ahu-ai/repo pull) before any sync. - Deploy after merge: on
ai-ahu,cd /home/efran/ahu-ai && docker compose up -d --build gpu-server.
How to verify the fix
The wedge is a concurrency property, so test it as one:
- Fire ~5 concurrent
POST /summarizerequests with a largedocument_text. - While they are in flight,
GET /healthfrom a separate connection. - Before the fix:
/healthblocks until the summaries finish.
After:/healthanswers in milliseconds throughout. - Then
docker exec ahu-gpu-server sh -c "awk 'NR>1 && \$2 ~ /:1F40/ {print \$4}' /proc/net/tcp | sort | uniq -c"
—08(CLOSE_WAIT) should not accumulate.
Already mitigated on the platform side (do not duplicate)
ops/autoheal.sh + label autoheal: "true" on gpu-server (deploy repo commit
9e1d768) restarts the container when Docker reports it unhealthy: cron every
minute, skips containers younger than 300s, gives up after 6 restarts/day.
That bounds a recurrence to ~2 minutes instead of an hour — it treats the
symptom. This fix removes the cause.