Skip to content

UN-3996 [FIX] Stop WebSockets exhausting the Gunicorn thread pool - #2241

Merged
kirtimanmishrazipstack merged 5 commits into
mainfrom
UN-4001-gunicorn-thread-exhaustion
Aug 17, 2026
Merged

UN-3996 [FIX] Stop WebSockets exhausting the Gunicorn thread pool#2241
kirtimanmishrazipstack merged 5 commits into
mainfrom
UN-4001-gunicorn-thread-exhaustion

Conversation

@kirtimanmishrazipstack

@kirtimanmishrazipstack kirtimanmishrazipstack commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

  • backend/entrypoint.sh: Gunicorn workers and threads are now env-driven, with the thread default raised from 2 to 512.
  • backend/entrypoint.sh: --worker-class gthread is stated explicitly.
  • backend/sample.env: ships both variables set to their defaults, so the knob is visible in the file every deployment copies.

Why

  • The Socket.IO WSGI app runs with async_mode="threading", so every open WebSocket holds one gthread pool thread for that connection's lifetime rather than for a single request.
  • With --threads 2, two logged-in browser tabs landing on the same worker left it with zero threads for HTTP, and it kept accepting connections it could never read — so requests stalled silently instead of failing over.
  • Reported as Workflows / Prompt Studio / Agentic Prompt Studio loading very slowly, and far worse with two concurrent logins.

How

  • Verified chain: frontend/src/index.jsx:64 mounts one SocketProvider per tab → backend/backend/wsgi.py:42 wraps Django in the Socket.IO app (async_mode="threading", utils/log_events.py:29-31) → engineio/socket.py:222 runs the socket loop inline on a pool thread sized by --threads (gunicorn/workers/gthread.py:98).
  • 512 matches the value already running in the cloud chart and is free at idle, since --threads is a ThreadPoolExecutor ceiling and CPython only starts a thread on submit() when none is idle.
  • --worker-class gthread was already in effect — config.py:109-110 coerces sync to gthread whenever threads > 1 — so stating it is a no-op that just removes the ambiguity.
  • worker-connections is deliberately left alone. 1000 is already Gunicorn's default (config.py:732), so passing it explicitly would change nothing, and making it settable only adds a way to configure it below GUNICORN_THREADS, which drives max_keepalived negative (gthread.py:73) and force-closes every response (gthread.py:330).

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

No — the change is confined to Gunicorn CLI arguments, both variables fall back to the values already in use, and no application code is touched. The only behavioural change is that a worker can now serve more than two concurrent connections.

Database Migrations

  • None.

Env Config

  • GUNICORN_WORKERS (default 2, unchanged from before).
  • GUNICORN_THREADS (default 512, was a hardcoded 2) — must exceed concurrent browser tabs per worker.
  • Both are set to these values in backend/sample.env and reach the container through the existing env_file: ../backend/.env on the backend service. The entrypoint keeps a :- fallback per variable, so an .env that predates this PR starts with the same values.

Relevant Docs

  • None.

Related Issues or PRs

  • Jira: UN-3996.
  • Zipstack/unstract-cloud#1724 — closed. The cloud chart already runs --threads 512, so no cloud change is needed.
  • Request id in logs and docker memory optimizations #338 lowered this value from 4 to 2 as part of a local-memory cleanup. The line it changed carried a # NOTE updated socket threads comment, added in the same commit that introduced the Socket.IO threading mode.

Dependencies Versions

  • None.

Notes on Testing

  • bash -n passes; argument array expanded and inspected with no env set and with both variables overridden.

  • Measured locally with backend/.venv (Python 3.12.9) — --threads is a ceiling, not an allocation, so a high default is free until the connections exist:

    scenario threads alive RSS delta
    ceiling 512, at construction 0 0
    50 sequential tasks 1
    20 concurrent blocking tasks 20
    all 512 forced live 512 +11.8 MB

    CPU with 512 threads parked: 0.005% of one core over 3s. VmSize grows ~12.5 GB at 512 live threads (glibc reserves an 8 MB stack per thread from RLIMIT_STACK), but that is reserved address space rather than RSS.

  • Repro for reviewers: with --threads 2, open two logged-in tabs and loop GET /api/v1/health; one worker stops responding while ss -tanp | grep :8000 shows ESTABLISHED sockets with non-zero Recv-Q and an empty LISTEN backlog.

Screenshots

N/A — no user-facing surface.

Checklist

I have read and understood the Contribution Guidelines.

…thread pool

