think
16px
820px

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:

  1. 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.
  2. Bound the wait. With def handlers 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.py in tidyup master is byte-identical to what runs
    on ai-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/extract and /pendirian-pp/surat-pernyataan/extract).
    sync-from-repo.sh gpu-server syncs repo/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:

  1. Fire ~5 concurrent POST /summarize requests with a large document_text.
  2. While they are in flight, GET /health from a separate connection.
  3. Before the fix: /health blocks until the summaries finish.
    After: /health answers in milliseconds throughout.
  4. 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.