Files
openchamber/packages/web/server/lib/relay/tunnel-host.test.js
T
Muhammad Zaim 6a2f0b8135 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.
2026-08-12 16:54:23 +03:00

117 lines
4.3 KiB
JavaScript

import { describe, test, expect } from 'bun:test';
import http from 'node:http';
import { createTunnelHost } from './tunnel-host.js';
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
const startLoopback = () =>
new Promise((resolve) => {
const requests = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
requests.push({
method: req.method,
url: req.url,
body: Buffer.concat(chunks).toString('utf8'),
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
server.listen(0, '127.0.0.1', () => resolve({
server,
port: server.address().port,
requests,
stop: () => new Promise((r) => server.close(() => r())),
}));
});
const createHarness = async () => {
const loopback = await startLoopback();
const sentFrames = [];
const host = createTunnelHost({
connectionId: 'conn-test',
getLocalPort: () => loopback.port,
sendFrame: async (frame) => {
sentFrames.push(decodeTunnelFrame(frame));
},
getBufferedAmount: () => 0,
});
return { host, loopback, sentFrames };
};
const httpHead = (overrides = {}) => encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
method: 'POST',
path: '/api/submit',
query: '',
headers: { 'content-type': 'application/json' },
...overrides,
}));
const waitFor = async (predicate, timeoutMs = 2000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 20));
}
return predicate();
};
describe('tunnel-host HTTP body forwarding', () => {
test('buffers tunneled body frames and forwards the complete body', async () => {
const { host, loopback, sentFrames } = await createHarness();
await host.handleFrame(httpHead());
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new TextEncoder().encode('alpha')));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new TextEncoder().encode('beta')));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].method).toBe('POST');
expect(loopback.requests[0].body).toBe('alphabeta');
await waitFor(() => sentFrames.some((f) => f.frameType === TunnelFrameType.StreamEnd));
await loopback.stop();
});
test('body-expected request with zero delivered frames is aborted as ambiguous, not forwarded', async () => {
const { host, loopback, sentFrames } = await createHarness();
await host.handleFrame(httpHead({ hasBody: true }));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const aborted = await waitFor(() => sentFrames.some((f) => f.frameType === TunnelFrameType.StreamAbort));
expect(aborted).toBe(true);
// Loopback must never have seen a request with a lost body.
expect(loopback.requests.length).toBe(0);
await loopback.stop();
});
test('bodyless request (hasBody absent, legacy client) still forwards empty', async () => {
const { host, loopback } = await createHarness();
await host.handleFrame(httpHead());
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].body).toBe('');
await loopback.stop();
});
test('GET forwards immediately with no body wait', async () => {
const { host, loopback } = await createHarness();
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
method: 'GET',
path: '/api/health',
query: '',
headers: {},
})));
await host.handleFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0)));
const received = await waitFor(() => loopback.requests.length === 1);
expect(received).toBe(true);
expect(loopback.requests[0].method).toBe('GET');
await loopback.stop();
});
});