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.
155 lines
5.6 KiB
JavaScript
155 lines
5.6 KiB
JavaScript
// Downstream credit belongs to the end client, not the relay's TCP connection.
|
|
// Producers await send(), so HTTP readers stop before building a plaintext queue.
|
|
import { decodeTunnelFrame } from './tunnel-codec.js';
|
|
import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
|
|
|
|
export const DOWNSTREAM_CHUNK_BYTES = 8 * 1024;
|
|
const MIN_WINDOW_BYTES = 64 * 1024;
|
|
const MAX_WINDOW_BYTES = 1024 * 1024;
|
|
const MAX_QUEUED_BYTES = 4 * 1024 * 1024;
|
|
const MAX_QUEUED_FRAMES = 4096;
|
|
const MAX_OUTSTANDING_FRAMES = 4096;
|
|
const QUEUE_DELAY_TARGET_MS = 100;
|
|
|
|
export const createDownstreamScheduler = ({ sendBatch, onError, maxBatchFrames = 32, now = () => performance.now() }) => {
|
|
const queues = new Map();
|
|
const outstanding = [];
|
|
let queuedBytes = 0;
|
|
let queuedFrames = 0;
|
|
let sentBytes = 0;
|
|
let acknowledgedBytes = 0;
|
|
let windowBytes = MIN_WINDOW_BYTES;
|
|
let minRtt = Infinity;
|
|
let adjustmentBoundary = MIN_WINDOW_BYTES;
|
|
let windowLimited = false;
|
|
let draining = false;
|
|
let closed = false;
|
|
let sending = [];
|
|
|
|
const close = () => {
|
|
closed = true;
|
|
for (const queue of queues.values()) for (const entry of queue) entry.resolve();
|
|
for (const entry of sending) entry.resolve();
|
|
sending = [];
|
|
queues.clear();
|
|
outstanding.length = 0;
|
|
queuedBytes = 0;
|
|
queuedFrames = 0;
|
|
};
|
|
|
|
const fail = error => {
|
|
close();
|
|
onError(error);
|
|
};
|
|
|
|
const drain = async () => {
|
|
if (draining || closed) return;
|
|
draining = true;
|
|
try {
|
|
while (!closed) {
|
|
const selected = [];
|
|
let batchBytes = 1;
|
|
while (selected.length < maxBatchFrames) {
|
|
// Stream zero carries only tiny keepalives; it must not wait for credit.
|
|
const next = queues.has(0) ? [0, queues.get(0)] : queues.entries().next().value;
|
|
if (!next) break;
|
|
const [streamId, queue] = next;
|
|
const entry = queue[0];
|
|
// Do not skip a larger head indefinitely in favour of smaller frames.
|
|
if (streamId !== 0 && sentBytes - acknowledgedBytes + entry.frame.length > windowBytes) {
|
|
windowLimited = true;
|
|
break;
|
|
}
|
|
if (streamId !== 0 && outstanding.length >= MAX_OUTSTANDING_FRAMES) break;
|
|
if (batchBytes + 4 + entry.frame.length > MAX_PLAINTEXT_FRAME_BYTES) break;
|
|
queue.shift();
|
|
queues.delete(streamId);
|
|
if (queue.length) queues.set(streamId, queue);
|
|
queuedBytes -= entry.frame.length;
|
|
queuedFrames -= 1;
|
|
if (streamId !== 0) {
|
|
sentBytes += entry.frame.length;
|
|
outstanding.push({ end: sentBytes, at: now() });
|
|
}
|
|
selected.push(entry);
|
|
batchBytes += 4 + entry.frame.length;
|
|
}
|
|
if (!selected.length) break;
|
|
sending = selected;
|
|
try {
|
|
// Selection precedes encryption. Never reorder encrypted IV counters.
|
|
await sendBatch(selected.map(entry => entry.frame));
|
|
} finally {
|
|
for (const entry of selected) entry.resolve();
|
|
sending = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
fail(error);
|
|
} finally {
|
|
draining = false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
send(frame) {
|
|
if (closed) return Promise.resolve();
|
|
const { streamId } = decodeTunnelFrame(frame);
|
|
if (queuedBytes + frame.length > MAX_QUEUED_BYTES || queuedFrames >= MAX_QUEUED_FRAMES) {
|
|
fail(new Error('relay downstream queue limit exceeded'));
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise(resolve => {
|
|
const queue = queues.get(streamId) ?? [];
|
|
queue.push({ frame, resolve, streamId });
|
|
queues.set(streamId, queue);
|
|
queuedBytes += frame.length;
|
|
queuedFrames += 1;
|
|
// Let concurrent streams join this round before selecting the next frame.
|
|
queueMicrotask(() => { void drain(); });
|
|
});
|
|
},
|
|
acknowledge(bytes) {
|
|
if (closed) return;
|
|
if (!Number.isSafeInteger(bytes) || bytes <= acknowledgedBytes || bytes > sentBytes) {
|
|
throw new Error('invalid relay delivery acknowledgement');
|
|
}
|
|
// An ACK may cover several complete frames, never a partial frame.
|
|
const endIndex = outstanding.findIndex(entry => entry.end === bytes);
|
|
if (endIndex < 0) throw new Error('relay acknowledgement is not a frame boundary');
|
|
const rtt = Math.max(1, now() - outstanding[endIndex].at);
|
|
outstanding.splice(0, endIndex + 1);
|
|
acknowledgedBytes = bytes;
|
|
minRtt = Math.min(minRtt, rtt);
|
|
// Grow on a clear path, reduce when the client's delivery delay grows.
|
|
// Adjust once per window, not once per frame in a burst of ACKs.
|
|
if (bytes >= adjustmentBoundary) {
|
|
if (rtt > minRtt + QUEUE_DELAY_TARGET_MS) {
|
|
windowBytes = Math.max(MIN_WINDOW_BYTES, Math.floor(windowBytes / 2));
|
|
} else if (windowLimited) {
|
|
// Sparse token traffic must not inflate a future bash burst's window.
|
|
windowBytes = Math.min(MAX_WINDOW_BYTES, windowBytes * 2);
|
|
}
|
|
windowLimited = false;
|
|
adjustmentBoundary = bytes + windowBytes;
|
|
}
|
|
void drain();
|
|
},
|
|
cancel(streamId) {
|
|
// Already-selected bytes may still arrive and must still be ACKed, but
|
|
// cancelled producers need not wait for an in-progress encryption call.
|
|
for (const entry of sending) if (entry.streamId === streamId) entry.resolve();
|
|
const queue = queues.get(streamId);
|
|
if (!queue) return;
|
|
queues.delete(streamId);
|
|
for (const entry of queue) {
|
|
queuedBytes -= entry.frame.length;
|
|
queuedFrames -= 1;
|
|
entry.resolve();
|
|
}
|
|
void drain();
|
|
},
|
|
close,
|
|
};
|
|
};
|