mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(ui): revoke code interpreter image object URLs on unmount
The effect cleanup captured imageUrls from the render it closed over, which is always the initial empty object since the URLs are created later by the async fetch. Every container image blob URL was therefore never revoked and stayed pinned until full page unload. Track created URLs in an effect-local array and revoke those, and abort in-flight image fetches on unmount so late-resolving blobs cannot create URLs after cleanup has run.
This commit is contained in:
parent
2b2ae4ca49
commit
17128054eb
2 changed files with 105 additions and 2 deletions
|
|
@ -295,6 +295,100 @@ describe("CodeInterpreterOutput", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("revokes created object URLs on unmount", async () => {
|
||||
let urlCounter = 0;
|
||||
(URL.createObjectURL as ReturnType<typeof vi.fn>).mockImplementation(() => `blob:test-${++urlCounter}`);
|
||||
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(mockBlob),
|
||||
});
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-2",
|
||||
filename: "plot.jpg",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const { unmount } = render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-1");
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-2");
|
||||
});
|
||||
|
||||
it("does not leak object URLs from fetches that complete after unmount", async () => {
|
||||
let urlCounter = 0;
|
||||
(URL.createObjectURL as ReturnType<typeof vi.fn>).mockImplementation(() => `blob:late-${++urlCounter}`);
|
||||
|
||||
const mockBlob = new Blob(["image data"], { type: "image/png" });
|
||||
let resolveBlob: (value: Blob) => void;
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockReturnValue(
|
||||
new Promise<Blob>((resolve) => {
|
||||
resolveBlob = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const annotations = [
|
||||
{
|
||||
type: "container_file_citation" as const,
|
||||
container_id: "container-1",
|
||||
file_id: "file-1",
|
||||
filename: "chart.png",
|
||||
start_index: 0,
|
||||
end_index: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const { unmount } = render(
|
||||
<CodeInterpreterOutput
|
||||
code="import matplotlib.pyplot as plt"
|
||||
annotations={annotations}
|
||||
accessToken="test-token"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
resolveBlob!(mockBlob);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const created = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.results.map((r) => r.value);
|
||||
const revoked = (URL.revokeObjectURL as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]);
|
||||
expect(revoked).toEqual(expect.arrayContaining(created));
|
||||
});
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(global.fetch as any).mockRejectedValue(new Error("Network error"));
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
|
||||
// Fetch images from container files API
|
||||
useEffect(() => {
|
||||
const createdUrls: string[] = [];
|
||||
const abortController = new AbortController();
|
||||
|
||||
const fetchImages = async () => {
|
||||
for (const annotation of annotations) {
|
||||
const isImage =
|
||||
|
|
@ -58,16 +61,21 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
if (abortController.signal.aborted) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
createdUrls.push(url);
|
||||
setImageUrls((prev) => ({ ...prev, [annotation.file_id]: url }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching image:", error);
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error("Error fetching image:", error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingImages((prev) => ({ ...prev, [annotation.file_id]: false }));
|
||||
}
|
||||
|
|
@ -81,7 +89,8 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
|
||||
// Cleanup URLs on unmount
|
||||
return () => {
|
||||
Object.values(imageUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
abortController.abort();
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [annotations, accessToken, proxyBaseUrl]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue