fix(relay): never forward a tunneled body that lost frames (#2822)

When the relay drops mid-request, the prompt_async body frames can be
lost. The tunnel host forwarded the request to loopback as an
empty/truncated chunked body, which the server rejects with a bare 400
(empty response body) — the mobile app's 'Failed to send message (400)'.

Host now buffers request bodies (<512KB) and forwards the complete body
only once StreamEnd arrives; larger bodies still stream live. A new
hasBody flag on the request head lets the host detect a body that
delivered zero frames and abort it as an ambiguous transport failure
(which the client already retries) instead of forwarding an empty body.
A 15s body-delivery deadline converts stalled tunnels into clean aborts.
This commit is contained in:
Muhammad Zaim
2026-08-12 16:54:23 +03:00
committed by GitHub
parent 9e43b9ae46
commit 6a2f0b8135
5 changed files with 274 additions and 31 deletions
+150 -31
View File
@@ -62,6 +62,17 @@ const STRIPPED_RESPONSE_HEADERS = new Set([
const BACKPRESSURE_LIMIT_BYTES = 4 * 1024 * 1024;
const BACKPRESSURE_POLL_MS = 20;
// Bodies smaller than this are fully buffered before the loopback request is
// sent, so a tunneled body that lost frames (relay reconnect, dropped HttpBody
// frames) can never reach the loopback server as an empty/truncated chunked
// body — the server rejects those with a bare 400, surfacing as the mobile
// app's "Failed to send message (400)". Larger bodies stream live as before.
const BODY_BUFFER_MAX_BYTES = 512 * 1024;
// While the body is still being buffered, abort the stream if it never
// completes, so a stalled tunnel converts into an ambiguous transport failure
// (which the client already retries) instead of a hung loopback request.
const BODY_DELIVERY_TIMEOUT_MS = 15_000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isHttpRequestPayload = (parsed) =>
@@ -168,39 +179,14 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
await send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
};
const runHttpStream = async (streamId, request) => {
const method = request.method.toUpperCase();
if (!isAllowedHttpPath(request.path)) {
dropStream(streamId);
await syntheticResponse(streamId, 403, 'Path is not allowed through the relay');
return;
}
const stream = streams.get(streamId);
if (!stream || stream.kind !== 'http') return;
const hasBody = method !== 'GET' && method !== 'HEAD';
let requestBody;
if (hasBody) {
requestBody = new ReadableStream({
start(controller) {
stream.body = controller;
},
});
} else {
stream.body = null;
stream.noBody = true;
}
const loopbackOrigin = `http://127.0.0.1:${getLocalPort()}`;
const url = `${loopbackOrigin}${request.path}${request.query ? `?${request.query}` : ''}`;
const forwardRequest = async (streamId, stream, url, method, request, body, loopbackOrigin) => {
let response;
try {
response = await fetch(url, {
method,
headers: buildRequestHeaders(request.headers, loopbackOrigin),
body: requestBody,
duplex: hasBody ? 'half' : undefined,
body,
duplex: body ? 'half' : undefined,
signal: stream.abort.signal,
});
} catch (error) {
@@ -242,6 +228,139 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
}
};
const runHttpStream = async (streamId, request) => {
const method = request.method.toUpperCase();
if (!isAllowedHttpPath(request.path)) {
dropStream(streamId);
await syntheticResponse(streamId, 403, 'Path is not allowed through the relay');
return;
}
const stream = streams.get(streamId);
if (!stream || stream.kind !== 'http') return;
const hasBody = method !== 'GET' && method !== 'HEAD';
const loopbackOrigin = `http://127.0.0.1:${getLocalPort()}`;
const url = `${loopbackOrigin}${request.path}${request.query ? `?${request.query}` : ''}`;
if (!hasBody) {
stream.noBody = true;
await forwardRequest(streamId, stream, url, method, request, null, loopbackOrigin);
return;
}
// Body-carrying request. Buffer the tunneled body frames and forward the
// COMPLETE body only once StreamEnd arrives. Forwarding a body that lost
// frames through the tunnel (relay reconnect, dropped HttpBody frames)
// reaches the loopback server as an empty/truncated chunked body, which it
// rejects with a bare 400 (empty response body) — the "Failed to send
// message (400)" seen from the mobile APK. Bodies above BODY_BUFFER_MAX_BYTES
// stream live so large uploads are not fully buffered.
const buffered = [];
let bufferedBytes = 0;
let bodyFrameCount = 0;
let liveStream = null;
let liveController = null;
let completed = false;
let bodyFailure = null;
let resolveBodyEnd;
const bodyEnded = new Promise((resolve) => { resolveBodyEnd = resolve; });
const finishBody = (error) => {
if (completed) return;
completed = true;
bodyFailure = error ?? null;
if (liveController) {
try {
if (error) liveController.error(error);
else liveController.close();
} catch {
// stream already errored/closed
}
}
resolveBodyEnd();
};
let deliveryDeadline = null;
const switchToLive = () => {
liveStream = new ReadableStream({
start(controller) {
liveController = controller;
stream.body = controller;
},
});
for (const chunk of buffered) {
try { liveController.enqueue(chunk); } catch { break; }
}
buffered.length = 0;
// The loopback request is now streaming live; runHttpStream has nothing
// left to do — clear the deadline and let the async body forwarding own
// this stream from here (abort/StreamEnd close the controller).
if (deliveryDeadline) clearTimeout(deliveryDeadline);
resolveBodyEnd();
void forwardRequest(streamId, stream, url, method, request, liveStream, loopbackOrigin);
};
stream.body = {
enqueue(payload) {
if (completed) return;
bodyFrameCount += 1;
if (liveController) {
try { liveController.enqueue(payload); } catch {
// stream already errored/closed
}
return;
}
buffered.push(payload);
bufferedBytes += payload.length;
if (bufferedBytes > BODY_BUFFER_MAX_BYTES) {
switchToLive();
}
},
close() {
finishBody(null);
},
error(error) {
finishBody(error);
},
};
deliveryDeadline = setTimeout(() => {
if (streams.get(streamId) === stream && !completed && !liveStream) {
dropStream(streamId);
void sendAbort(streamId, 'tunnel request body was not delivered in time');
}
}, BODY_DELIVERY_TIMEOUT_MS);
deliveryDeadline.unref?.();
await bodyEnded;
if (deliveryDeadline) clearTimeout(deliveryDeadline);
if (streams.get(streamId) !== stream) return; // aborted or dropped meanwhile
if (bodyFailure) {
dropStream(streamId);
await sendAbort(streamId, bodyFailure.message ?? 'tunnel request body failed');
return;
}
if (liveStream) return; // already forwarded via the streaming path
// The client signaled it had a body but no HttpBody frame arrived before
// StreamEnd — the body frames were lost through the tunnel. Forwarding an
// empty body would make the loopback server reject the request with a bare
// 400. Abort instead so the client treats it as an ambiguous transport
// failure (dispatched, outcome unknown) and can safely retry.
if (request.hasBody === true && bodyFrameCount === 0) {
dropStream(streamId);
await sendAbort(streamId, 'tunnel request body frames were lost');
return;
}
// Buffered path: forward the complete body as a single buffer so Bun
// frames it with content-length — never as a chunked body that could be
// truncated. Reset the buffered handler so late frames cannot enqueue.
stream.body = null;
await forwardRequest(streamId, stream, url, method, request, Buffer.concat(buffered), loopbackOrigin);
};
const handleHttpRequest = (streamId, payload) => {
if (streams.has(streamId)) {
abortLocalStream(streamId, 'duplicate stream id');
@@ -263,9 +382,9 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
const handleHttpBody = (streamId, payload) => {
const stream = streams.get(streamId);
if (!stream || stream.kind !== 'http' || stream.noBody) return;
// The body controller attaches synchronously in runHttpStream before any
// await, so by the time body frames arrive it is set for body-carrying
// methods; drop stray body bytes otherwise.
// runHttpStream installs a body sink (buffering handler, or the live stream
// controller once the buffer cap is crossed) before any HttpBody frame can
// arrive; drop stray bytes for request bodies already completed/aborted.
try {
stream.body?.enqueue(payload);
} catch {