fix(relay): keep bulk streaming from blocking client requests

Negotiate downstream delivery credit and schedule response streams fairly before encryption. Bound queued output, preserve deltas and WebSocket close ordering, and retain legacy peer compatibility.

Validated with 81 relay tests, workspace type-check and lint, web build and mobile assets, and slow-link tests through the production relay. Confirmed on LTE by the maintainer.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent 4d8148587c
commit af84735388
13 changed files with 761 additions and 58 deletions
+37 -8
View File
@@ -6,8 +6,9 @@
import { WebSocket } from 'ws';
import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
import { createOutboundFrameBatcher, decodeFrameBatch } from './tunnel-codec.js';
import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
import { createTunnelHost } from './tunnel-host.js';
import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
const BACKOFF_BASE_MS = 1000;
const BACKOFF_CAP_MS = 30000;
@@ -48,7 +49,7 @@ const resolveBatchWindowMs = (option) => {
* logger?: Pick<Console, 'warn'>,
* }} options
*/
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
const localBatch = batch !== false;
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
@@ -95,6 +96,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
dataSockets.delete(connectionId);
if (entry.openTimer) clearTimeout(entry.openTimer);
entry.batcher?.dispose();
entry.scheduler?.close();
entry.tunnel?.close();
try {
if (entry.socket.readyState === WebSocket.OPEN || entry.socket.readyState === WebSocket.CONNECTING) {
@@ -118,14 +120,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
return;
}
const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
const entry = { socket, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
dataSockets.set(connectionId, entry);
entry.openTimer = setTimeout(() => {
logger.warn('[Relay] host-data socket open timeout');
teardownDataSocket(connectionId);
}, DATA_SOCKET_OPEN_TIMEOUT_MS);
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch });
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch, flowControl });
let channel = null;
let batchNegotiated = false;
// Serialize async message handling so encrypted frame order (and the
@@ -140,11 +142,14 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
.then(async () => {
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN || !channel) return;
const encrypted = await channel.encryptor.encrypt(plaintext);
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
socket.send(encrypted, { binary: true });
})
.catch((error) => {
logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
failChannel(RelayCloseCode.ChannelFailure, 'send failed');
});
return sendChain;
};
const failChannel = (closeCode, reason) => {
@@ -167,17 +172,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
} else if (action.type === 'established') {
channel = action.channel;
batchNegotiated = action.batch === true;
entry.batcher = batchNegotiated
entry.scheduler = action.flowControl ? createDownstreamScheduler({
sendBatch: frames => sendEncryptedPlaintext(batchNegotiated ? encodeFrameBatch(frames) : frames[0]),
maxBatchFrames: batchNegotiated ? 32 : 1,
onError: () => failChannel(RelayCloseCode.ChannelFailure, 'downstream queue failed'),
}) : null;
entry.batcher = batchNegotiated && !entry.scheduler
? createOutboundFrameBatcher({ windowMs: resolvedBatchWindowMs, sendBatch: sendEncryptedPlaintext })
: null;
entry.tunnel = createTunnelHost({
connectionId,
getLocalPort: resolveLocalPort,
getBufferedAmount: () => socket.bufferedAmount,
responseChunkBytes: entry.scheduler ? DOWNSTREAM_CHUNK_BYTES : undefined,
cancelPendingFrames: streamId => entry.scheduler?.cancel(streamId),
sendFrame: (plaintextFrame) => {
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
if (entry.scheduler) return entry.scheduler.send(plaintextFrame);
if (entry.batcher) entry.batcher.enqueue(plaintextFrame);
else sendEncryptedPlaintext(plaintextFrame);
else return sendEncryptedPlaintext(plaintextFrame);
},
});
if (action.replyText) socket.send(action.replyText);
@@ -205,16 +218,32 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
// in order through the same per-frame handling as legacy.
for (const frame of decodeFrameBatch(plaintext)) {
if (dataSockets.get(connectionId) !== entry) return;
await entry.tunnel.handleFrame(frame);
await dispatchFrame(frame);
}
} else {
await entry.tunnel.handleFrame(plaintext);
await dispatchFrame(plaintext);
}
} catch (error) {
logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
}
};
const dispatchFrame = (plaintext) => {
const frame = decodeTunnelFrame(plaintext);
if (frame.frameType === TunnelFrameType.DeliveryAck) {
if (!entry.scheduler || frame.streamId !== 0 || frame.hasMoreFragments) {
throw new Error('unexpected delivery acknowledgement');
}
entry.scheduler.acknowledge(decodeDeliveryAck(frame.payload));
return;
}
// Never await outbound credit on the receive chain: ACKs use this chain too.
void entry.tunnel.handleFrame(plaintext).catch(() => {
failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
});
};
socket.on('open', () => {
if (entry.openTimer) {
clearTimeout(entry.openTimer);