mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
fix(transport): preserve backpressure across framing and routes
Ordinary daemon writes must remain visible to terminal catch-up and socket admission. Pace chunked writes and explicit completion requests, include waiting logical messages in pressure readings, and propagate origin completion through routed client transports.
This commit is contained in:
@@ -8504,3 +8504,40 @@ test("uses the frame budget selected by an asynchronously opened transport", asy
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => {
|
||||
const mock = createMockTransport();
|
||||
let drain!: () => void;
|
||||
let blocked = false;
|
||||
const send = mock.transport.send;
|
||||
mock.transport.send = (frame) => {
|
||||
send(frame);
|
||||
if (blocked)
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "routed-drain",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen({ features: { binaryChunkingV1: true } });
|
||||
await connecting;
|
||||
blocked = true;
|
||||
let finished = false;
|
||||
const write = Promise.resolve(
|
||||
client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"),
|
||||
).then(() => {
|
||||
finished = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
drain();
|
||||
await write;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1884,9 +1884,9 @@ export class DaemonClient {
|
||||
})
|
||||
).routeId;
|
||||
}
|
||||
sendHostFrame(routeId: string, data: string | Uint8Array): void {
|
||||
sendHostFrame(routeId: string, data: string | Uint8Array): Promise<void> {
|
||||
if (typeof data === "string") {
|
||||
this.sendSessionMessageStrict({
|
||||
return this.sendSessionMessageStrict({
|
||||
type: "hosts.route.frame.request",
|
||||
routeId,
|
||||
data,
|
||||
@@ -1894,7 +1894,7 @@ export class DaemonClient {
|
||||
});
|
||||
} else {
|
||||
if (!this.isConnected || !this.transport) throw this.unavailableError();
|
||||
this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
|
||||
return this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
|
||||
}
|
||||
}
|
||||
private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>();
|
||||
@@ -2060,7 +2060,7 @@ export class DaemonClient {
|
||||
messageType: string,
|
||||
message: unknown,
|
||||
submitted?: () => void,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const isOpen =
|
||||
this.config.trace?.isEnabled() === true &&
|
||||
this.beginTraceSection("paseo.ws.message.outbound", {
|
||||
@@ -2069,25 +2069,30 @@ export class DaemonClient {
|
||||
...describeRequestIdentity(message),
|
||||
});
|
||||
try {
|
||||
this.sendTransportFrame(JSON.stringify(message), submitted);
|
||||
return this.sendTransportFrame(JSON.stringify(message), submitted);
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
}
|
||||
|
||||
private sendSessionPayload(payload: SessionInboundMessage): void {
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
});
|
||||
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
|
||||
return this.sendJsonMessage(
|
||||
"session",
|
||||
payload.type,
|
||||
{ type: "session", message: payload },
|
||||
() => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private sendTransportFrame(
|
||||
frame: string | Uint8Array | ArrayBuffer,
|
||||
submitted?: () => void,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (!this.transport) {
|
||||
throw this.unavailableError();
|
||||
}
|
||||
@@ -2102,9 +2107,9 @@ export class DaemonClient {
|
||||
submitted?.();
|
||||
const payload = typeof frame === "string" ? frame : asUint8Array(frame)!;
|
||||
const transport = this.transport;
|
||||
void this.chunkedTransport!.send(payload).catch(() =>
|
||||
transport.close(4003, "Transport send failed"),
|
||||
);
|
||||
const write = this.chunkedTransport!.send(payload);
|
||||
void write.catch(() => transport.close(4003, "Transport send failed"));
|
||||
return write;
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
@@ -2376,13 +2381,13 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): Promise<void> {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
throw this.unavailableError();
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.sendSessionPayload(payload);
|
||||
return this.sendSessionPayload(payload);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function createHostChannelTransport(
|
||||
code: "host_offline",
|
||||
serverId,
|
||||
});
|
||||
origin.sendHostFrame(
|
||||
return origin.sendHostFrame(
|
||||
routeId,
|
||||
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user