From df0716973047bca447a4e1ccd5ecd65082017c40 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:15:03 +1000 Subject: [PATCH 1/2] fix(api): stream uploads to the file-server with fetch, not axios over node:http On Bun, node:http's ClientRequest can drop the tail of a chunked request body: write() accepts every byte and end() is called after the last write, yet the peer receives 32 KiB-800 KiB less. The file-server then stores a short object and reports success, so a 20 MiB upload comes back corrupt with no error anywhere (Bun 1.3.10-1.3.14; 1 MiB is unaffected). Send the busboy file part with the global fetch and a web ReadableStream instead. Bun's native fetch and Node's undici stream the body intact. Co-Authored-By: Claude Fable 5.1 --- service/src/service/router.ts | 73 ++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/service/src/service/router.ts b/service/src/service/router.ts index f89bbb17..2c42f60c 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -3,7 +3,7 @@ import busboy from 'busboy'; import { nanoid } from 'nanoid'; import { Router } from 'express'; import type { Response } from 'express'; -import type { Readable } from 'stream'; +import { Readable } from 'stream'; import type * as t from '../types'; import { checkServiceStartUp, checkServiceShutDown } from '../lifecycle'; import { sessionAuth } from '../middleware/auth'; @@ -42,6 +42,43 @@ const JOB_COMPLETION_WAIT_TIMEOUT_MS = jobCompletionWaitTimeoutMs( ); const UPLOAD_TIMEOUT_MS = 30_000; + +/** + * Streams one busboy file part to the file-server. + * + * Uses the global `fetch` rather than axios on purpose. axios routes a + * stream body through `node:http`'s `ClientRequest`, and on Bun (the + * runtime in `Dockerfile.api`) that client can drop the tail of a + * chunked request body: every byte is accepted by `write()`, `end()` + * is called after the last write, yet the peer receives 32 KiB-800 KiB + * less and the file-server stores a short object while reporting + * success (reproduced on Bun 1.3.10-1.3.14 with a 20 MiB upload; a + * 1 MiB upload is unaffected). Bun's native `fetch` and Node's undici + * stream the same body intact. busboy's `limits.fileSize` already caps + * the part, so no separate body-length guard is needed here. + */ +async function putFileToFileServer( + url: string, + file: Readable, + headers: Record, + signal: AbortSignal, +): Promise { + const response = await fetch(url, { + method: 'PUT', + headers, + body: Readable.toWeb(file) as unknown as ReadableStream, + signal, + /* Required by the WHATWG fetch spec for streamed request bodies. */ + duplex: 'half', + } as RequestInit); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error( + `file-server responded ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, + ); + } + return (await response.json()) as t.UploadResult; +} /* Batch cap sized for skill-priming uploads: a single skill (e.g. pptx) * can carry 60+ resource files including .xsd schemas, helper scripts, * docs, and Python __init__.py markers. The previous cap of 20 silently @@ -500,20 +537,16 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R recordSessionOwnership(connection, session_id, sessionKey) .then(() => { logger.info(`[${INSTANCE_ID}] Upload: Session ID: ${session_id} | User ID: ${userId} | Session key: ${sessionKey}`); - return axios.put( - `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, - file, - { - headers: internalServiceHeaders(putHeaders), - maxBodyLength: planFileSize, - maxContentLength: planFileSize, - signal: abortController.signal, - }, - ); + return putFileToFileServer( + `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, + file, + internalServiceHeaders(putHeaders), + abortController.signal, + ); }) - .then(response => { + .then(result => { clearTimeout(uploadTimeout); - resolve(response.data); + resolve(result); }) .catch(error => { clearTimeout(uploadTimeout); @@ -742,18 +775,14 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, logger.error(`[${INSTANCE_ID}] Batch upload file failed: ${filename} | Session: ${session_id}`, { error: message }); resolve({ status: 'error', filename, error: message }); }; - const forwardFile = (): Promise => axios.put( + const forwardFile = (): Promise => putFileToFileServer( `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, file, - { - headers: internalServiceHeaders(putHeaders), - maxBodyLength: planFileSize, - maxContentLength: planFileSize, - signal: abortController.signal, - }, - ).then(response => { + internalServiceHeaders(putHeaders), + abortController.signal, + ).then(result => { clearTimeout(uploadTimeout); - resolve({ status: 'success', filename: response.data.filename, fileId: response.data.fileId }); + resolve({ status: 'success', filename: result.filename, fileId: result.fileId }); }, resolveUploadFailure); void ensureSessionRegistered(sessionKey) From 6c5586a901cbe1014177cb0bf00fa83b934a2585 Mon Sep 17 00:00:00 2001 From: Mihidum Hettiyahandi <55163074+mihidumh@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:15:03 +1000 Subject: [PATCH 2/2] build: bump Bun base images 1.3.14 -> 1.4.2 Bun 1.3.x's node:http client drops the tail of chunked request bodies under load (see the previous commit). Bun 1.4.2 streams them intact in the same test, so the base image bump is the second line of defence for every remaining node:http client in the services. Co-Authored-By: Claude Fable 5.1 --- service/Dockerfile | 10 +++++----- service/Dockerfile.api | 4 ++-- service/Dockerfile.bun | 2 +- service/Dockerfile.egress-gateway | 4 ++-- service/Dockerfile.local | 4 ++-- service/Dockerfile.service | 2 +- service/Dockerfile.tool-call-server | 4 ++-- service/Dockerfile.worker | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/service/Dockerfile b/service/Dockerfile index 00680111..d7bf9036 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -1,5 +1,5 @@ # File Server Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/worker-server.ts --minify --outdir .build-worker --target bu RUN bun build ./src/egress-gateway.ts --minify --outdir .build-egress-gateway --target bun --external '@opentelemetry/*' # File server production -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -35,7 +35,7 @@ COPY --from=builder /app/.build ./.build CMD ["bun", "run", ".build/file-server.js"] # API server (HTTP on port 3112) -FROM oven/bun:1.3.14 AS api +FROM oven/bun:1.4.2 AS api ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -45,7 +45,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-api/api-server.js"] # Worker server (job processor, health on port 3113) -FROM oven/bun:1.3.14 AS worker +FROM oven/bun:1.4.2 AS worker ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules @@ -54,7 +54,7 @@ COPY --from=builder /app/src/*.py ./src/ CMD ["bun", "run", ".build-worker/worker-server.js"] # Egress gateway (sandbox outbound delegation) -FROM oven/bun:1.3.14 AS egress-gateway +FROM oven/bun:1.4.2 AS egress-gateway ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.api b/service/Dockerfile.api index 2921401e..1da68c46 100644 --- a/service/Dockerfile.api +++ b/service/Dockerfile.api @@ -1,7 +1,7 @@ # API-Only Server Dockerfile # This builds the HTTP API server without workers # Scale this based on HTTP traffic -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -27,7 +27,7 @@ RUN bun build ./src/api-server.ts --minify --outdir .build --target bun --extern RUN bun build ./scripts/rehydrate-session-cache.ts --minify --outdir .build-migrations --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck (not included in bun base image) diff --git a/service/Dockerfile.bun b/service/Dockerfile.bun index b416f14e..d545f32c 100644 --- a/service/Dockerfile.bun +++ b/service/Dockerfile.bun @@ -1,5 +1,5 @@ # Base stage -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.egress-gateway b/service/Dockerfile.egress-gateway index ca9e9a9d..bd1d2b64 100644 --- a/service/Dockerfile.egress-gateway +++ b/service/Dockerfile.egress-gateway @@ -1,5 +1,5 @@ # Egress Gateway Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app FROM base AS install @@ -11,7 +11,7 @@ RUN mkdir -p /temp/prod COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* diff --git a/service/Dockerfile.local b/service/Dockerfile.local index cbb7af13..4ed93ee4 100644 --- a/service/Dockerfile.local +++ b/service/Dockerfile.local @@ -1,5 +1,5 @@ # Local development Dockerfile - no authentication required -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -22,7 +22,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/local-api.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.service b/service/Dockerfile.service index 3a89dc15..d5d4e6d9 100644 --- a/service/Dockerfile.service +++ b/service/Dockerfile.service @@ -1,5 +1,5 @@ # Service API Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /usr/src/app # Install dependencies diff --git a/service/Dockerfile.tool-call-server b/service/Dockerfile.tool-call-server index 355e8ec1..76afb837 100644 --- a/service/Dockerfile.tool-call-server +++ b/service/Dockerfile.tool-call-server @@ -1,5 +1,5 @@ # Tool Call Server Dockerfile -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -13,7 +13,7 @@ COPY service/package.json service/bun.lock /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app COPY --from=install /temp/prod/node_modules ./node_modules diff --git a/service/Dockerfile.worker b/service/Dockerfile.worker index e99c16c4..bb7b6945 100644 --- a/service/Dockerfile.worker +++ b/service/Dockerfile.worker @@ -2,7 +2,7 @@ # This builds the job processing worker without HTTP server # Deploy alongside a sandbox sidecar for execution # Scale this based on queue depth -FROM oven/bun:1.3.14 AS base +FROM oven/bun:1.4.2 AS base WORKDIR /app # Install dependencies @@ -25,7 +25,7 @@ COPY service/tsconfig.json ./ RUN bun build ./src/worker-server.ts --minify --outdir .build --target bun --external '@opentelemetry/*' # Production stage -FROM oven/bun:1.3.14 AS production +FROM oven/bun:1.4.2 AS production ENV NODE_ENV=production WORKDIR /app # Install curl for healthcheck