docs: add streaming connection pool leak blog post with visualizations

This commit is contained in:
Ryan Crabbe 2026-02-16 10:19:35 -08:00
parent 2b91978b99
commit 5fcb5c2ad5
4 changed files with 906 additions and 0 deletions

View file

@ -0,0 +1,317 @@
---
slug: streaming-connection-pool-leak
title: "How We Fixed a Streaming Connection Pool Leak"
date: 2026-02-14T10:00:00
authors:
- name: Ryan Crabbe
title: "Performance Engineer, LiteLLM"
url: https://www.linkedin.com/in/ryan-crabbe-0b9687214
image_url: https://media.licdn.com/dms/image/v2/D5603AQHt1t9Z4BJ6Gw/profile-displayphoto-shrink_400_400/profile-displayphoto-shrink_400_400/0/1724453682340?e=1772064000&v=beta&t=VXdmr13rsNB05wyA2F1TENOB5UuDHUZ0FCHTolNyR5M
description: "How we tracked down and fixed a connection pool leak that caused OpenAI streaming requests to hang after 30 minutes"
tags: [performance, streaming, debugging]
hide_table_of_contents: false
---
import { HappyPathScene, LeakScene, FixScene } from '@site/src/components/ParkingGarageAnimation';
> Sometimes after roughly 30 minutes of running the LiteLLM proxy, OpenAI calls would just hang. Direct requests to OpenAI worked fine, and other providers through the proxy were unaffected. Since the requests were hanging rather than failing, and the provider itself was healthy, this pointed to a streaming connection issue.
{/* truncate */}
---
## Reproducing It
We wrote a self-contained Python script that starts a fake OpenAI server, starts the LiteLLM proxy pointed at it, and makes streaming requests where the client disconnects mid-stream. With the connection pool limit set to 2, the first two requests leak their connections, and the third request hangs forever waiting for a pool slot — confirming the bug.
<details>
<summary>Repro script</summary>
```python
"""
Tests streaming connection pool leak THROUGH the proxy (the real-world scenario).
Run: poetry run python tests/repro_connection_leak_proxy.py
How it works:
1. Starts a fake OpenAI server on :8099 (slow-streaming, never finishes quickly)
2. Starts the LiteLLM proxy on :4111 configured to route to the fake server
3. Client makes streaming requests to the proxy, reads a few chunks, then disconnects
4. After exhausting the pool, tries one more request to see if it hangs
BEFORE fix: Request 3 hangs (pool exhausted, connections leaked)
AFTER fix: Request 3 succeeds (async_data_generator finally block releases connections)
"""
import os
# Set low pool limits BEFORE importing litellm
os.environ["AIOHTTP_CONNECTOR_LIMIT_PER_HOST"] = "2"
os.environ["AIOHTTP_CONNECTOR_LIMIT"] = "2"
# Disable auth so we don't need a real key for the proxy
os.environ["LITELLM_MASTER_KEY"] = "sk-test-master-key"
import asyncio
import json
import uuid
import subprocess
import sys
import time
import signal
import httpx
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import uvicorn
# ── Fake slow-streaming OpenAI server ──────────────────────────
fake_app = FastAPI()
async def slow_stream():
for i in range(200):
chunk = {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion.chunk",
"created": 1677652288,
"model": "gpt-3.5-turbo",
"choices": [
{
"index": 0,
"delta": {"content": f"word{i} "},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(chunk)}\n\n"
await asyncio.sleep(0.5) # slow enough that client will disconnect before done
yield "data: [DONE]\n\n"
@fake_app.post("/v1/chat/completions")
async def completions():
return StreamingResponse(slow_stream(), media_type="text/event-stream")
def start_fake_server():
uvicorn.run(fake_app, host="127.0.0.1", port=8099, log_level="warning")
# ── Proxy config ──────────────────────────────────────────────
PROXY_CONFIG = {
"model_list": [
{
"model_name": "fake-model",
"litellm_params": {
"model": "openai/gpt-3.5-turbo",
"api_key": "fake-key",
"api_base": "http://127.0.0.1:8099/v1",
},
}
],
"general_settings": {
"master_key": "sk-test-master-key",
},
}
PROXY_PORT = 4111
PROXY_URL = f"http://127.0.0.1:{PROXY_PORT}"
# ── Client that disconnects mid-stream ────────────────────────
async def stream_and_disconnect(request_num: int):
"""Connect to the proxy, read a few chunks, then disconnect abruptly."""
async with httpx.AsyncClient() as client:
async with client.stream(
"POST",
f"{PROXY_URL}/v1/chat/completions",
json={
"model": "fake-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": True,
},
headers={"Authorization": "Bearer sk-test-master-key"},
timeout=30.0,
) as resp:
chunks_read = 0
async for line in resp.aiter_lines():
if line.startswith("data: ") and line != "data: [DONE]":
chunks_read += 1
if chunks_read >= 3:
print(
f"[Request {request_num}] Read {chunks_read} chunks, disconnecting..."
)
return # abrupt disconnect — context manager closes connection
async def main():
import threading
import tempfile
import yaml
pool_limit = int(os.environ["AIOHTTP_CONNECTOR_LIMIT_PER_HOST"])
print("=" * 60)
print("CONNECTION POOL LEAK TEST (through proxy)")
print(f"Pool limit per host: {pool_limit}")
print("=" * 60)
# 1. Start fake OpenAI server
print("\n[Setup] Starting fake OpenAI server on :8099...")
server_thread = threading.Thread(target=start_fake_server, daemon=True)
server_thread.start()
await asyncio.sleep(1)
# 2. Write temp config and start proxy as subprocess
config_path = tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, prefix="litellm_test_"
)
yaml.dump(PROXY_CONFIG, config_path)
config_path.close()
print(f"[Setup] Starting LiteLLM proxy on :{PROXY_PORT}...")
proxy_proc = subprocess.Popen(
[
"litellm",
"--config", config_path.name,
"--port", str(PROXY_PORT),
"--num_workers", "1",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env={**os.environ},
)
# Wait for proxy to be ready
for i in range(30):
ret = proxy_proc.poll()
if ret is not None:
stdout = proxy_proc.stdout.read().decode() if proxy_proc.stdout else ""
print(f"[Setup] ERROR: Proxy exited with code {ret}")
return
try:
async with httpx.AsyncClient() as client:
r = await client.get(f"{PROXY_URL}/health/liveliness")
if r.status_code == 200:
print("[Setup] Proxy is ready!")
break
except (httpx.ConnectError, httpx.RemoteProtocolError):
pass
await asyncio.sleep(1)
else:
print("[Setup] ERROR: Proxy failed to start (timeout)")
proxy_proc.kill()
return
# 3. Exhaust the connection pool
try:
for i in range(pool_limit):
print(f"\n[Request {i+1}] Streaming from proxy then disconnecting...")
await stream_and_disconnect(i + 1)
await asyncio.sleep(0.5)
# 4. Try one more — will it hang?
next_req = pool_limit + 1
print(f"\n{'=' * 60}")
print(f"[Request {next_req}] If pool is leaked, this will HANG...")
print(f"{'=' * 60}\n")
try:
await asyncio.wait_for(stream_and_disconnect(next_req), timeout=15)
print(f"\n** SUCCESS: Request {next_req} completed! No pool exhaustion. **")
except asyncio.TimeoutError:
print(f"\n!! FAILURE: Request {next_req} TIMED OUT — connection pool exhausted !!")
finally:
proxy_proc.send_signal(signal.SIGTERM)
try:
stdout, _ = proxy_proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proxy_proc.kill()
stdout, _ = proxy_proc.communicate()
os.unlink(config_path.name)
if __name__ == "__main__":
asyncio.run(main())
```
</details>
---
## How the Connection Pool Works
When a client makes a streaming request through the proxy, there are two separate connections:
```
Client ↔ Connection A ↔ Proxy ↔ Connection B ↔ Provider
```
The proxy holds Connection B in a pool — by default 50 slots per provider host. On the happy path, a connection is acquired from the pool, used for streaming, and released when the stream completes:
<HappyPathScene />
---
## The Leak
When the client disconnects mid-stream, Starlette cancels the async generator that yields chunks. But the generator isn't the same thing as the `CustomStreamWrapper` that owns the upstream HTTP connection. The generator gets torn down — the connection stays in its pool slot. Under normal traffic, disconnects accumulate faster than GC can reclaim them, and eventually every slot is leaked:
<LeakScene />
---
## The First Fix — And Why It Wasn't Enough
Two changes to fix the leak:
1. **Added `aclose()` to `CustomStreamWrapper`** — giving the cleanup path a way to release the connection. The proxy interacts with `CustomStreamWrapper`, not the raw provider connection, so it needed a method that delegates down to the underlying stream's close and releases the HTTP connection.
2. **Added a `finally` block in `async_data_generator`** — ensuring the connection is always released, whether the stream completes normally, the client disconnects, or something throws.
We ran the repro script. The leak was still there.
---
## Three More Problems
We added logging throughout the cleanup chain and reran the repro. The `finally` block wasn't even being hit. Here's what we found, in order of discovery:
### Starlette stopped detecting disconnects
Starlette 0.45.3 changed how `StreamingResponse` detects client disconnects. The old behavior used a dedicated `listen_for_disconnect` task that actively watched for `http.disconnect` messages. The new behavior relies on `send()` raising `OSError` when the client is gone. But uvicorn's `send()` silently returns instead of raising — so the generator never gets cancelled and the `finally` block never runs.
**Fix:** Monkey-patch `StreamingResponse.__call__` to restore the task-group disconnect listener.
### The `aclose()` call chain was broken
After fixing disconnect detection, the `finally` block ran and `aclose()` was called — but `AiohttpResponseStream.aclose()` was never reached. In `aiohttp_transport.py`, the response was constructed with `content=` instead of `stream=`. This caused httpx to wrap the stream in an `AsyncIteratorByteStream` whose `aclose()` is a no-op. The call chain hit a dead end one layer too early.
**Fix:** Change `content=` to `stream=` in `aiohttp_transport.py`. One word.
### Cleanup was cancelled before it could finish
After the above two fixes, `aclose()` reached the right function — but it wasn't completing. When the task group cancels the streaming task, anyio throws `CancelledError` into every subsequent `await`. The `finally` block runs, but every async cleanup call inside it is immediately interrupted.
**Fix:** Wrap cleanup awaits in `anyio.CancelScope(shield=True)` — a brief immunity window that lets the cleanup complete before cancellation resumes.
---
## The Complete Fix
We ran the repro script again. Request 3 completes instead of hanging. The `finally` block now properly releases the connection on disconnect:
<FixScene />
---
## What This Means
For LiteLLM users, streaming connections through the proxy are now properly released on client disconnect instead of leaking until pool exhaustion. The Starlette/Uvicorn disconnect detection gap also affects any FastAPI application doing streaming — not just LiteLLM.
We prevent regression with unit tests for each fix — verifying that `aclose()` propagates through the `stream=` path, that cleanup completes under anyio cancellation, that the disconnect monkey-patch is applied, and that the router's generator calls `aclose()` on close. We're also adding the integration repro script to CI/CD to catch this class of streaming bug end-to-end.

View file

@ -0,0 +1,371 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import styles from './styles.module.css';
/* ─── Layout ─── */
const W = 500;
const H = 300;
const CX = W / 2;
const SLOT_W = 60;
const SLOT_H = 44;
const SLOT_GAP = 28;
const NUM_SLOTS = 3;
const SLOTS_TOTAL = NUM_SLOTS * SLOT_W + (NUM_SLOTS - 1) * SLOT_GAP;
const SLOTS_X0 = (W - SLOTS_TOTAL) / 2;
const SLOTS_Y = 30;
const SLOT_BOTTOM = SLOTS_Y + SLOT_H;
const PROXY_W = 100;
const PROXY_H = 26;
const PROXY_Y = 145;
const PROXY_BOTTOM = PROXY_Y + PROXY_H;
const CLIENT_W = 50;
const CLIENT_H = 24;
const CLIENT_Y = 240;
const CONN_B_LABEL_X = W - 20;
const slotX = (i: number) => SLOTS_X0 + i * (SLOT_W + SLOT_GAP);
const slotCenterX = (i: number) => slotX(i) + SLOT_W / 2;
const CONN_B_MID_Y = (SLOT_BOTTOM + PROXY_Y) / 2;
const CONN_A_MID_Y = (PROXY_BOTTOM + CLIENT_Y) / 2;
/* ─── Types ─── */
type SlotColor = 'empty' | 'blue' | 'red' | 'green';
interface Step {
duration: number;
slots: SlotColor[];
leaked: number[];
fixed: number[];
streaming: boolean;
streamSlot: number;
clientVisible: boolean;
disconnected: boolean;
shaking: boolean;
showTimeout: boolean;
dotsToProxy: boolean;
hideLines: boolean;
caption: string;
}
/* ─── Helpers ─── */
const defaults: Omit<Step, 'duration' | 'slots' | 'caption'> = {
leaked: [], fixed: [], streaming: false, streamSlot: 0,
clientVisible: false, disconnected: false, shaking: false, showTimeout: false,
dotsToProxy: false, hideLines: false,
};
const step = (
duration: number, slots: SlotColor[], caption: string,
o: Partial<Step> = {},
): Step => ({ ...defaults, duration, slots, caption, ...o });
const EMPTY: SlotColor[] = ['empty', 'empty', 'empty'];
const ALL_RED: SlotColor[] = ['red', 'red', 'red'];
/* ═══ Timelines ═══ */
const HAPPY_STEPS: Step[] = [
step(2000, ['blue', 'empty', 'empty'], 'Request arrives — connection acquired from pool', { clientVisible: true, streamSlot: 0 }),
step(3000, ['blue', 'empty', 'empty'], 'Streaming response chunks…', { streaming: true, streamSlot: 0, clientVisible: true }),
step(1500, ['green', 'empty', 'empty'], 'Stream complete — connection released', { clientVisible: true, streamSlot: 0 }),
step(2000, EMPTY, 'Connection returned to pool ✓'),
step(2000, EMPTY, '\u00A0'),
];
const LEAK_STEPS: Step[] = [
// Client 1 — streams, disconnects, leaks
step(1800, ['blue', 'empty', 'empty'], 'Request 1 — connection acquired', { clientVisible: true, streamSlot: 0 }),
step(2000, ['blue', 'empty', 'empty'], 'Streaming…', { streaming: true, streamSlot: 0, clientVisible: true }),
step(1800, ['red', 'empty', 'empty'], 'Client disconnects — slot 1 leaked', { clientVisible: true, disconnected: true, streamSlot: 0, leaked: [0] }),
// Client 2 — arrive immediately
step(1800, ['red', 'blue', 'empty'], 'Request 2 — connection acquired', { leaked: [0], clientVisible: true, streamSlot: 1 }),
step(2000, ['red', 'blue', 'empty'], 'Streaming…', { leaked: [0], streaming: true, streamSlot: 1, clientVisible: true }),
step(1800, ['red', 'red', 'empty'], 'Client disconnects — slot 2 leaked', { leaked: [0, 1], clientVisible: true, disconnected: true, streamSlot: 1 }),
// Client 3
step(1800, ['red', 'red', 'blue'], 'Request 3 — connection acquired', { leaked: [0, 1], clientVisible: true, streamSlot: 2 }),
step(2000, ['red', 'red', 'blue'], 'Streaming…', { leaked: [0, 1], streaming: true, streamSlot: 2, clientVisible: true }),
step(1800, ALL_RED, 'Client disconnects — all slots leaked', { leaked: [0, 1, 2], clientVisible: true, disconnected: true, streamSlot: 2 }),
// Pool exhausted — dots hit proxy but nothing connects beyond
step(3000, ALL_RED, 'New request — pool exhausted, nowhere to go', { leaked: [0, 1, 2], dotsToProxy: true }),
step(2000, EMPTY, '\u00A0'),
];
const FIX_STEPS: Step[] = [
step(1800, ['empty', 'blue', 'empty'], 'Streaming request', { clientVisible: true, streamSlot: 1 }),
step(2500, ['empty', 'blue', 'empty'], 'Streaming…', { streaming: true, streamSlot: 1, clientVisible: true }),
step(2000, ['empty', 'blue', 'empty'], 'Client disconnects (Connection A)', { clientVisible: true, disconnected: true, streamSlot: 1 }),
step(2000, ['empty', 'green', 'empty'], 'finally block runs — Connection B released', { fixed: [1], clientVisible: true, streamSlot: 1, hideLines: true }),
step(2000, EMPTY, 'Slot freed. Pool stays healthy. ✓', { clientVisible: true, streamSlot: 1, hideLines: true }),
];
/* ═══ Renderer ═══ */
function PoolScene({ steps }: { steps: Step[] }) {
const [stepIdx, setStepIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; }
}, []);
useEffect(() => {
const s = steps[stepIdx];
const delay = stepIdx === steps.length - 1 ? s.duration + 1000 : s.duration;
timerRef.current = setTimeout(() => setStepIdx((p) => (p + 1) % steps.length), delay);
return clearTimer;
}, [stepIdx, steps, clearTimer]);
const s = steps[stepIdx];
const slotAnchorX = slotCenterX(s.streamSlot);
const showChain = (s.clientVisible || s.streaming) && !s.hideLines;
return (
<div className={styles.sceneWrapper}>
<svg className={styles.sceneSvg} viewBox={`0 0 ${W} ${H}`}>
{/* ── Header ── */}
<text x={CX} y={14} textAnchor="middle" fill="var(--pg-text-secondary)" fontSize={11} fontWeight={700}>
Connection Pool
</text>
{/* ── Slots ── */}
{s.slots.map((color, i) => {
const sx = slotX(i);
const cx = slotCenterX(i);
const empty = color === 'empty';
const fill = empty ? 'transparent'
: color === 'blue' ? '#3b82f6'
: color === 'red' ? '#ef4444'
: '#22c55e';
const stroke = empty ? 'var(--pg-spot-border)' : fill;
return (
<g key={i}>
<rect
x={sx} y={SLOTS_Y} width={SLOT_W} height={SLOT_H} rx={8}
fill={fill} stroke={stroke} strokeWidth={1.5}
style={{ transition: 'fill 0.4s, stroke 0.4s' }}
/>
<text x={cx} y={SLOTS_Y + SLOT_H + 14} textAnchor="middle"
fill="var(--pg-text-dim)" fontSize={10} fontFamily="var(--pg-mono)">
{i + 1}
</text>
{s.leaked.includes(i) && (
<g className={styles.pulse}>
<rect x={cx - 24} y={SLOTS_Y + SLOT_H + 20} width={48} height={14} rx={3} fill="#ef4444" />
<text x={cx} y={SLOTS_Y + SLOT_H + 30.5} textAnchor="middle"
fill="#fff" fontSize={8} fontWeight={800} letterSpacing="0.5px">
LEAKED
</text>
</g>
)}
{s.fixed.includes(i) && (
<g>
<rect x={cx - 34} y={SLOTS_Y + SLOT_H + 20} width={68} height={14} rx={3} fill="#22c55e" />
<text x={cx} y={SLOTS_Y + SLOT_H + 30.5} textAnchor="middle"
fill="#fff" fontSize={7.5} fontWeight={800} letterSpacing="0.5px">
RELEASED
</text>
</g>
)}
</g>
);
})}
{/* ── Connection B: slot → proxy ── */}
{showChain && (
<g>
<line
x1={slotAnchorX} y1={SLOT_BOTTOM}
x2={CX} y2={PROXY_Y}
stroke="var(--pg-spot-border)" strokeWidth={1} strokeDasharray="4 3" opacity={0.4}
/>
<text x={CONN_B_LABEL_X} y={CONN_B_MID_Y + 4} textAnchor="end"
fill="var(--pg-text-dim)" fontSize={8} fontFamily="var(--pg-mono)">
Connection B
</text>
</g>
)}
{/* ── Connection A: proxy → client ── */}
{showChain && (
<g>
<line
x1={CX} y1={PROXY_BOTTOM}
x2={CX} y2={CLIENT_Y}
stroke="var(--pg-spot-border)" strokeWidth={1} strokeDasharray="4 3" opacity={0.4}
/>
<text x={CONN_B_LABEL_X} y={CONN_A_MID_Y + 4} textAnchor="end"
fill="var(--pg-text-dim)" fontSize={8} fontFamily="var(--pg-mono)">
Connection A
</text>
</g>
)}
{/* ── Streaming dots on Connection B (slot → proxy) ── */}
{s.streaming && (
<g>
{[0, 1, 2].map((i) => (
<circle key={`b${i}`} r={3} fill="var(--pg-dot-color)"
className={styles.streamDot}
style={{
'--dot-from-x': `${slotAnchorX}px`,
'--dot-from-y': `${SLOT_BOTTOM + 4}px`,
'--dot-to-x': `${CX}px`,
'--dot-to-y': `${PROXY_Y - 4}px`,
animationDelay: `${i * 0.4}s`,
} as React.CSSProperties}
/>
))}
</g>
)}
{/* ── Streaming dots on Connection A (proxy → client, delayed) ── */}
{s.streaming && (
<g>
{[0, 1, 2].map((i) => (
<circle key={`a${i}`} r={3} fill="var(--pg-dot-color)"
className={styles.streamDot}
style={{
'--dot-from-x': `${CX}px`,
'--dot-from-y': `${PROXY_BOTTOM + 4}px`,
'--dot-to-x': `${CX}px`,
'--dot-to-y': `${CLIENT_Y - 4}px`,
animationDelay: `${1.5 + i * 0.4}s`,
} as React.CSSProperties}
/>
))}
</g>
)}
{/* ── Dots hitting proxy with nowhere to go (pool exhausted) ── */}
{s.dotsToProxy && (
<g>
{/* Proxy box */}
<rect
x={CX - PROXY_W / 2} y={PROXY_Y}
width={PROXY_W} height={PROXY_H} rx={6}
fill="var(--pg-caption-bg)" stroke="var(--pg-spot-border)" strokeWidth={1}
/>
<text x={CX} y={PROXY_Y + PROXY_H / 2 + 4} textAnchor="middle"
fill="var(--pg-text-primary)" fontSize={10} fontWeight={600} fontFamily="var(--pg-mono)">
LiteLLM Proxy
</text>
{/* Client below */}
<rect
x={CX - CLIENT_W / 2} y={CLIENT_Y}
width={CLIENT_W} height={CLIENT_H} rx={6}
fill="none" stroke="var(--pg-spot-border)" strokeWidth={1}
/>
<text x={CX} y={CLIENT_Y + CLIENT_H / 2 + 4} textAnchor="middle"
fill="var(--pg-text-secondary)" fontSize={10} fontFamily="var(--pg-mono)">
Client
</text>
{/* Connection A line */}
<line
x1={CX} y1={PROXY_BOTTOM}
x2={CX} y2={CLIENT_Y}
stroke="var(--pg-spot-border)" strokeWidth={1} strokeDasharray="4 3" opacity={0.4}
/>
{/* Dots coming from client up to proxy — but no connection B above */}
{[0, 1, 2].map((i) => (
<circle key={`e${i}`} r={3} fill="var(--pg-dot-color)"
className={styles.streamDot}
style={{
'--dot-from-x': `${CX}px`,
'--dot-from-y': `${CLIENT_Y - 4}px`,
'--dot-to-x': `${CX}px`,
'--dot-to-y': `${PROXY_Y + PROXY_H + 4}px`,
animationDelay: `${i * 0.4}s`,
} as React.CSSProperties}
/>
))}
{/* Question mark above proxy — no connection to pool */}
<text x={CX} y={PROXY_Y - 12} textAnchor="middle"
fill="var(--pg-text-dim)" fontSize={16} fontWeight={700}
className={styles.pulse}>
?
</text>
</g>
)}
{/* ── LiteLLM Proxy box (centered) ── */}
{(showChain || s.clientVisible) && !s.dotsToProxy && (
<g>
<rect
x={CX - PROXY_W / 2} y={PROXY_Y}
width={PROXY_W} height={PROXY_H} rx={6}
fill="var(--pg-caption-bg)" stroke="var(--pg-spot-border)" strokeWidth={1}
/>
<text x={CX} y={PROXY_Y + PROXY_H / 2 + 4} textAnchor="middle"
fill="var(--pg-text-primary)" fontSize={10} fontWeight={600} fontFamily="var(--pg-mono)">
LiteLLM Proxy
</text>
</g>
)}
{/* ── Client box (centered) ── */}
{s.clientVisible && !s.dotsToProxy && (
<g>
<rect
x={CX - CLIENT_W / 2} y={CLIENT_Y}
width={CLIENT_W} height={CLIENT_H} rx={6}
fill="none" stroke="var(--pg-spot-border)" strokeWidth={1}
/>
<text x={CX} y={CLIENT_Y + CLIENT_H / 2 + 4} textAnchor="middle"
fill="var(--pg-text-secondary)" fontSize={10} fontFamily="var(--pg-mono)">
Client
</text>
</g>
)}
{/* ── Disconnect ✕ on Connection A ── */}
{s.disconnected && (
<g>
<circle cx={CX} cy={CONN_A_MID_Y} r={16} fill="#ef4444" opacity={0.15} />
<text x={CX} y={CONN_A_MID_Y + 1} textAnchor="middle" dominantBaseline="central"
fill="#ef4444" fontSize={20} fontWeight={900}>
</text>
</g>
)}
{/* ── Shaking blocked request ── */}
{s.shaking && (
<g>
<rect width={16} height={16} rx={3} fill="#3b82f6"
x={SLOTS_X0 - 30} y={SLOTS_Y + SLOT_H / 2 - 8}
className={styles.shake}
/>
<text x={SLOTS_X0 - 22} y={SLOTS_Y + SLOT_H / 2 - 14} textAnchor="middle"
fill="var(--pg-text-secondary)" fontSize={16} fontWeight={700}
className={styles.pulse}>
?
</text>
</g>
)}
</svg>
{/* ── Timeout overlay ── */}
{s.showTimeout && (
<div className={styles.timeoutOverlay}>
<span className={`${styles.timeoutText} ${styles.pulse}`}>TIMEOUT</span>
<span className={styles.timeoutSubtext}>pool exhausted</span>
</div>
)}
<div className={styles.sceneCaption}>{s.caption}</div>
</div>
);
}
/* ═══ Exports ═══ */
export function HappyPathScene() { return <PoolScene steps={HAPPY_STEPS} />; }
export function LeakScene() { return <PoolScene steps={LEAK_STEPS} />; }
export function FixScene() { return <PoolScene steps={FIX_STEPS} />; }