The Socket.IO WSGI app runs with async_mode="threading", so every open
WebSocket holds one gthread pool thread for that connection's lifetime rather
than for a single request. With --threads 2, two browser tabs landing on the
same worker left it with no threads for HTTP, and the worker kept accepting
connections it could never read.

Make workers, threads and worker-connections environment-configurable and
raise the default thread ceiling to 512, matching the value already used by
the cloud chart. The pool spawns threads lazily, so the higher ceiling costs
nothing at idle. Drive --log-level from DEFAULT_LOG_LEVEL instead of
hardcoding debug, and state --worker-class gthread explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-4001 [FIX] Stop Socket.IO WebSockets from exhausting the Gunicorn … UN-4001 [FIX] Prevent Socket.IO WebSockets from exhausting the backend Gunicorn thread pool Aug 13, 2026
@kirtimanmishrazipstack
kirtimanmishrazipstack marked this pull request as ready for review August 13, 2026 08:29
@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes Gunicorn worker and thread counts configurable, raises the default thread ceiling to accommodate long-lived Socket.IO connections, and explicitly selects the gthread worker class.

  • Adds GUNICORN_WORKERS and GUNICORN_THREADS defaults to the backend startup command.
  • Documents the corresponding deployment settings in backend/sample.env.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
backend/entrypoint.sh Makes worker and thread counts environment-driven, raises the thread default to 512, and explicitly configures the gthread worker class.
backend/sample.env Exposes the new Gunicorn worker and thread settings with defaults matching the entrypoint.

Reviews (6): Last reviewed commit: "Merge branch 'main' into UN-4001-gunicor..." | Re-trigger Greptile

Comment thread backend/entrypoint.sh Outdated
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-4001 [FIX] Prevent Socket.IO WebSockets from exhausting the backend Gunicorn thread pool UN-4001 [FIX] Stop WebSockets exhausting the Gunicorn thread pool Aug 13, 2026
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps please re-review.

The one open finding (backend/entrypoint.sh:40-42) has been answered inline rather than patched: worker-connections == threads would set max_keepalived = 0 (gthread.py:73), tripping gunicorn's own "No keepalived connections can be handled" warning (gthread.py:86-89) and force-closing every response (gthread.py:330), which disables HTTP keep-alive. The 1000 - 512 = 488 excess is that keep-alive budget, so the current values are intentional.

@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-4001 [FIX] Stop WebSockets exhausting the Gunicorn thread pool UN-3996 [FIX] Stop WebSockets exhausting the Gunicorn thread pool Aug 13, 2026
Comment thread backend/entrypoint.sh
Comment thread backend/entrypoint.sh Outdated
kirtimanmishrazipstack and others added 4 commits August 17, 2026 12:13
Revert --log-level back to the hardcoded debug so this change is scoped to
the thread pool alone. Reusing DEFAULT_LOG_LEVEL conflated Django's
application log level with Gunicorn's, which are independent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were commented out, unlike the other 110 settings in the file. Leaving
them commented hid the knob, which was the original complaint — there was no
discoverable way to change the thread count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1000 is already Gunicorn's default for worker_connections, so passing it
changed nothing. Making it configurable only added a way to set it below
GUNICORN_THREADS, which silently disables HTTP keep-alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kirtimanmishrazipstack
kirtimanmishrazipstack requested review from chandrasekharan-zipstack and ritwik-g and removed request for ritwik-g August 17, 2026 08:04
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 17.0
e2e-coowners e2e 1 0 0 0 1.5
e2e-etl e2e 1 0 0 0 8.8
e2e-login e2e 2 0 0 0 1.4
e2e-prompt-studio e2e 1 0 0 0 4.7
e2e-smoke e2e 2 0 0 0 1.5
e2e-workflow e2e 1 0 0 0 16.4
integration-backend integration 267 0 0 26 46.1
integration-connectors integration 1 0 0 7 8.2
integration-workers integration 140 0 0 1 51.3
unit-backend unit 998 0 0 1 41.5
unit-connectors unit 63 0 0 0 10.3
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 117 0 0 0 5.5
unit-sdk1 unit 518 0 0 0 23.0
unit-workers unit 1346 0 0 1 102.8
TOTAL 3509 0 0 36 344.2

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@kirtimanmishrazipstack
kirtimanmishrazipstack merged commit 3be7483 into main Aug 17, 2026
10 checks passed
@kirtimanmishrazipstack
kirtimanmishrazipstack deleted the UN-4001-gunicorn-thread-exhaustion branch August 17, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants