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
+12 -4
View File
@@ -44,7 +44,7 @@ export type HandshakeAction =
| { type: 'send-text'; text: string }
// `replyText`, when present, must be sent to the peer before any encrypted frame.
// `batch` is the negotiated frame-batching capability for the session.
| { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; replyText?: string }
| { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; flowControl: boolean; replyText?: string }
| { type: 'ignore' }
| { type: 'fail'; closeCode: number; reason: string };
@@ -60,8 +60,9 @@ const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage
if (message.v !== RELAY_PROTOCOL_VERSION) return null;
// Unknown/missing capability flag = false = legacy behavior.
const batch = message.batch === true;
const flowControl = message.flowControl === true;
if (message.t === 'ready') {
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch, flowControl };
}
if (
message.t === 'hello' &&
@@ -75,6 +76,7 @@ const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage
clientPubJwk: message.clientPubJwk as JsonWebKey,
nonce: message.nonce,
batch,
flowControl,
};
}
return null;
@@ -97,6 +99,7 @@ export interface ClientHandshake {
export interface ClientHandshakeOptions {
/** Advertise frame batching. Default true; set false to force legacy behavior. */
batch?: boolean;
flowControl?: boolean;
}
// hostEncPubJwk comes from the pairing offer (QR / deep link) and is the trust
@@ -114,8 +117,9 @@ export const createClientHandshake = async (
v: RELAY_PROTOCOL_VERSION,
clientPubJwk: await exportPublicKeyJwk(ephemeralKeyPair.publicKey),
nonce: bytesToBase64Url(nonce),
...(localBatch ? { batch: true } : {}),
};
if (localBatch) hello.batch = true;
if (options.flowControl !== false) hello.flowControl = true;
let established = false;
return {
helloText: JSON.stringify(hello),
@@ -143,6 +147,7 @@ export const createClientHandshake = async (
type: 'established',
// Batching runs only if both peers advertised it.
batch: localBatch && message.batch === true,
flowControl: options.flowControl !== false && message.flowControl === true,
channel: {
encryptor: createFrameEncryptor(keys.clientToHost),
decryptor: createFrameDecryptor(keys.hostToClient),
@@ -161,6 +166,7 @@ export interface HostHandshake {
export interface HostHandshakeOptions {
/** Support frame batching. Default true; set false to force legacy behavior. */
batch?: boolean;
flowControl?: boolean;
}
export const createHostHandshake = (
@@ -216,13 +222,15 @@ export const createHostHandshake = (
const ready: E2eeReadyMessage = {
t: 'ready',
v: RELAY_PROTOCOL_VERSION,
...(negotiatedBatch ? { batch: true } : {}),
};
if (negotiatedBatch) ready.batch = true;
if (options.flowControl !== false && message.flowControl === true) ready.flowControl = true;
readyText = JSON.stringify(ready);
established = true;
return {
type: 'established',
batch: negotiatedBatch,
flowControl: ready.flowControl === true,
replyText: readyText,
channel: {
encryptor: createFrameEncryptor(keys.hostToClient),
+5 -1
View File
@@ -47,6 +47,7 @@ export const TunnelFrameType = {
WsClose: 10,
Ping: 11,
Pong: 12,
DeliveryAck: 13,
} as const;
export type TunnelFrameTypeValue = (typeof TunnelFrameType)[keyof typeof TunnelFrameType];
@@ -97,6 +98,8 @@ export interface E2eeHelloMessage {
// Capability advertisement: the client can pack multiple tunnel frames into
// one encrypted WS message. Missing/false = legacy (one frame per message).
batch?: boolean;
/** Client supports cumulative downstream delivery acknowledgements. */
flowControl?: boolean;
}
export interface E2eeReadyMessage {
@@ -105,6 +108,8 @@ export interface E2eeReadyMessage {
// Host echoes `batch: true` only when it also supports batching AND the client
// advertised it. Batching is enabled for the session only if both agree.
batch?: boolean;
/** Enabled only when both peers support downstream flow control. */
flowControl?: boolean;
}
// Relay-assigned WebSocket close codes.
@@ -119,4 +124,3 @@ export const RelayCloseCode = {
RekeyMismatch: 1008,
ChannelFailure: 1011,
} as const;
+47 -20
View File
@@ -25,6 +25,7 @@ import {
encodeFragmentedMessage,
encodeJsonPayload,
encodeTunnelFrame,
encodeDeliveryAck,
type OutboundFrameBatcher,
type TunnelFrame,
} from './tunnel-codec';
@@ -162,6 +163,8 @@ export interface RelayTunnelClientOptions {
batchWindowMs?: number;
/** Advertise frame batching in the handshake. Default true. */
batch?: boolean;
/** Advertise downstream delivery acknowledgements. Default true. */
flowControl?: boolean;
reconnectBaseDelayMs?: number;
reconnectMaxDelayMs?: number;
hiddenOrOfflineMaxDelayMs?: number;
@@ -350,7 +353,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
let handshake;
try {
handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch });
handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch, flowControl: options.flowControl });
} catch (error) {
if (generation !== attemptGeneration || closed) return;
failAttempt(generation, toError(error), true);
@@ -380,12 +383,20 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
let channel: ActiveChannel | null = null;
let cryptoChannel: EstablishedChannelCrypto | null = null;
let batchNegotiated = false;
let flowControlNegotiated = false;
let receivedBytes = 0;
let acknowledgedBytes = 0;
let ackTimer: ReturnType<typeof setTimeout> | null = null;
let batcher: OutboundFrameBatcher | null = null;
// Idle tracking: updated on any non-Ping/Pong frame in EITHER direction.
// Ping/Pong are excluded so the keepalive can't sustain itself.
let lastActivityAt = Date.now();
const cleanupTimers = (): void => {
if (ackTimer !== null) {
clearTimeout(ackTimer);
ackTimer = null;
}
if (helloInterval !== null) {
clearInterval(helloInterval);
helloInterval = null;
@@ -444,9 +455,10 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}
};
const establish = (crypto: EstablishedChannelCrypto, batch: boolean): void => {
const establish = (crypto: EstablishedChannelCrypto, batch: boolean, flowControl: boolean): void => {
cryptoChannel = crypto;
batchNegotiated = batch;
flowControlNegotiated = flowControl;
if (helloInterval !== null) {
clearInterval(helloInterval);
helloInterval = null;
@@ -467,10 +479,11 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
.then(async () => {
if (channelObj.dead) return;
const encrypted = await crypto.encryptor.encrypt(plaintext);
if (channelObj.dead) return;
wire.send(encrypted);
})
.catch(() => {
// Send failures surface via wire close; do not break the chain.
failAttemptLocal(new Error('relay encrypt/send failed'));
});
};
const localBatcher = batch
@@ -485,7 +498,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
send(frame: Uint8Array): void {
if (channelObj.dead) return;
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong) {
if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong && frameType !== TunnelFrameType.DeliveryAck) {
lastActivityAt = Date.now();
}
if (localBatcher) localBatcher.enqueue(frame);
@@ -514,6 +527,14 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}, pingIntervalMs);
};
const acknowledgeDelivery = (): void => {
if (ackTimer !== null) clearTimeout(ackTimer);
ackTimer = null;
if (!channel || channel.dead || receivedBytes === acknowledgedBytes) return;
acknowledgedBytes = receivedBytes;
channel.send(encodeTunnelFrame(TunnelFrameType.DeliveryAck, 0, encodeDeliveryAck(receivedBytes)));
};
const handleTunnelFrame = (channelObj: ActiveChannel, plaintext: Uint8Array): void => {
let frame: TunnelFrame;
try {
@@ -522,6 +543,15 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
failAttemptLocal(toError(error));
return;
}
if (frame.frameType === TunnelFrameType.DeliveryAck) {
failAttemptLocal(new Error('unexpected downstream delivery acknowledgement'));
return;
}
if (flowControlNegotiated && frame.streamId !== 0) {
// Count even late/cancelled streams: they still consumed sender credit.
receivedBytes += plaintext.length;
if (ackTimer === null) ackTimer = setTimeout(acknowledgeDelivery, 10);
}
// Any received frame proves the tunnel is alive — clear the pong deadline.
if (pongDeadline !== null) {
clearTimeout(pongDeadline);
@@ -576,7 +606,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
const action = await handshake.handleText(data);
if (action.type === 'established') {
if (cryptoChannel) return;
establish(action.channel, action.batch);
establish(action.channel, action.batch, action.flowControl);
} else if (action.type === 'fail') {
failAttemptLocal(new Error(`relay handshake failed: ${action.reason}`));
}
@@ -605,23 +635,20 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
failAttemptLocal(toError(error));
return;
}
if (batchNegotiated) {
// One encrypted message may carry several tunnel frames; dispatch
// each in order through the same per-frame handling as legacy.
let frames: Uint8Array[];
try {
frames = decodeFrameBatch(plaintext);
} catch (error) {
failAttemptLocal(toError(error));
return;
}
for (const frame of frames) {
if (settled || generation !== attemptGeneration || currentChannel.dead) return;
handleTunnelFrame(currentChannel, frame);
}
let frames: Uint8Array[];
try {
frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext];
} catch (error) {
failAttemptLocal(toError(error));
return;
}
handleTunnelFrame(currentChannel, plaintext);
for (const frame of frames) {
if (settled || generation !== attemptGeneration || currentChannel.dead) return;
handleTunnelFrame(currentChannel, frame);
}
// One ACK per received batch, rather than one per fragment. Small
// tails use the timer so a final frame cannot strand sender credit.
if (flowControlNegotiated && receivedBytes - acknowledgedBytes >= 16 * 1024) acknowledgeDelivery();
})
.catch((error: unknown) => {
failAttemptLocal(toError(error));
@@ -18,6 +18,14 @@ import {
const MAX_STREAM_ID = 0xffffffff;
/** Cumulative raw tunnel-frame bytes, excluding stream zero and batch/crypto overhead. */
export const encodeDeliveryAck = (receivedBytes: number): Uint8Array => {
if (!Number.isSafeInteger(receivedBytes) || receivedBytes < 0) throw new Error('invalid delivery acknowledgement');
const payload = new Uint8Array(8);
new DataView(payload.buffer).setBigUint64(0, BigInt(receivedBytes));
return payload;
};
export class TunnelCodecError extends Error {
constructor(message: string) {
super(message);