mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
docs: update streaming connection pool leak blog post
Rewrite blog for accuracy against PR #21213, add interactive diagrams, fix technical inaccuracies, and improve readability.
This commit is contained in:
parent
5fcb5c2ad5
commit
2839c02096
8 changed files with 480 additions and 29 deletions
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
|
|
@ -1,20 +1,20 @@
|
|||
---
|
||||
slug: streaming-connection-pool-leak
|
||||
title: "How We Fixed a Streaming Connection Pool Leak"
|
||||
date: 2026-02-14T10:00:00
|
||||
date: 2026-03-04T10: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
|
||||
image_url: https://github.com/ryan-crabbe.png
|
||||
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';
|
||||
import { HappyPathScene, LeakScene, FixScene, StreamChainDiagram, ContentVsStreamDiagram } from '@site/src/components/StreamingDiagrams';
|
||||
|
||||
> 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.
|
||||
> Sometimes after roughly 30 minutes of running the LiteLLM proxy, streaming requests would just hang. Direct requests to the providers worked fine. Since the requests were hanging rather than failing, and the providers themselves were healthy, this pointed to a streaming connection issue.
|
||||
|
||||
{/* truncate */}
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ import { HappyPathScene, LeakScene, FixScene } from '@site/src/components/Parkin
|
|||
|
||||
## 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.
|
||||
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>
|
||||
|
|
@ -252,7 +252,7 @@ When a client makes a streaming request through the proxy, there are two separat
|
|||
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 proxy holds Connection B in a pool. By default there are 500 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 />
|
||||
|
||||
|
|
@ -260,58 +260,99 @@ The proxy holds Connection B in a pool — by default 50 slots per provider host
|
|||
|
||||
## 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:
|
||||
When a client disconnects mid-stream, the upstream HTTP connection should be released back to the pool. Instead, it stays stuck in its pool slot. Over time, leaked connections accumulate and the pool fills up. New requests hang waiting for a slot that will never free up:
|
||||
|
||||
<LeakScene />
|
||||
|
||||
---
|
||||
|
||||
## The First Fix — And Why It Wasn't Enough
|
||||
## The Initial Fix
|
||||
|
||||
Two changes to fix the leak:
|
||||
LiteLLM's `CustomStreamWrapper` is the universal streaming adapter that wraps every provider's raw HTTP stream and normalizes the chunks into a single response format. The proxy interacts with this wrapper, not the raw provider connection. But `CustomStreamWrapper` didn't have an `aclose()` method, so there was no way for cleanup code to reach through it and release the underlying HTTP connection.
|
||||
|
||||
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.
|
||||
The obvious first step: give the cleanup path a way to release the connection, and make sure it always runs.
|
||||
|
||||
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.
|
||||
<StreamChainDiagram />
|
||||
|
||||
1. **Added `aclose()` to `CustomStreamWrapper`** so that cleanup code can delegate down through the wrapper to the underlying stream's close and release the HTTP connection.
|
||||
|
||||
2. **Added a `finally` block in `async_data_generator`** to ensure the connection is always released, whether the stream completes normally, the client disconnects, or something throws. `async_data_generator` is the async generator that pulls chunks from `CustomStreamWrapper` and yields them to the client as they come in.
|
||||
|
||||
We ran the repro script. The leak was still there.
|
||||
|
||||
---
|
||||
|
||||
## Three More Problems
|
||||
## Why It Was Still Leaking
|
||||
|
||||
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.
|
||||
We added logging throughout the cleanup chain and reran the repro. `aclose()` wasn't actually closing anything, and the `finally` block wasn't even being hit. Here's what we found.
|
||||
|
||||
### 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.
|
||||
This was the hardest problem to track down, and the most impactful.
|
||||
|
||||
**Fix:** Change `content=` to `stream=` in `aiohttp_transport.py`. One word.
|
||||
In our aiohttp transport layer (`aiohttp_transport.py`), the httpx Response was constructed using `content=` instead of `stream=`:
|
||||
|
||||
### Cleanup was cancelled before it could finish
|
||||
```python
|
||||
# Before (broken): content= eagerly reads the body and wraps it in a ByteStream
|
||||
# whose aclose() is a no-op
|
||||
return httpx.Response(status_code=status_code, content=stream, ...)
|
||||
|
||||
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.
|
||||
# After (fixed): stream= preserves the original async stream so aclose()
|
||||
# propagates to the real connection
|
||||
return httpx.Response(status_code=status_code, stream=stream, ...)
|
||||
```
|
||||
|
||||
**Fix:** Wrap cleanup awaits in `anyio.CancelScope(shield=True)` — a brief immunity window that lets the cleanup complete before cancellation resumes.
|
||||
This single-word difference has significant consequences. Here's the relevant section from httpx's [Response constructor](https://github.com/encode/httpx/blob/master/httpx/_models.py#L548-L567), where a 6-year-old comment leads the way:
|
||||
|
||||

|
||||
|
||||
When `content=` is used, httpx consumes the data immediately and discards the original stream. By the time `aclose()` is called, there's nothing left to close. The real HTTP connection is never released.
|
||||
|
||||
When you pass `stream=`, httpx keeps the original stream intact. Closing the response actually closes the underlying connection and returns it to the pool.
|
||||
|
||||
The httpx source code comment goes: "stream= is only useful when creating response instances having received a stream from the transport API", which is exactly what our aiohttp transport does.
|
||||
|
||||
|
||||
<ContentVsStreamDiagram />
|
||||
|
||||
Because we were using `content=`, every cancelled streaming request had a silently broken cleanup path. When a client disconnected mid-stream, the `aclose()` call that should have released the connection was a no-op. It wasn't a missing reference that garbage collection could fix; the close mechanism itself was pointing at the wrong thing.
|
||||
|
||||
#### Smaller fixes that also needed to happen
|
||||
|
||||
<details>
|
||||
<summary>Cleanup was cancelled before it could finish</summary>
|
||||
|
||||
With the stream fix in place, `aclose()` now reached the right function. But it wasn't completing.
|
||||
|
||||
When a client disconnects, Starlette cancels the streaming task. This cancellation is aggressive: it interrupts every `await` inside the task, including the ones in our `finally` block that are trying to clean up the connection. The cleanup code runs, but each async call inside it is immediately killed before it can finish.
|
||||
|
||||
So the fix was wrapping the cleanup in `anyio.CancelScope(shield=True)`, which temporarily protects it from cancellation so it can complete before the task is torn down.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Uvicorn disconnect detection gap</summary>
|
||||
|
||||
On Uvicorn versions 0.28 through 0.32.0, the `finally` block in our async generator wasn't being triggered on client disconnect at all. These versions of Uvicorn reported support for ASGI spec 2.4 but didn't actually implement its disconnect signaling. Starlette saw the reported spec version and skipped its own fallback disconnect detection, meaning disconnects went completely unnoticed.
|
||||
|
||||
This has been fixed upstream in Uvicorn 0.32.1+, which correctly references ASGI spec 2.3 and causes Starlette to use its own disconnect detection. LiteLLM now pins Uvicorn to `>=0.32.1` to ensure this works correctly.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
Running the repro script again: request 3 completes instead of hanging. The `finally` block now properly releases the connection on disconnect:
|
||||
|
||||
<FixScene />
|
||||
|
||||
---
|
||||
|
||||
## What This Means
|
||||
## Going Forward
|
||||
|
||||
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.
|
||||
Streaming performance and reliability has been a top issue for us. We're putting a [sustained emphasis on performance and reliability](https://www.linkedin.com/feed/update/urn:li:activity:7433372716365332480/) for streaming workloads, and this fix is part of that recent effort.
|
||||
|
||||
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.
|
||||
For LiteLLM users, streaming connections through the proxy are now properly released on client disconnect instead of leaking until pool exhaustion.
|
||||
|
||||
In addition to regression and e2e tests in our CI/CD workflow, we are also adding a test suite to the [LiteLLM Observatory](/blog/litellm-observatory) to verify on major releases that there are no breaking regressions for these streaming changes under a production workload.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { HappyPathScene, LeakScene, FixScene } from './PoolScene';
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import React from 'react';
|
||||
|
||||
const W = 720;
|
||||
const HALF_W = W / 2 - 12;
|
||||
const BOX_H = 34;
|
||||
const BOX_RX = 6;
|
||||
const GAP_Y = 12;
|
||||
const CODE_LINE_H = 16;
|
||||
const CODE_FONT = 10;
|
||||
const LABEL_FONT = 10.5;
|
||||
const SUB_FONT = 9;
|
||||
const MONO = "var(--pg-mono)";
|
||||
|
||||
/* ─── Code block ─── */
|
||||
function CodeBlock({ x, y, width, lines }: {
|
||||
x: number; y: number; width: number;
|
||||
lines: { text: string; color?: string; highlight?: string }[];
|
||||
}) {
|
||||
const blockH = lines.length * CODE_LINE_H + 8;
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={width} height={blockH} rx={6}
|
||||
fill="var(--pg-spot-bg)" stroke="var(--pg-spot-border)" strokeWidth={1} />
|
||||
{lines.map((line, i) => (
|
||||
<g key={i}>
|
||||
{line.highlight && (
|
||||
<rect x={x + 1} y={y + 4 + i * CODE_LINE_H - 1}
|
||||
width={width - 2} height={CODE_LINE_H}
|
||||
fill={line.highlight} opacity={0.12} rx={2} />
|
||||
)}
|
||||
<text x={x + 8} y={y + 4 + i * CODE_LINE_H + CODE_LINE_H * 0.72}
|
||||
fill={line.color || 'var(--pg-text-secondary)'}
|
||||
fontSize={CODE_FONT} fontFamily={MONO} fontWeight={line.color ? 600 : 400}>
|
||||
{line.text}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Step box with label + subtitle ─── */
|
||||
function StepBox({ cx, y, width, label, subtitle, status }: {
|
||||
cx: number; y: number; width: number;
|
||||
label: string; subtitle?: string;
|
||||
status: 'normal' | 'error' | 'success';
|
||||
}) {
|
||||
const boxX = cx - width / 2;
|
||||
const h = subtitle ? BOX_H + 10 : BOX_H;
|
||||
|
||||
const fill = status === 'error' ? 'rgba(239, 68, 68, 0.08)'
|
||||
: status === 'success' ? 'rgba(34, 197, 94, 0.08)'
|
||||
: 'var(--pg-spot-bg)';
|
||||
const stroke = status === 'error' ? 'rgba(239, 68, 68, 0.4)'
|
||||
: status === 'success' ? 'rgba(34, 197, 94, 0.4)'
|
||||
: 'var(--pg-spot-border)';
|
||||
const textColor = status === 'error' ? '#ef4444'
|
||||
: status === 'success' ? '#22c55e'
|
||||
: 'var(--pg-text-primary)';
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect x={boxX} y={y} width={width} height={h} rx={BOX_RX}
|
||||
fill={fill} stroke={stroke} strokeWidth={1.5} />
|
||||
<text x={cx} y={subtitle ? y + h / 2 - 5 : y + h / 2 + 1}
|
||||
textAnchor="middle" dominantBaseline="central"
|
||||
fill={textColor} fontSize={LABEL_FONT} fontFamily={MONO} fontWeight={600}>
|
||||
{label}
|
||||
</text>
|
||||
{subtitle && (
|
||||
<text x={cx} y={y + h / 2 + 9}
|
||||
textAnchor="middle" dominantBaseline="central"
|
||||
fill={status === 'error' ? 'rgba(239, 68, 68, 0.7)' : status === 'success' ? 'rgba(34, 197, 94, 0.7)' : 'var(--pg-text-dim)'}
|
||||
fontSize={SUB_FONT} fontFamily={MONO}>
|
||||
{subtitle}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Arrow connector ─── */
|
||||
function DownArrow({ cx, y1, y2 }: { cx: number; y1: number; y2: number }) {
|
||||
return (
|
||||
<g>
|
||||
<line x1={cx} y1={y1} x2={cx} y2={y2 - 5}
|
||||
stroke="var(--pg-spot-border)" strokeWidth={1} strokeDasharray="3 3" />
|
||||
<polygon
|
||||
points={`${cx},${y2} ${cx - 3.5},${y2 - 5} ${cx + 3.5},${y2 - 5}`}
|
||||
fill="var(--pg-spot-border)" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Column ─── */
|
||||
function Column({ offsetX, title, titleColor, code, steps, result, resultColor }: {
|
||||
offsetX: number;
|
||||
title: string;
|
||||
titleColor: string;
|
||||
code: { text: string; color?: string; highlight?: string }[];
|
||||
steps: { label: string; subtitle?: string; status: 'normal' | 'error' | 'success' }[];
|
||||
result: string;
|
||||
resultColor: string;
|
||||
}) {
|
||||
const colW = HALF_W;
|
||||
const cx = offsetX + colW / 2;
|
||||
const boxW = colW - 40;
|
||||
|
||||
let y = 16;
|
||||
const titleY = y; y += 26;
|
||||
const codeY = y;
|
||||
const codeH = code.length * CODE_LINE_H + 8;
|
||||
y += codeH + GAP_Y + 4;
|
||||
|
||||
const stepPositions: number[] = [];
|
||||
for (const step of steps) {
|
||||
const h = step.subtitle ? BOX_H + 10 : BOX_H;
|
||||
if (stepPositions.length > 0) y += GAP_Y;
|
||||
stepPositions.push(y);
|
||||
y += h;
|
||||
}
|
||||
|
||||
const resultY = y + 18;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<text x={cx} y={titleY} textAnchor="middle" fill={titleColor}
|
||||
fontSize={12} fontWeight={700} fontFamily={MONO}>
|
||||
{title}
|
||||
</text>
|
||||
|
||||
<CodeBlock x={offsetX + 12} y={codeY} width={colW - 24} lines={code} />
|
||||
|
||||
{steps.map((step, i) => {
|
||||
const sy = stepPositions[i];
|
||||
const prevH = i > 0 ? (steps[i - 1].subtitle ? BOX_H + 10 : BOX_H) : 0;
|
||||
return (
|
||||
<g key={i}>
|
||||
{i === 0 && <DownArrow cx={cx} y1={codeY + codeH} y2={sy} />}
|
||||
{i > 0 && <DownArrow cx={cx} y1={stepPositions[i - 1] + prevH} y2={sy} />}
|
||||
<StepBox cx={cx} y={sy} width={boxW}
|
||||
label={step.label} subtitle={step.subtitle} status={step.status} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<text x={cx} y={resultY} textAnchor="middle"
|
||||
fill={resultColor} fontSize={11} fontWeight={700} fontFamily={MONO}>
|
||||
{result}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main ─── */
|
||||
export function ContentVsStreamDiagram() {
|
||||
const H = 310;
|
||||
const dividerX = W / 2;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '1.5rem 0' }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
<Column
|
||||
offsetX={0}
|
||||
title="content= (before)"
|
||||
titleColor="#ef4444"
|
||||
code={[
|
||||
{ text: 'httpx.Response(' },
|
||||
{ text: ' content=stream,', color: '#ef4444', highlight: '#ef4444' },
|
||||
{ text: ')' },
|
||||
]}
|
||||
steps={[
|
||||
{ label: 'Data consumed into memory', subtitle: 'Original stream discarded', status: 'normal' },
|
||||
{ label: 'aclose() has nothing to close', subtitle: 'Points at an empty wrapper', status: 'error' },
|
||||
{ label: 'Connection stuck in pool', status: 'error' },
|
||||
]}
|
||||
result="Leaked on every request"
|
||||
resultColor="#ef4444"
|
||||
/>
|
||||
|
||||
<line x1={dividerX} y1={8} x2={dividerX} y2={H - 8}
|
||||
stroke="var(--pg-spot-border)" strokeWidth={1} opacity={0.5} />
|
||||
|
||||
<Column
|
||||
offsetX={dividerX + 12}
|
||||
title="stream= (after)"
|
||||
titleColor="#22c55e"
|
||||
code={[
|
||||
{ text: 'httpx.Response(' },
|
||||
{ text: ' stream=stream,', color: '#22c55e', highlight: '#22c55e' },
|
||||
{ text: ')' },
|
||||
]}
|
||||
steps={[
|
||||
{ label: 'Original stream preserved', subtitle: 'Data flows through on demand', status: 'normal' },
|
||||
{ label: 'aclose() reaches real connection', subtitle: 'Propagates through to aiohttp', status: 'success' },
|
||||
{ label: 'Connection returned to pool', status: 'success' },
|
||||
]}
|
||||
result="Released ✓"
|
||||
resultColor="#22c55e"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import React from 'react';
|
||||
|
||||
/* ─── Layout constants ─── */
|
||||
const W = 720;
|
||||
const H = 240;
|
||||
const BOX_H = 36;
|
||||
const BOX_RX = 8;
|
||||
const ARROW_GAP = 32;
|
||||
const CODE_OFFSET_Y = 20;
|
||||
const CODE_LINE_H = 18;
|
||||
const CODE_FONT = 11;
|
||||
const LABEL_FONT = 11;
|
||||
|
||||
interface Box {
|
||||
label: string;
|
||||
width: number;
|
||||
codeLines?: string[];
|
||||
}
|
||||
|
||||
const boxes: Box[] = [
|
||||
{ label: 'Provider HTTP Stream', width: 160 },
|
||||
{
|
||||
label: 'CustomStreamWrapper',
|
||||
width: 170,
|
||||
codeLines: [
|
||||
'+ async def aclose(self):',
|
||||
'+ await self.response.aclose()',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'async_data_generator',
|
||||
width: 170,
|
||||
codeLines: [
|
||||
'try:',
|
||||
' async for chunk in stream:',
|
||||
' yield chunk',
|
||||
'+ finally:',
|
||||
'+ await stream.aclose()',
|
||||
],
|
||||
},
|
||||
{ label: 'Client', width: 70 },
|
||||
];
|
||||
|
||||
/* ─── Compute positions ─── */
|
||||
const totalW = boxes.reduce((s, b) => s + b.width, 0) + (boxes.length - 1) * ARROW_GAP;
|
||||
const startX = (W - totalW) / 2;
|
||||
|
||||
interface BoxPos {
|
||||
x: number;
|
||||
cx: number;
|
||||
y: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
const positions: BoxPos[] = [];
|
||||
let cx = startX;
|
||||
const BOX_Y = 24;
|
||||
for (const b of boxes) {
|
||||
positions.push({ x: cx, cx: cx + b.width / 2, y: BOX_Y, w: b.width });
|
||||
cx += b.width + ARROW_GAP;
|
||||
}
|
||||
|
||||
/* ─── Arrow between boxes (left to right data flow) ─── */
|
||||
function Arrow({ fromIdx, toIdx }: { fromIdx: number; toIdx: number }) {
|
||||
const from = positions[fromIdx];
|
||||
const to = positions[toIdx];
|
||||
const x1 = from.x + from.w;
|
||||
const x2 = to.x;
|
||||
const y = BOX_Y + BOX_H / 2;
|
||||
return (
|
||||
<g>
|
||||
<line x1={x1} y1={y} x2={x2 - 8} y2={y}
|
||||
stroke="var(--pg-text-dim)" strokeWidth={1.5} strokeDasharray="4 3" />
|
||||
{/* arrowhead pointing right */}
|
||||
<polygon
|
||||
points={`${x2},${y} ${x2 - 8},${y - 4} ${x2 - 8},${y + 4}`}
|
||||
fill="var(--pg-text-dim)"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Code diff block ─── */
|
||||
function CodeBlock({ cx, lines, topY }: { cx: number; lines: string[]; topY: number }) {
|
||||
const maxLen = Math.max(...lines.map(l => l.length));
|
||||
const charW = CODE_FONT * 0.62;
|
||||
const blockW = maxLen * charW + 16;
|
||||
const blockH = lines.length * CODE_LINE_H + 8;
|
||||
const blockX = cx - blockW / 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={blockX} y={topY}
|
||||
width={blockW} height={blockH}
|
||||
rx={6}
|
||||
fill="var(--pg-spot-bg)"
|
||||
stroke="var(--pg-spot-border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{lines.map((line, i) => {
|
||||
const isAdded = line.startsWith('+');
|
||||
return (
|
||||
<g key={i}>
|
||||
{isAdded && (
|
||||
<rect
|
||||
x={blockX + 1} y={topY + 4 + i * CODE_LINE_H - 1}
|
||||
width={blockW - 2} height={CODE_LINE_H}
|
||||
fill="#22c55e" opacity={0.12} rx={2}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={blockX + 8}
|
||||
y={topY + 4 + i * CODE_LINE_H + CODE_LINE_H * 0.72}
|
||||
fill={isAdded ? '#22c55e' : 'var(--pg-text-secondary)'}
|
||||
fontSize={CODE_FONT}
|
||||
fontFamily="var(--pg-mono)"
|
||||
fontWeight={isAdded ? 600 : 400}
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main component ─── */
|
||||
export function StreamChainDiagram() {
|
||||
const codeTopY = BOX_Y + BOX_H + CODE_OFFSET_Y;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '1.5rem 0' }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
>
|
||||
{/* Boxes */}
|
||||
{boxes.map((b, i) => {
|
||||
const p = positions[i];
|
||||
const isEndpoint = i === 0 || i === boxes.length - 1;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={p.x} y={p.y}
|
||||
width={p.w} height={BOX_H}
|
||||
rx={BOX_RX}
|
||||
fill={isEndpoint ? 'transparent' : 'var(--pg-caption-bg)'}
|
||||
stroke="var(--pg-spot-border)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={p.cx} y={p.y + BOX_H / 2 + 1}
|
||||
textAnchor="middle" dominantBaseline="central"
|
||||
fill="var(--pg-text-primary)"
|
||||
fontSize={LABEL_FONT}
|
||||
fontFamily="var(--pg-mono)"
|
||||
fontWeight={600}
|
||||
>
|
||||
{b.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Arrows (data flows left to right: Provider → CSW → generator → Client) */}
|
||||
<Arrow fromIdx={0} toIdx={1} />
|
||||
<Arrow fromIdx={1} toIdx={2} />
|
||||
<Arrow fromIdx={2} toIdx={3} />
|
||||
|
||||
{/* Code diffs below relevant boxes */}
|
||||
{boxes.map((b, i) => {
|
||||
if (!b.codeLines) return null;
|
||||
return (
|
||||
<CodeBlock
|
||||
key={i}
|
||||
cx={positions[i].cx}
|
||||
lines={b.codeLines}
|
||||
topY={codeTopY}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Connector lines from boxes to code blocks */}
|
||||
{boxes.map((b, i) => {
|
||||
if (!b.codeLines) return null;
|
||||
const p = positions[i];
|
||||
return (
|
||||
<line
|
||||
key={`conn-${i}`}
|
||||
x1={p.cx} y1={p.y + BOX_H}
|
||||
x2={p.cx} y2={codeTopY}
|
||||
stroke="var(--pg-spot-border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
opacity={0.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { HappyPathScene, LeakScene, FixScene } from './PoolScene';
|
||||
export { StreamChainDiagram } from './StreamChainDiagram';
|
||||
export { ContentVsStreamDiagram } from './ContentVsStreamDiagram';
|
||||
Loading…
Add table
Reference in a new issue