Keep large routed timelines connected when the receiver falls behind (#9109)

* fix(server): pace routed output to its receiving client

A routed transfer previously waited only for the destination-to-intermediary write, so a slower final hop overflowed the route queue. Negotiate bounded per-channel acknowledgments after final forwarding drains, preserving concurrent small sends and legacy peers.

* fix(transport): keep small routed writes concurrent

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-08 15:02:04 +00:00
committed by GitHub
parent 27872a8e86
commit 01888dc5de
16 changed files with 618 additions and 40 deletions
@@ -390,3 +390,65 @@ it("unpaced transports retain ordinary outbound admission and accept inbound chu
expect(delivered).toEqual(["A"]);
await transport.send("small");
});
it("fills an ordered writer's window while keeping chunk sequences contiguous", async () => {
const delivered: (string | Uint8Array)[] = [];
const receiver = new ChunkedTransport({
frameBytes: 8,
send() {},
deliver: (frame) => {
delivered.push(frame);
},
});
receiver.enabled = true;
const drain = Promise.withResolvers<void>();
const frames: (string | Uint8Array)[] = [];
const sender = new ChunkedTransport({
frameBytes: 8,
supportsConcurrentWrites: true,
send: (frame) => {
frames.push(frame);
receiver.receive(frame);
return drain.promise;
},
deliver() {},
});
sender.enabled = true;
const first = sender.send("one");
const second = sender.send("two");
expect(delivered).toEqual(["one", "two"]);
expect(sender.queuedByteLength).toBe(0);
const large = sender.send("large message");
const after = sender.send("after");
expect(frames).toHaveLength(3);
expect(sender.queuedByteLength).toBe(5);
drain.resolve();
await Promise.all([first, second, large, after]);
expect(delivered).toEqual(["one", "two", "large message", "after"]);
expect(sender.queuedByteLength).toBe(0);
});
it("accounts for independently draining writes and a rejected admission", async () => {
const firstDrain = Promise.withResolvers<void>();
const secondDrain = Promise.withResolvers<void>();
const sender = new ChunkedTransport({
frameBytes: 8,
supportsConcurrentWrites: true,
send: (frame) => {
if (frame === "reject") throw new Error("capacity");
return frame === "one" ? firstDrain.promise : secondDrain.promise;
},
deliver() {},
});
const first = sender.send("one");
const second = sender.send("two");
expect(() => sender.send("reject")).toThrow("capacity");
expect(sender.queuedByteLength).toBe(0);
secondDrain.resolve();
await second;
expect(sender.queuedByteLength).toBe(0);
firstDrain.resolve();
await first;
expect(sender.queuedByteLength).toBe(0);
await sender.send("usable");
});
+21 -13
View File
@@ -23,6 +23,8 @@ export class ChunkedTransport {
private readonly io: {
frameBytes: number;
supportsDrain?: boolean;
/** Writes are admitted synchronously in order; promises track only their drain. */
supportsConcurrentWrites?: boolean;
send: (frame: Frame, waitForDrain: boolean) => void | Promise<void>;
deliver: (frame: Frame) => void;
onActivity?: () => void;
@@ -46,9 +48,11 @@ export class ChunkedTransport {
const chunk =
this.enabled && this.io.supportsDrain !== false && byteLength > this.io.frameBytes;
if (this.closed) throw new Error("Transport closed");
let started = false;
const write = () => {
if (this.closed) throw new Error("Transport closed");
this.activeBytes = byteLength;
started = true;
this.activeBytes += byteLength;
// A synchronous rejection did not admit a frame. Only accepted writes can
// fail the connection; queued admission failures still belong to their sender.
const result = chunk
@@ -76,27 +80,31 @@ export class ChunkedTransport {
result = this.pending ? this.pending.then(write) : write();
} catch (error) {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
if (started) this.activeBytes -= byteLength;
throw error;
}
if (!result) {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
if (started) this.activeBytes -= byteLength;
return Promise.resolve();
}
const completion = Promise.resolve(result).finally(() => {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
});
const tail = completion.then(
() => undefined,
() => undefined,
);
this.pending = tail;
void tail.then(() => {
if (this.pending === tail) this.pending = undefined;
return undefined;
if (started) this.activeBytes -= byteLength;
});
// Ordinary writes already entered an ordered writer and can use its bounded
// window. Chunk sequences remain a barrier so their frames never interleave.
if (chunk || !this.io.supportsConcurrentWrites) {
const tail = completion.then(
() => undefined,
() => undefined,
);
this.pending = tail;
void tail.then(() => {
if (this.pending === tail) this.pending = undefined;
return undefined;
});
}
return completion;
}
+12 -1
View File
@@ -116,8 +116,19 @@ export const HostsLinkOpenRequestSchema = z.object({
type: z.literal("hosts.link.open.request"),
requestId: z.string(),
channelId: z.string().min(1).max(256),
flowControl: z.boolean().optional(),
});
export const HostsLinkOpenResponseSchema = z.object({
type: z.literal("hosts.link.open.response"),
payload: z.object({ requestId: z.string(), routeId: z.string() }),
payload: z.object({
requestId: z.string(),
routeId: z.string(),
flowControl: z.boolean().optional(),
}),
});
export const HostsLinkAckSchema = z.object({
type: z.literal("hosts.link.ack.request"),
routeId: z.string(),
sequence: z.number().int().positive(),
});
+2
View File
@@ -5,6 +5,7 @@ import {
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsLinkAckSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
@@ -3397,6 +3398,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsLinkAckSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,