mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user