Skip to content

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

Open
kirtimanmishrazipstack wants to merge 1 commit into
mainfrom
UN-4001-gunicorn-thread-exhaustion
Open

UN-3996 [FIX] Stop WebSockets exhausting the Gunicorn thread pool#2241
kirtimanmishrazipstack wants to merge 1 commit 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/threads/worker-connections are now env-driven, with the thread default raised from 2 to 512.
  • backend/entrypoint.sh: --log-level reads DEFAULT_LOG_LEVEL instead of being hardcoded to debug; --worker-class gthread is stated explicitly.
  • backend/sample.env: documents the three new optional variables.

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 ThreadPoolExecutor spawns threads lazily on submit.
  • worker-connections stays at 1000: the excess over --threads is gunicorn's keep-alive budget (gthread.py:73), so collapsing it onto the thread count would disable HTTP keep-alive.

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, all three variables are optional, and no application code is touched. Log volume drops because --log-level debug was previously unconditional including in production mode; set DEFAULT_LOG_LEVEL=DEBUG to restore it.

Database Migrations

  • None.

Env Config

  • GUNICORN_WORKERS (optional, default 2).
  • GUNICORN_THREADS (optional, default 512) — must exceed concurrent browser tabs per worker.
  • GUNICORN_WORKER_CONNECTIONS (optional, default 1000).
  • Existing DEFAULT_LOG_LEVEL (default INFO) now also drives Gunicorn's --log-level.

Relevant Docs

  • None.

Related Issues or PRs

  • Jira: UN-3996.
  • Companion cloud PR: Zipstack/unstract-cloud#1724 — independent, no merge ordering constraint.

Dependencies Versions

  • None.

Notes on Testing

  • bash -n passes; argument array expanded and inspected with no env set and with all four variables overridden.
  • Gunicorn accepts the uppercase level — loglevel uses validate_string and glogging.py:196 lowercases before lookup.
  • 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>
@sonarqubecloud

Copy link
Copy Markdown

@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

The PR makes Gunicorn worker, thread, connection, and logging settings environment-driven to prevent Socket.IO WebSockets from exhausting the default thread pool.

  • Raises the default thread ceiling from 2 to 512 and explicitly selects the gthread worker.
  • Preserves a 488-connection HTTP keep-alive budget with 1,000 worker connections.
  • Documents the new optional Gunicorn environment variables 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 Configures Gunicorn capacity and logging through optional environment variables while preserving the intended keep-alive budget.
backend/sample.env Documents defaults that match the Gunicorn arguments configured by the entrypoint.

Fix All in Greploop

Reviews (2): Last reviewed commit: "UN-4001 [FIX] Stop Socket.IO WebSockets ..." | Re-trigger Greptile

Comment thread backend/entrypoint.sh
@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
@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 16.5
e2e-coowners e2e 1 0 0 0 1.5
e2e-etl e2e 1 0 0 0 8.0
e2e-login e2e 2 0 0 0 1.0
e2e-prompt-studio e2e 1 0 0 0 4.5
e2e-smoke e2e 2 0 0 0 2.1
e2e-workflow e2e 1 0 0 0 16.0
integration-backend integration 267 0 0 26 46.9
integration-connectors integration 1 0 0 7 8.5
integration-workers integration 140 0 0 1 50.8
unit-backend unit 998 0 0 1 31.4
unit-connectors unit 63 0 0 0 10.8
unit-core unit 33 0 0 0 1.1
unit-platform-service unit 15 0 0 0 2.4
unit-rig unit 117 0 0 0 4.4
unit-sdk1 unit 480 0 0 0 19.9
unit-workers unit 1335 0 0 1 79.5
TOTAL 3460 0 0 36 305.3

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

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
--threads 2
--log-level debug
--workers "${GUNICORN_WORKERS:-2}"
--threads "${GUNICORN_THREADS:-512}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kirtimanmishrazipstack a default 512 threads might eatup your local machine memory and cpu. I believe that is why it was left 2 deliberately in the beginning. @muhammad-ali-e would be better person to answer.

What I would suggest is a bit more conservative default value something like 8 or 16.

Also if any other similar services need such a configurability please do include that as well.

Comment thread backend/entrypoint.sh
--threads "${GUNICORN_THREADS:-512}"
--worker-class gthread
--worker-connections "${GUNICORN_WORKER_CONNECTIONS:-1000}"
--log-level "${DEFAULT_LOG_LEVEL:-INFO}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kirtimanmishrazipstack I will suggest using a different env for this log level. There might be places when we need gunicorn at debug log level but not the application

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