[GH-ISSUE #41] ISSUE: Terminal Orchestrator EXTREMELY SLOW and CPU bound at growing user bases - unusable speeds, CPU bound, permanently at 100% CPU usage (one full core) #18

Closed
opened 2026-10-18 21:13:25 +00:00 by bsakaguchi · 5 comments
Owner

Originally created by @Classic298 on GitHub (Jul 15, 2026).
Original GitHub issue: https://github.com/open-webui/terminals/issues/41

at many many hundreds of containers, it's CPU usage is permanently bound to 100% and never less. Completely CPU bottlenecked.

Something about the code is very inefficient.

What makes it worse, you cant scale the orchestrator itself.

The user containers themselves dont use that much CPU, but the orchestrator is permanently bound to 100% and the Terminal User interface in Open WebUI is noticably VERY slow and the Orchestrator, as per my debugging and log analysis, also takes multiple seconds to answer simple list files requests.

Originally created by @Classic298 on GitHub (Jul 15, 2026). Original GitHub issue: https://github.com/open-webui/terminals/issues/41 at many many hundreds of containers, it's CPU usage is permanently bound to 100% and never less. Completely CPU bottlenecked. Something about the code is very inefficient. What makes it worse, you cant scale the orchestrator itself. The user containers themselves dont use that much CPU, but the orchestrator is permanently bound to 100% and the Terminal User interface in Open WebUI is noticably VERY slow and the Orchestrator, as per my debugging and log analysis, also takes multiple seconds to answer simple list files requests.
Author
Owner

@owui-terminator[bot] commented on GitHub (Jul 15, 2026):

🔍 Related Issues Found

I found some existing issues that might be related. Please check if any of these are duplicates or contain helpful solutions:

  1. 🟢 #39 issue: policy doesnt trigger for constantly running containers
    This issue is about the orchestrator never timing out constantly running containers, which points to scaling/runtime behavior in the orchestrator under many active containers. It is not the same symptom, but it touches the same orchestrator load and lifecycle behavior that could contribute to heavy CPU use.
    by Classic298

  2. 🟢 #36 issue: Terminal system prompt is never injected for orchestrator connections (/system fetch omits X-User-Id)
    This issue involves the orchestrator proxy path and repeated failed fetches through /p/{policy_id}/*, which is adjacent to the slow orchestrator requests described here. While the main bug is missing headers, it is still relevant because it exercises the same request path and backend logic that could impact latency under load.
    by Classic298


💡 If your issue is a duplicate, please close it and add any additional details to the existing issue instead.

This comment was generated automatically. React with 👍 if helpful, 👎 if not.

<!-- gh-comment-id:4979125630 --> @owui-terminator[bot] commented on GitHub (Jul 15, 2026): <!-- terminator-bot:related-issues-reply --> 🔍 **Related Issues Found** I found some existing issues that might be related. Please check if any of these are duplicates or contain helpful solutions: 1. 🟢 [#39](https://github.com/open-webui/terminals/issues/39) **issue: policy doesnt trigger for constantly running containers** *This issue is about the orchestrator never timing out constantly running containers, which points to scaling/runtime behavior in the orchestrator under many active containers. It is not the same symptom, but it touches the same orchestrator load and lifecycle behavior that could contribute to heavy CPU use.* *by Classic298* 2. 🟢 [#36](https://github.com/open-webui/terminals/issues/36) **issue: Terminal system prompt is never injected for orchestrator connections (`/system` fetch omits `X-User-Id`)** *This issue involves the orchestrator proxy path and repeated failed fetches through `/p/{policy_id}/*`, which is adjacent to the slow orchestrator requests described here. While the main bug is missing headers, it is still relevant because it exercises the same request path and backend logic that could impact latency under load.* *by Classic298* --- 💡 If your issue is a duplicate, please close it and add any additional details to the existing issue instead. *This comment was generated automatically.* React with 👍 if helpful, 👎 if not.
Author
Owner

@Classic298 commented on GitHub (Jul 15, 2026):

https://github.com/user-attachments/assets/1541dc05-772e-484e-ac09-d166133a24e0

Unacceptably slow - and the process is CONSTANTLY bound at 100%

Image
<!-- gh-comment-id:4979197387 --> @Classic298 commented on GitHub (Jul 15, 2026): https://github.com/user-attachments/assets/1541dc05-772e-484e-ac09-d166133a24e0 Unacceptably slow - and the process is CONSTANTLY bound at 100% <img width="1379" height="36" alt="Image" src="https://github.com/user-attachments/assets/2e7cd108-7977-49c8-8af6-981caf1ed5c2" />
Author
Owner

@Classic298 commented on GitHub (Jul 15, 2026):

Orchestrator CPU bottleneck at ~500 concurrent terminals: single-core ceiling, per-frame WS compression, per-request Docker inspects (+ related bugs found while investigating)

Environment

  • Terminals main (v0.0.5), Docker backend
  • Single host: 24 cores / 2 sockets, 200 GB RAM
  • 500–600 concurrent per-user Open Terminal containers
  • Symptom: the terminals serve process is permanently pinned at exactly ~100% CPU (one full core) in htop while dockerd sits at ~2%. Terminal I/O latency degrades for all users; the process cannot use a second core.

"Exactly 100%" is the tell: the orchestrator is one Python process, so the GIL caps it at ~1 core, and the entire proxy data plane runs on that one asyncio event loop. Everything below was found by reading the code and verified against library sources; line references are against current main.


A. Hot-path performance issues

A1. WebSocket permessage-deflate is enabled by default on BOTH proxy legs (dominant CPU cost)

  • Client leg: cli.py:60 calls uvicorn.run(...) without ws_per_message_deflate, and uvicorn's default is True.
  • Upstream leg: routers/proxy.py:497 calls websockets.connect(upstream_url), and the websockets default is compression="deflate".

Every terminal frame — every keystroke echo, every chunk of command output, for every session — is zlib-compressed and decompressed in pure Python, twice (once per leg). Terminal traffic is high-frequency small frames, the worst case for per-frame deflate (per-frame CPU overhead, negligible bandwidth win on a LAN/localhost hop). At hundreds of concurrent sessions this dominates the event loop.

Suggested fix: disable compression by default on both legs (ws_per_message_deflate=False, compression=None), with an opt-in setting for deployments where clients are on slow links.

A2. Every proxied HTTP request performs two Docker inspect calls

Backend.ensure_terminal()'s fast path calls status() on every request (backends/base.py:117), and DockerBackend.status() does containers.get(instance_id) + container.show() (backends/docker.py:318-319) — two identical GET /containers/{id}/json round trips, each returning a ~20 KB JSON document parsed in Python. The proxy hot path (_resolve_instance) therefore costs 2 Docker API calls + 2 large JSON parses per request, per user, before a single byte is proxied.

Suggested fix: trust a confirmed-running status for a short TTL (and/or subscribe to the Docker events stream to invalidate on die/stop — see C3), invalidating on proxy connection failure so a dead container is still re-detected promptly.

A3. JWT mode: one HTTP round trip to Open WebUI per proxied request

When TERMINALS_OPEN_WEBUI_URL is set, verify_api_key calls validate_token() on every request (routers/auth.py:74), which issues GET {open_webui}/api/v1/auths/ (routers/auth.py:38) with no caching. Every proxied request pays a full network round trip plus JSON parse before touching the terminal. Same on WS connects (routers/proxy.py:451).

Suggested fix: short-TTL token-validation cache (keyed by token hash), or accept locally-verifiable signed tokens.

A4. Request bodies are fully buffered in memory

_proxy_request reads the entire request body up front for retry purposes (routers/proxy.py:150). Large file uploads through /files/* are buffered fully in orchestrator RAM instead of streamed, costing memory and latency at concurrency.

Suggested fix: stream bodies for requests above a size threshold and restrict retries to requests with replayable bodies.

A5. RequestIdMiddleware uses Starlette's BaseHTTPMiddleware

middleware.py:10 — BaseHTTPMiddleware is a known per-request overhead source (it wraps every request in an additional task/anyio machinery) and it sits on the hottest path in the system. A pure-ASGI middleware does the same job for near zero cost.

A6. Minor: _wait_until_ready builds a new httpx.AsyncClient per poll iteration

backends/docker.py:172 constructs and tears down a client every 0.5 s while waiting for a container to become healthy. Provisioning-path only, but trivially avoidable.

A7. Minor: duplicate inspects in discovery paths

_extract_instance_info always calls container.show() (backends/docker.py:189) even when the caller already holds the inspect payload — e.g. reconcile() inspects at docker.py:239 and then _extract_instance_info re-inspects at docker.py:263, doubling Docker API calls for every container on startup.


B. Correctness bugs found while investigating

B1. active_ws_connections counter leaks on failed connection attempts

The counter is incremented on entry to _ws_proxy_handler (routers/proxy.py:479), but the early-return failure paths (instance-resolve failure, connect-retry exhaustion) return before the try/finally that decrements it. Every WS attempt against an unreachable container permanently inflates the stats counter.

B2. WS connect retry loop misses handshake failures

routers/proxy.py:499 catches only (ConnectionRefusedError, OSError). websockets.connect() also raises InvalidHandshake subclasses (e.g. InvalidStatus) when the container's HTTP port answers but the upgrade returns non-101 — typical for a container that is still initializing. Those escape as unhandled ASGI exceptions instead of hitting the retry loop.

B3. provision() retry loop can die with UnboundLocalError, and the StorageOpt fallback fires on any error

In backends/docker.py, if the final retry attempt raises 409 while StorageOpt is set, the 409 branch is skipped (attempt < max_conflict_retries is false), the StorageOpt fallback pops the quota and continues, the loop exhausts, and container is referenced unbound at docker.py:161 → UnboundLocalError surfaces as a 500. Separately, that fallback catches every DockerError (not just quota-unsupported ones), so unrelated provisioning errors silently drop the user's storage limit and retry.

B4. Wheel packaging silently drops the admin UI (.gitignore vs hatchling)

.gitignore contains build/, which matches terminals/frontend/build/. Hatchling excludes VCS-ignored files even when they match include patterns — ignored build outputs must be declared via the artifacts option. As a result, pip install . produces a wheel without terminals/frontend/build, so in any image built from the repo Dockerfile (which pip-installs the package; the console script imports from site-packages, not /app), FRONTEND_BUILD_DIR.exists() is False, the UI routes are never registered, and GET / falls through to the auth-guarded catch-all proxy and returns 401 {"detail":"Missing Authorization header"} instead of the admin UI.

Fix: add to pyproject.toml:

[tool.hatch.build.targets.wheel]
artifacts = ["terminals/frontend/build"]

B5. create_or_replace destroys running containers instead of adopting them

backends/docker.py:126 provisions with containers.create_or_replace. In aiodocker, create_or_replace stops and deletes an existing container whenever the desired config differs — and it always differs, because each provision generates a fresh OPEN_TERMINAL_API_KEY. Any situation where a running container exists but isn't in the in-memory _instances map (state loss, crash-restart race, or any attempt to run more than one orchestrator process) replaces the user's live container and kills their sessions, where adopting the existing container (its name is deterministic and the API key is recoverable from its env) would be transparent.


C. Architectural ceiling

C1. The orchestrator is a single Python process with no worker support

cli.py:60 runs uvicorn with no workers option. The GIL caps the process at ~1 core regardless of host size — on a 24-core machine, 23+ cores are unreachable to the data plane. All terminal bytes for every user (WS pump in routers/proxy.py) traverse this one event loop.

C2. All orchestration state is process-local, so you can't just add workers

_instances, _activity, _specs, and per-key asyncio.Locks live in process memory (backends/base.py). Naively running uvicorn --workers N today is unsafe:

  • each worker's idle reaper would tear down containers that are active on another worker (activity is tracked per-process);
  • two workers provisioning the same user race, and via B4 the loser deletes the winner's live container;
  • admin endpoints (/api/v1/terminals, stop, refresh) would only see the receiving worker's subset.

Multi-worker (or multi-replica) support needs: container adoption by deterministic name instead of replace, shared last-active state (the DB is already a dependency), and conflict-safe provisioning (create + adopt-on-409).

C3. Status checking is poll-based; the Docker events API is unused

Rather than inspecting containers per request (A2), the backend could subscribe to the Docker events stream (label-filtered die/stop/destroy) and invalidate tracked instances event-driven, making the request fast path a pure dictionary lookup with no staleness window.


Reference implementation

We run this in production and have working patches for most of the above (happy to send PRs — split however you prefer):

  • Hot-path fixes only (no schema/deployment changes): compression off by default on both legs (A1), status-check TTL cache with invalidate-and-re-resolve mid-request healing (A2), B1/B2 fixes, wheel-packaging fix (B4) — branch: Classic298/terminals@claude/terminals-perf-only-5jz2w3
  • Multi-worker support (Docker backend): everything above plus TERMINALS_WORKERS, container adoption via deterministic names, create + adopt-on-conflict provisioning (fixes B3/B5), DB-shared last-active timestamps with a fail-closed reaper, SQLite WAL + file-locked startup migrations — branch: Classic298/terminals@claude/terminals-cpu-bottleneck-5jz2w3

How to reproduce / measure

On any deployment with a few hundred active sessions:

pip install py-spy
py-spy top --pid <terminals-serve-pid>

Before the fixes, the profile is dominated by zlib/permessage-deflate frames and JSON parsing of Docker inspect payloads; dockerd staying at ~2% CPU while terminals serve pins a core confirms the cost is Python-side. After A1+A2 alone, our orchestrator CPU dropped from a saturated core to a small fraction of one at the same load.

<!-- gh-comment-id:4979855348 --> @Classic298 commented on GitHub (Jul 15, 2026): # Orchestrator CPU bottleneck at ~500 concurrent terminals: single-core ceiling, per-frame WS compression, per-request Docker inspects (+ related bugs found while investigating) ## Environment - Terminals `main` (v0.0.5), Docker backend - Single host: 24 cores / 2 sockets, 200 GB RAM - 500–600 concurrent per-user Open Terminal containers - Symptom: the `terminals serve` process is **permanently pinned at exactly ~100% CPU** (one full core) in htop while `dockerd` sits at ~2%. Terminal I/O latency degrades for all users; the process cannot use a second core. "Exactly 100%" is the tell: the orchestrator is one Python process, so the GIL caps it at ~1 core, and the entire proxy data plane runs on that one asyncio event loop. Everything below was found by reading the code and verified against library sources; line references are against current `main`. --- ## A. Hot-path performance issues ### A1. WebSocket permessage-deflate is enabled by default on BOTH proxy legs (dominant CPU cost) - Client leg: `cli.py:60` calls `uvicorn.run(...)` without `ws_per_message_deflate`, and uvicorn's default is `True`. - Upstream leg: `routers/proxy.py:497` calls `websockets.connect(upstream_url)`, and the `websockets` default is `compression="deflate"`. Every terminal frame — every keystroke echo, every chunk of command output, for every session — is zlib-compressed and decompressed **in pure Python, twice** (once per leg). Terminal traffic is high-frequency small frames, the worst case for per-frame deflate (per-frame CPU overhead, negligible bandwidth win on a LAN/localhost hop). At hundreds of concurrent sessions this dominates the event loop. **Suggested fix:** disable compression by default on both legs (`ws_per_message_deflate=False`, `compression=None`), with an opt-in setting for deployments where clients are on slow links. ### A2. Every proxied HTTP request performs two Docker `inspect` calls `Backend.ensure_terminal()`'s fast path calls `status()` on **every request** (`backends/base.py:117`), and `DockerBackend.status()` does `containers.get(instance_id)` + `container.show()` (`backends/docker.py:318-319`) — two identical `GET /containers/{id}/json` round trips, each returning a ~20 KB JSON document parsed in Python. The proxy hot path (`_resolve_instance`) therefore costs 2 Docker API calls + 2 large JSON parses per request, per user, before a single byte is proxied. **Suggested fix:** trust a confirmed-running status for a short TTL (and/or subscribe to the Docker events stream to invalidate on `die`/`stop` — see C3), invalidating on proxy connection failure so a dead container is still re-detected promptly. ### A3. JWT mode: one HTTP round trip to Open WebUI per proxied request When `TERMINALS_OPEN_WEBUI_URL` is set, `verify_api_key` calls `validate_token()` on every request (`routers/auth.py:74`), which issues `GET {open_webui}/api/v1/auths/` (`routers/auth.py:38`) with no caching. Every proxied request pays a full network round trip plus JSON parse before touching the terminal. Same on WS connects (`routers/proxy.py:451`). **Suggested fix:** short-TTL token-validation cache (keyed by token hash), or accept locally-verifiable signed tokens. ### A4. Request bodies are fully buffered in memory `_proxy_request` reads the entire request body up front for retry purposes (`routers/proxy.py:150`). Large file uploads through `/files/*` are buffered fully in orchestrator RAM instead of streamed, costing memory and latency at concurrency. **Suggested fix:** stream bodies for requests above a size threshold and restrict retries to requests with replayable bodies. ### A5. `RequestIdMiddleware` uses Starlette's `BaseHTTPMiddleware` `middleware.py:10` — `BaseHTTPMiddleware` is a known per-request overhead source (it wraps every request in an additional task/anyio machinery) and it sits on the hottest path in the system. A pure-ASGI middleware does the same job for near zero cost. ### A6. Minor: `_wait_until_ready` builds a new `httpx.AsyncClient` per poll iteration `backends/docker.py:172` constructs and tears down a client every 0.5 s while waiting for a container to become healthy. Provisioning-path only, but trivially avoidable. ### A7. Minor: duplicate inspects in discovery paths `_extract_instance_info` always calls `container.show()` (`backends/docker.py:189`) even when the caller already holds the inspect payload — e.g. `reconcile()` inspects at `docker.py:239` and then `_extract_instance_info` re-inspects at `docker.py:263`, doubling Docker API calls for every container on startup. --- ## B. Correctness bugs found while investigating ### B1. `active_ws_connections` counter leaks on failed connection attempts The counter is incremented on entry to `_ws_proxy_handler` (`routers/proxy.py:479`), but the early-return failure paths (instance-resolve failure, connect-retry exhaustion) return **before** the `try/finally` that decrements it. Every WS attempt against an unreachable container permanently inflates the stats counter. ### B2. WS connect retry loop misses handshake failures `routers/proxy.py:499` catches only `(ConnectionRefusedError, OSError)`. `websockets.connect()` also raises `InvalidHandshake` subclasses (e.g. `InvalidStatus`) when the container's HTTP port answers but the upgrade returns non-101 — typical for a container that is still initializing. Those escape as unhandled ASGI exceptions instead of hitting the retry loop. ### B3. `provision()` retry loop can die with `UnboundLocalError`, and the StorageOpt fallback fires on *any* error In `backends/docker.py`, if the **final** retry attempt raises 409 while `StorageOpt` is set, the 409 branch is skipped (`attempt < max_conflict_retries` is false), the StorageOpt fallback pops the quota and `continue`s, the loop exhausts, and `container` is referenced unbound at `docker.py:161` → `UnboundLocalError` surfaces as a 500. Separately, that fallback catches **every** `DockerError` (not just quota-unsupported ones), so unrelated provisioning errors silently drop the user's storage limit and retry. ### B4. Wheel packaging silently drops the admin UI (`.gitignore` vs hatchling) `.gitignore` contains `build/`, which matches `terminals/frontend/build/`. Hatchling **excludes VCS-ignored files even when they match `include` patterns** — ignored build outputs must be declared via the `artifacts` option. As a result, `pip install .` produces a wheel without `terminals/frontend/build`, so in any image built from the repo Dockerfile (which pip-installs the package; the console script imports from site-packages, not `/app`), `FRONTEND_BUILD_DIR.exists()` is `False`, the UI routes are never registered, and `GET /` falls through to the auth-guarded catch-all proxy and returns `401 {"detail":"Missing Authorization header"}` instead of the admin UI. **Fix:** add to `pyproject.toml`: ```toml [tool.hatch.build.targets.wheel] artifacts = ["terminals/frontend/build"] ``` ### B5. `create_or_replace` destroys running containers instead of adopting them `backends/docker.py:126` provisions with `containers.create_or_replace`. In aiodocker, `create_or_replace` **stops and deletes** an existing container whenever the desired config differs — and it always differs, because each provision generates a fresh `OPEN_TERMINAL_API_KEY`. Any situation where a running container exists but isn't in the in-memory `_instances` map (state loss, crash-restart race, or any attempt to run more than one orchestrator process) replaces the user's live container and kills their sessions, where adopting the existing container (its name is deterministic and the API key is recoverable from its env) would be transparent. --- ## C. Architectural ceiling ### C1. The orchestrator is a single Python process with no worker support `cli.py:60` runs uvicorn with no `workers` option. The GIL caps the process at ~1 core regardless of host size — on a 24-core machine, 23+ cores are unreachable to the data plane. All terminal bytes for every user (WS pump in `routers/proxy.py`) traverse this one event loop. ### C2. All orchestration state is process-local, so you can't just add workers `_instances`, `_activity`, `_specs`, and per-key `asyncio.Lock`s live in process memory (`backends/base.py`). Naively running `uvicorn --workers N` today is unsafe: - each worker's idle reaper would tear down containers that are active on another worker (activity is tracked per-process); - two workers provisioning the same user race, and via B4 the loser **deletes the winner's live container**; - admin endpoints (`/api/v1/terminals`, `stop`, `refresh`) would only see the receiving worker's subset. Multi-worker (or multi-replica) support needs: container **adoption** by deterministic name instead of replace, shared last-active state (the DB is already a dependency), and conflict-safe provisioning (`create` + adopt-on-409). ### C3. Status checking is poll-based; the Docker events API is unused Rather than inspecting containers per request (A2), the backend could subscribe to the Docker events stream (label-filtered `die`/`stop`/`destroy`) and invalidate tracked instances event-driven, making the request fast path a pure dictionary lookup with no staleness window. --- ## Reference implementation We run this in production and have working patches for most of the above (happy to send PRs — split however you prefer): - **Hot-path fixes only** (no schema/deployment changes): compression off by default on both legs (A1), status-check TTL cache with invalidate-and-re-resolve mid-request healing (A2), B1/B2 fixes, wheel-packaging fix (B4) — branch: `Classic298/terminals@claude/terminals-perf-only-5jz2w3` - **Multi-worker support** (Docker backend): everything above plus `TERMINALS_WORKERS`, container adoption via deterministic names, `create` + adopt-on-conflict provisioning (fixes B3/B5), DB-shared last-active timestamps with a fail-closed reaper, SQLite WAL + file-locked startup migrations — branch: `Classic298/terminals@claude/terminals-cpu-bottleneck-5jz2w3` ## How to reproduce / measure On any deployment with a few hundred active sessions: ```bash pip install py-spy py-spy top --pid <terminals-serve-pid> ``` Before the fixes, the profile is dominated by zlib/permessage-deflate frames and JSON parsing of Docker inspect payloads; `dockerd` staying at ~2% CPU while `terminals serve` pins a core confirms the cost is Python-side. After A1+A2 alone, our orchestrator CPU dropped from a saturated core to a small fraction of one at the same load.
Author
Owner

@Classic298 commented on GitHub (Jul 15, 2026):

Follow-up: profiling data from production (~600 concurrent terminals) — two more CPU sinks

We ran py-spy top against the live orchestrator after applying the compression + status-cache fixes from the original report. Two additional hot spots showed up that aren't in the list above:

1. httpx/httpcore shared connection pool scanning — ~69% of the event loop.
The proxy uses a single shared httpx.AsyncClient for all upstream containers (routers/proxy.py). httpcore's _assign_requests_to_connections runs on every request start/finish and iterates the entire pool — is_idle() / has_expired() checks per connection, including is_socket_readable(), which is a syscall per idle keepalive connection. With each user container being a distinct host:port origin in one pool, this is effectively O(pool size) work per request event: at ~600 containers the profile showed ~69% of main-thread time in this tree (_assign_requests_to_connections 69% total, is_idle 28%, has_expired 18%, is_socket_readable 8%). The 4s keepalive_expiry in _get_proxy_client amplifies it via constant connection churn.
Fix that worked for us: one small client per upstream (host, port) (a few keepalive connections each, LRU-evicted for torn-down containers) instead of one shared pool — each scan then touches a handful of connections for exactly one origin. Implemented in the branches referenced above.

2. Per-request access logging through the loguru intercept — measurable at high request rates.
Every request generates a uvicorn access-log record that passes through _InterceptHandler.emit (logging.py), which walks the Python stack frame-by-frame (logging.currentframe() loop) per record before loguru formats and writes it. Making access logs opt-out recovered a few percent of CPU on top of everything else.

Also worth noting for anyone profiling this themselves: py-spy reports aiosqlite's _connection_worker_thread as ~100% "active" per DB connection — that's an artifact (the thread blocks in SimpleQueue.get()); htop shows the truth.

Profiling confirmed the middleware (BaseHTTPMiddleware) and JWT per-request validation costs already listed above are real but secondary to the pool scanning.

<!-- gh-comment-id:4980165950 --> @Classic298 commented on GitHub (Jul 15, 2026): **Follow-up: profiling data from production (~600 concurrent terminals) — two more CPU sinks** We ran `py-spy top` against the live orchestrator after applying the compression + status-cache fixes from the original report. Two additional hot spots showed up that aren't in the list above: **1. httpx/httpcore shared connection pool scanning — ~69% of the event loop.** The proxy uses a single shared `httpx.AsyncClient` for all upstream containers (`routers/proxy.py`). httpcore's `_assign_requests_to_connections` runs on every request start/finish and iterates the *entire* pool — `is_idle()` / `has_expired()` checks per connection, including `is_socket_readable()`, which is a syscall per idle keepalive connection. With each user container being a distinct `host:port` origin in one pool, this is effectively O(pool size) work per request event: at ~600 containers the profile showed ~69% of main-thread time in this tree (`_assign_requests_to_connections` 69% total, `is_idle` 28%, `has_expired` 18%, `is_socket_readable` 8%). The 4s `keepalive_expiry` in `_get_proxy_client` amplifies it via constant connection churn. *Fix that worked for us:* one small client per upstream `(host, port)` (a few keepalive connections each, LRU-evicted for torn-down containers) instead of one shared pool — each scan then touches a handful of connections for exactly one origin. Implemented in the branches referenced above. **2. Per-request access logging through the loguru intercept — measurable at high request rates.** Every request generates a uvicorn access-log record that passes through `_InterceptHandler.emit` (`logging.py`), which walks the Python stack frame-by-frame (`logging.currentframe()` loop) per record before loguru formats and writes it. Making access logs opt-out recovered a few percent of CPU on top of everything else. Also worth noting for anyone profiling this themselves: py-spy reports aiosqlite's `_connection_worker_thread` as ~100% "active" per DB connection — that's an artifact (the thread blocks in `SimpleQueue.get()`); htop shows the truth. Profiling confirmed the middleware (`BaseHTTPMiddleware`) and JWT per-request validation costs already listed above are real but secondary to the pool scanning.
Author
Owner

@Classic298 commented on GitHub (Jul 15, 2026):

https://github.com/open-webui/terminals/pull/43/

<!-- gh-comment-id:4980242879 --> @Classic298 commented on GitHub (Jul 15, 2026): https://github.com/open-webui/terminals/pull/43/
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-webui/terminals#18
No description provided.