From 9bf28c00cdb362fe9ca812b39b47b996aa123a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:48:18 +0000 Subject: [PATCH 1/8] Initial plan From 762cdab78f0bb2f5df7a6d8c24dc4da8cbc1ce47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:54:03 +0000 Subject: [PATCH 2/8] Address review comments 1-4: Fix optional checks, remove redundant code, simplify column visibility, and share utility functions Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../manage/tools/docker/docker-table.tsx | 35 +++---------------- packages/docker/src/shared.ts | 19 ++++++++++ packages/widgets/src/docker/component.tsx | 21 +---------- 3 files changed, 24 insertions(+), 51 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 45408fe99..d3bc01049 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; import type { MantineColor } from "@mantine/core"; import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; import { @@ -19,7 +18,7 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr, DockerLogsModal } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -80,9 +79,10 @@ const createColumns = ( accessorKey: "ports", header: t("docker.field.ports.label"), Cell({ cell }) { - if (!cell?.row?.original?.ports?.length) return null; + const ports = cell.row.original.ports; + if (!ports || ports.length === 0) return null; return ( - port.PrivatePort.toString())} /> + port.PrivatePort.toString())} /> ); }, }, @@ -135,7 +135,6 @@ const createColumns = ( export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const [showStats, setShowStats] = useState(false); const { data } = clientApi.docker.getContainers.useQuery(undefined, { initialData, refetchOnMount: false, @@ -209,14 +208,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" columns: createColumns(t), }); - useEffect(() => { - table.setColumnVisibility((prev) => ({ - ...prev, - cpuUsage: showStats, - memoryUsage: showStats, - })); - }, [showStats, table]); - return ( <> {tDocker("table.updated", { when: relativeTime })} @@ -334,21 +325,3 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const memoryUsageColor = (number: number, state: string) => { - const mbUsage = number / 1024 / 1024; - if (mbUsage === 0 && state !== "running") return "red"; - if (mbUsage < 128) return "green"; - if (mbUsage < 256) return "yellow"; - if (mbUsage < 512) return "orange"; - return "red"; -}; - -const cpuUsageColor = (number: number, state: string) => { - if (number === 0 && state !== "running") return "red"; - if (number < 40) return "green"; - if (number < 60) return "yellow"; - if (number < 90) return "orange"; - return "red"; -}; - -const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index f50bd32c7..d63598e21 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -11,3 +11,22 @@ export const containerStateColorMap = { removing: "pink", dead: "dark", } satisfies Record; + +export const memoryUsageColor = (number: number, state: string): MantineColor => { + const mbUsage = number / 1024 / 1024; + if (mbUsage === 0 && state !== "running") return "red"; + if (mbUsage < 128) return "green"; + if (mbUsage < 256) return "yellow"; + if (mbUsage < 512) return "orange"; + return "red"; +}; + +export const cpuUsageColor = (number: number, state: string): MantineColor => { + if (number === 0 && state !== "running") return "red"; + if (number < 40) return "green"; + if (number < 60) return "yellow"; + if (number < 90) return "orange"; + return "red"; +}; + +export const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 869b76f0b..a8d0b6f3c 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -11,7 +11,7 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; @@ -28,25 +28,6 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const memoryUsageColor = (number: number, state: string) => { - const mbUsage = number / 1024 / 1024; - if (mbUsage === 0 && state !== "running") return "red"; - if (mbUsage < 128) return "green"; - if (mbUsage < 256) return "yellow"; - if (mbUsage < 512) return "orange"; - return "red"; -}; - -const cpuUsageColor = (number: number, state: string) => { - if (number === 0 && state !== "running") return "red"; - if (number < 40) return "green"; - if (number < 60) return "yellow"; - if (number < 90) return "orange"; - return "red"; -}; - -const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); - const actionIconIconStyle: IconProps["style"] = { height: "var(--ai-icon-size)", width: "var(--ai-icon-size)", From 23ce0d14ae9c77eba52fb71ed58ca254452fabb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:57:33 +0000 Subject: [PATCH 3/8] Implement log streaming with subscription-based approach instead of polling Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../api/src/router/docker/docker-router.ts | 51 ++++++++++++- .../src/docker/docker-logs-modal.tsx | 72 ++++++++++++++----- packages/request-handler/src/docker.ts | 51 +++++++++++++ 3 files changed, 156 insertions(+), 18 deletions(-) diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 013c8e386..dba8d0ad0 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -4,7 +4,11 @@ import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker"; -import { dockerContainersRequestHandler, getContainerLogsAsync } from "@homarr/request-handler/docker"; +import { + dockerContainersRequestHandler, + getContainerLogsAsync, + streamContainerLogsAsync, +} from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -127,6 +131,51 @@ export const dockerRouter = createTRPCRouter({ logs, }; }), + subscribeLogs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .subscription(({ input }) => { + return observable((emit) => { + let cleanup: (() => void) | undefined; + + streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + emit.next(data); + }, + (err) => { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + }, + ) + .then((cleanupFn) => { + cleanup = cleanupFn; + }) + .catch((err) => { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + }); + + return () => { + cleanup?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index 2cee0534c..c4471e6ff 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; @@ -16,41 +16,79 @@ interface DockerLogsModalInnerProps { export const DockerLogsModal = createModal(({ innerProps }) => { const t = useI18n(); const viewportRef = useRef(null); - const didInitialScrollRef = useRef(false); + const [logs, setLogs] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const shouldScrollRef = useRef(true); - const { data, isPending, isError } = clientApi.docker.logs.useQuery( - { id: innerProps.id, tail: innerProps.tail }, + // Get initial logs + const { data: initialData } = clientApi.docker.logs.useQuery( + { id: innerProps.id, tail: innerProps.tail ?? 200 }, { refetchOnWindowFocus: false, retry: 0, - refetchInterval: 1000, - refetchIntervalInBackground: true, }, ); + // Subscribe to streaming logs + clientApi.docker.subscribeLogs.useSubscription( + { id: innerProps.id, tail: innerProps.tail ?? 200 }, + { + onData(data) { + setLogs((prev) => prev + data); + setIsLoading(false); + }, + onError(err) { + setError(err.message); + setIsLoading(false); + }, + }, + ); + + // Initialize with static logs useEffect(() => { - if (didInitialScrollRef.current) return; - if (!data?.logs) return; + if (initialData?.logs) { + setLogs(initialData.logs); + setIsLoading(false); + } + }, [initialData]); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (!shouldScrollRef.current) return; const viewport = viewportRef.current; if (!viewport) return; - viewport.scrollTop = viewport.scrollHeight; - didInitialScrollRef.current = true; - }, [data?.logs]); + + // Check if user has scrolled up + const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; + if (isAtBottom) { + viewport.scrollTop = viewport.scrollHeight; + } + }, [logs]); + + // Handle manual scroll to determine auto-scroll behavior + const handleScroll = () => { + const viewport = viewportRef.current; + if (!viewport) return; + + const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; + shouldScrollRef.current = isAtBottom; + }; return ( - {isPending ? ( + {isLoading ? ( - ) : isError ? ( + ) : error ? ( - Failed to load logs + {error} ) : ( - - {data?.logs ? ( + + {logs ? (
(({ innerPr
                   overflowWrap: "anywhere",
                 }}
               >
-                {data.logs}
+                {logs}
               
) : ( diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index f6f109275..32fe9e0f4 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -57,6 +57,57 @@ export const getContainerLogsAsync = async (id: string, tail = 200) => { return decodeDockerLogs(rawLogs); }; +export const streamContainerLogsAsync = async ( + id: string, + tail: number, + onData: (data: string) => void, + onError: (err: Error) => void, +) => { + const container = await findContainerByIdAsync(id); + if (!container) { + onError(new Error("Container not found")); + return () => undefined; + } + + const stream = await container.logs({ + tail, + stdout: true, + stderr: true, + follow: true, + }); + + // Docker multiplexed stream processing + let buffer = Buffer.alloc(0); + + const processChunk = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 8) { + const length = buffer.readUInt32BE(4); + const fullMessageSize = 8 + length; + + if (buffer.length < fullMessageSize) { + // Wait for more data + break; + } + + const payload = buffer.subarray(8, fullMessageSize); + onData(payload.toString("utf-8")); + + buffer = buffer.subarray(fullMessageSize); + } + }; + + stream.on("data", processChunk); + stream.on("error", onError); + + return () => { + stream.removeListener("data", processChunk); + stream.removeListener("error", onError); + stream.destroy(); + }; +}; + const decodeDockerLogs = (logs: Buffer | string) => { if (typeof logs === "string") { return logs; From 9e9e259fd1a1573b83ce1e9c8e8b12d5949b77da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:58:41 +0000 Subject: [PATCH 4/8] Add return type annotation to safeValue function for consistency Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- packages/docker/src/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index d63598e21..a7d9b79dc 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -29,4 +29,4 @@ export const cpuUsageColor = (number: number, state: string): MantineColor => { return "red"; }; -export const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); +export const safeValue = (value?: number, fallback = 0): number => (value !== undefined && !isNaN(value) ? value : fallback); From 4497f6ca6a86ef0136432a9de709a0b861e96cdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:00:08 +0000 Subject: [PATCH 5/8] Refactor docker logs modal: extract helper function and prevent duplicate initial logs Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../src/docker/docker-logs-modal.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index c4471e6ff..36f6e7c4c 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -13,6 +13,13 @@ interface DockerLogsModalInnerProps { tail?: number; } +const SCROLL_THRESHOLD = 100; + +const isViewportAtBottom = (viewport: HTMLDivElement | null): boolean => { + if (!viewport) return false; + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < SCROLL_THRESHOLD; +}; + export const DockerLogsModal = createModal(({ innerProps }) => { const t = useI18n(); const viewportRef = useRef(null); @@ -20,6 +27,7 @@ export const DockerLogsModal = createModal(({ innerPr const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const shouldScrollRef = useRef(true); + const hasInitializedRef = useRef(false); // Get initial logs const { data: initialData } = clientApi.docker.logs.useQuery( @@ -45,11 +53,12 @@ export const DockerLogsModal = createModal(({ innerPr }, ); - // Initialize with static logs + // Initialize with static logs (only once) useEffect(() => { - if (initialData?.logs) { + if (initialData?.logs && !hasInitializedRef.current) { setLogs(initialData.logs); setIsLoading(false); + hasInitializedRef.current = true; } }, [initialData]); @@ -59,9 +68,7 @@ export const DockerLogsModal = createModal(({ innerPr const viewport = viewportRef.current; if (!viewport) return; - // Check if user has scrolled up - const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; - if (isAtBottom) { + if (isViewportAtBottom(viewport)) { viewport.scrollTop = viewport.scrollHeight; } }, [logs]); @@ -69,10 +76,7 @@ export const DockerLogsModal = createModal(({ innerPr // Handle manual scroll to determine auto-scroll behavior const handleScroll = () => { const viewport = viewportRef.current; - if (!viewport) return; - - const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; - shouldScrollRef.current = isAtBottom; + shouldScrollRef.current = isViewportAtBottom(viewport); }; return ( From 8b7d849cd01ba37273a34543708d30fde34395ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:01:39 +0000 Subject: [PATCH 6/8] Address code review nitpicks: improve comments and use named constant for header size Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../src/docker/docker-logs-modal.tsx | 2 +- packages/request-handler/src/docker.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index 36f6e7c4c..e75508681 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -67,7 +67,7 @@ export const DockerLogsModal = createModal(({ innerPr if (!shouldScrollRef.current) return; const viewport = viewportRef.current; if (!viewport) return; - + if (isViewportAtBottom(viewport)) { viewport.scrollTop = viewport.scrollHeight; } diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 32fe9e0f4..779b8a61c 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -76,22 +76,27 @@ export const streamContainerLogsAsync = async ( follow: true, }); - // Docker multiplexed stream processing + // Docker uses a multiplexed stream format for logs where each message has an 8-byte header: + // - 1 byte: stream type (stdout/stderr) + // - 3 bytes: padding + // - 4 bytes: payload length (big-endian) + // We need to process the stream in chunks and extract complete messages from the buffer. + const DOCKER_STREAM_HEADER_SIZE = 8; let buffer = Buffer.alloc(0); const processChunk = (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 8) { + while (buffer.length >= DOCKER_STREAM_HEADER_SIZE) { const length = buffer.readUInt32BE(4); - const fullMessageSize = 8 + length; + const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length; if (buffer.length < fullMessageSize) { // Wait for more data break; } - const payload = buffer.subarray(8, fullMessageSize); + const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize); onData(payload.toString("utf-8")); buffer = buffer.subarray(fullMessageSize); From 58a2c508c60481a03d5a57e2d2ca148ae53ceb3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:03:27 +0000 Subject: [PATCH 7/8] Fix race condition in subscription cleanup and add log size limit for memory management Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../api/src/router/docker/docker-router.ts | 64 +++++++++++-------- .../src/docker/docker-logs-modal.tsx | 15 ++++- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index dba8d0ad0..213789023 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -142,37 +142,47 @@ export const dockerRouter = createTRPCRouter({ ) .subscription(({ input }) => { return observable((emit) => { - let cleanup: (() => void) | undefined; + let cleanupFn: (() => void) | undefined; + let isSubscribed = true; - streamContainerLogsAsync( - input.id, - input.tail ?? 200, - (data) => { - emit.next(data); - }, - (err) => { - emit.error( - new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: err.message, - }), + const initializeStream = async () => { + try { + cleanupFn = await streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + if (isSubscribed) { + emit.next(data); + } + }, + (err) => { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + } + }, ); - }, - ) - .then((cleanupFn) => { - cleanup = cleanupFn; - }) - .catch((err) => { - emit.error( - new TRPCError({ - code: "NOT_FOUND", - message: err instanceof Error ? err.message : "Container not found", - }), - ); - }); + } catch (err) { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + } + } + }; + + void initializeStream(); return () => { - cleanup?.(); + isSubscribed = false; + cleanupFn?.(); }; }); }), diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index e75508681..d0924a5dc 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -38,12 +38,25 @@ export const DockerLogsModal = createModal(({ innerPr }, ); + // Maximum log size to prevent memory issues (500KB) + const MAX_LOG_SIZE = 500 * 1024; + // Subscribe to streaming logs clientApi.docker.subscribeLogs.useSubscription( { id: innerProps.id, tail: innerProps.tail ?? 200 }, { onData(data) { - setLogs((prev) => prev + data); + setLogs((prev) => { + const newLogs = prev + data; + // Truncate from the beginning if logs exceed maximum size + if (newLogs.length > MAX_LOG_SIZE) { + const truncated = newLogs.slice(-MAX_LOG_SIZE); + // Try to start from a newline to avoid partial lines + const firstNewline = truncated.indexOf("\n"); + return firstNewline > 0 ? truncated.slice(firstNewline + 1) : truncated; + } + return newLogs; + }); setIsLoading(false); }, onError(err) { From c351c7e09b5f46ea206aa1eddb8075fc59ccd7fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:04:57 +0000 Subject: [PATCH 8/8] Add payload size validation to prevent memory exhaustion from corrupted Docker streams Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- packages/request-handler/src/docker.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 779b8a61c..c73eaf014 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -82,6 +82,7 @@ export const streamContainerLogsAsync = async ( // - 4 bytes: payload length (big-endian) // We need to process the stream in chunks and extract complete messages from the buffer. const DOCKER_STREAM_HEADER_SIZE = 8; + const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB maximum message size for safety let buffer = Buffer.alloc(0); const processChunk = (chunk: Buffer) => { @@ -89,6 +90,13 @@ export const streamContainerLogsAsync = async ( while (buffer.length >= DOCKER_STREAM_HEADER_SIZE) { const length = buffer.readUInt32BE(4); + + // Validate message size to prevent memory exhaustion from corrupted data + if (length > MAX_MESSAGE_SIZE) { + onError(new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${MAX_MESSAGE_SIZE} bytes)`)); + return; + } + const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length; if (buffer.length < fullMessageSize) {