From 5fcb5c2ad5dcfab016ca3fdf0d27ec58b34d3275 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Mon, 16 Feb 2026 10:19:35 -0800 Subject: [PATCH] docs: add streaming connection pool leak blog post with visualizations --- .../blog/streaming_connection_leak/index.mdx | 317 +++++++++++++++ .../ParkingGarageAnimation/PoolScene.tsx | 371 ++++++++++++++++++ .../ParkingGarageAnimation/index.tsx | 1 + .../ParkingGarageAnimation/styles.module.css | 217 ++++++++++ 4 files changed, 906 insertions(+) create mode 100644 docs/my-website/blog/streaming_connection_leak/index.mdx create mode 100644 docs/my-website/src/components/ParkingGarageAnimation/PoolScene.tsx create mode 100644 docs/my-website/src/components/ParkingGarageAnimation/index.tsx create mode 100644 docs/my-website/src/components/ParkingGarageAnimation/styles.module.css diff --git a/docs/my-website/blog/streaming_connection_leak/index.mdx b/docs/my-website/blog/streaming_connection_leak/index.mdx new file mode 100644 index 00000000000..dfeb780bda0 --- /dev/null +++ b/docs/my-website/blog/streaming_connection_leak/index.mdx @@ -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. + +
+Repro script + +```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()) +``` + +
+ +--- + +## 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: + + + +--- + +## 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: + + + +--- + +## 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: + + + +--- + +## 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. diff --git a/docs/my-website/src/components/ParkingGarageAnimation/PoolScene.tsx b/docs/my-website/src/components/ParkingGarageAnimation/PoolScene.tsx new file mode 100644 index 00000000000..c92d9457448 --- /dev/null +++ b/docs/my-website/src/components/ParkingGarageAnimation/PoolScene.tsx @@ -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 = { + 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 => ({ ...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 | 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 ( +
+ + + {/* ── Header ── */} + + Connection Pool + + + {/* ── 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 ( + + + + {i + 1} + + + {s.leaked.includes(i) && ( + + + + LEAKED + + + )} + + {s.fixed.includes(i) && ( + + + + RELEASED ✓ + + + )} + + ); + })} + + {/* ── Connection B: slot → proxy ── */} + {showChain && ( + + + + Connection B + + + )} + + {/* ── Connection A: proxy → client ── */} + {showChain && ( + + + + Connection A + + + )} + + {/* ── Streaming dots on Connection B (slot → proxy) ── */} + {s.streaming && ( + + {[0, 1, 2].map((i) => ( + + ))} + + )} + + {/* ── Streaming dots on Connection A (proxy → client, delayed) ── */} + {s.streaming && ( + + {[0, 1, 2].map((i) => ( + + ))} + + )} + + {/* ── Dots hitting proxy with nowhere to go (pool exhausted) ── */} + {s.dotsToProxy && ( + + {/* Proxy box */} + + + LiteLLM Proxy + + {/* Client below */} + + + Client + + {/* Connection A line */} + + {/* Dots coming from client up to proxy — but no connection B above */} + {[0, 1, 2].map((i) => ( + + ))} + {/* Question mark above proxy — no connection to pool */} + + ? + + + )} + + {/* ── LiteLLM Proxy box (centered) ── */} + {(showChain || s.clientVisible) && !s.dotsToProxy && ( + + + + LiteLLM Proxy + + + )} + + {/* ── Client box (centered) ── */} + {s.clientVisible && !s.dotsToProxy && ( + + + + Client + + + )} + + {/* ── Disconnect ✕ on Connection A ── */} + {s.disconnected && ( + + + + ✕ + + + )} + + {/* ── Shaking blocked request ── */} + {s.shaking && ( + + + + ? + + + )} + + + + {/* ── Timeout overlay ── */} + {s.showTimeout && ( +
+ TIMEOUT + pool exhausted +
+ )} + +
{s.caption}
+
+ ); +} + +/* ═══ Exports ═══ */ +export function HappyPathScene() { return ; } +export function LeakScene() { return ; } +export function FixScene() { return ; } diff --git a/docs/my-website/src/components/ParkingGarageAnimation/index.tsx b/docs/my-website/src/components/ParkingGarageAnimation/index.tsx new file mode 100644 index 00000000000..bbdd4c9e37a --- /dev/null +++ b/docs/my-website/src/components/ParkingGarageAnimation/index.tsx @@ -0,0 +1 @@ +export { HappyPathScene, LeakScene, FixScene } from './PoolScene'; diff --git a/docs/my-website/src/components/ParkingGarageAnimation/styles.module.css b/docs/my-website/src/components/ParkingGarageAnimation/styles.module.css new file mode 100644 index 00000000000..7c0e0f4b434 --- /dev/null +++ b/docs/my-website/src/components/ParkingGarageAnimation/styles.module.css @@ -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; + } +}