View file

@ -0,0 +1 @@
export { HappyPathScene, LeakScene, FixScene } from './PoolScene';

View file

@ -0,0 +1,217 @@
/* ── Parking Garage custom properties ── */
:root {
--pg-bg: #f0f2f5;
--pg-bg-gradient: linear-gradient(180deg, #e8ecf1 0%, #f0f2f5 100%);
--pg-border: #c9cfd8;
--pg-text-primary: #1a1a2e;
--pg-text-secondary: #6b7280;
--pg-text-dim: #9ca3af;
--pg-spot-bg: #f8f9fb;
--pg-spot-border: #d1d5db;
--pg-spot-empty-bg: #fafbfc;
--pg-garage-roof: #64748b;
--pg-garage-wall: #94a3b8;
--pg-garage-floor: #cbd5e1;
--pg-car-body-green: #22c55e;
--pg-car-body-blue: #3b82f6;
--pg-car-body-red: #ef4444;
--pg-car-window: #dbeafe;
--pg-car-wheel: #374151;
--pg-valet-body: #6366f1;
--pg-valet-skin: #fbbf24;
--pg-dot-color: #3b82f6;
--pg-leaked-bg: #fef2f2;
--pg-leaked-border: #fca5a5;
--pg-timeout-bg: rgba(15, 23, 42, 0.88);
--pg-caption-bg: #ffffff;
--pg-caption-border: #e5e7eb;
--pg-road: #94a3b8;
--pg-road-mark: #fbbf24;
--pg-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
[data-theme='dark'] {
--pg-bg: #0f172a;
--pg-bg-gradient: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
--pg-border: #334155;
--pg-text-primary: #e2e8f0;
--pg-text-secondary: #94a3b8;
--pg-text-dim: #64748b;
--pg-spot-bg: #1e293b;
--pg-spot-border: #475569;
--pg-spot-empty-bg: #0f172a;
--pg-garage-roof: #475569;
--pg-garage-wall: #334155;
--pg-garage-floor: #1e293b;
--pg-car-body-green: #4ade80;
--pg-car-body-blue: #60a5fa;
--pg-car-body-red: #f87171;
--pg-car-window: #1e3a5f;
--pg-car-wheel: #e2e8f0;
--pg-valet-body: #818cf8;
--pg-valet-skin: #fcd34d;
--pg-dot-color: #60a5fa;
--pg-leaked-bg: #451a1a;
--pg-leaked-border: #b91c1c;
--pg-timeout-bg: rgba(0, 0, 0, 0.92);
--pg-caption-bg: #1e293b;
--pg-caption-border: #334155;
--pg-road: #334155;
--pg-road-mark: #fbbf24;
}
/* ── Wrapper ── */
.wrapper {
margin: 1.5rem 0;
user-select: none;
cursor: pointer;
}
/* ── Dual label ── */
.garageLabel {
display: flex;
justify-content: center;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.garageLabelAnalogy {
font-size: 1rem;
font-weight: 700;
color: var(--pg-text-primary);
}
.garageLabelTechnical {
font-size: 0.75rem;
color: var(--pg-text-secondary);
font-family: var(--pg-mono);
}
/* ── SVG scene ── */
.scene {
width: 100%;
border: 2px solid var(--pg-border);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.sceneSvg {
display: block;
width: 100%;
height: auto;
}
/* ── Timeout overlay ── */
.timeoutOverlay {
position: absolute;
inset: 0;
background: var(--pg-timeout-bg);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.timeoutText {
color: var(--pg-car-body-red);
font-size: 1.6rem;
font-weight: 900;
letter-spacing: 0.15em;
}
.timeoutSubtext {
color: #94a3b8;
font-size: 0.8rem;
margin-top: 0.3rem;
font-family: var(--pg-mono);
}
/* ── Caption ── */
.caption {
margin-top: 0.75rem;
padding: 0.65rem 1rem;
background: var(--pg-caption-bg);
border: 1px solid var(--pg-caption-border);
border-radius: 8px;
text-align: center;
font-size: 0.88rem;
color: var(--pg-text-primary);
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.4;
}
.pauseHint {
font-size: 0.7rem;
color: var(--pg-text-dim);
text-align: center;
margin-top: 0.35rem;
}
/* ── Scene (PoolScene) ── */
.sceneWrapper {
margin: 1.5rem 0;
position: relative;
}
.sceneSvg {
display: block;
width: 100%;
height: auto;
}
.sceneCaption {
text-align: center;
font-size: 0.84rem;
color: var(--pg-text-secondary);
min-height: 28px;
line-height: 1.4;
}
/* ── CSS animations (PoolScene) ── */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(-3px); }
50% { transform: translateX(3px); }
}
.shake {
animation: shake 0.15s ease-in-out infinite;
}
@keyframes streamDot {
0% { cx: var(--dot-from-x); cy: var(--dot-from-y); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { cx: var(--dot-to-x); cy: var(--dot-to-y); opacity: 0; }
}
.streamDot {
animation: streamDot 1.2s linear infinite;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.caption {
font-size: 0.8rem;
padding: 0.5rem 0.75rem;
}
.timeoutText {
font-size: 1.2rem;
}
}