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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user