mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(hosts): share daemon links across isolated routed sessions
Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion.
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
HOST_ROUTE_OPCODE,
|
||||
encodeHostRouteFrame,
|
||||
decodeHostRouteFrame,
|
||||
type HostRouteFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
@@ -334,7 +340,7 @@ export type ConnectionState =
|
||||
| { status: "idle" }
|
||||
| { status: "connecting"; attempt: number }
|
||||
| { status: "connected" }
|
||||
| { status: "disconnected"; reason?: string }
|
||||
| { status: "disconnected"; reason?: string; nextRetryAt?: string; failureCode?: string }
|
||||
| { status: "disposed" };
|
||||
|
||||
export type DaemonEvent =
|
||||
@@ -1753,15 +1759,43 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
}
|
||||
sendHostFrame(routeId: string, data: string, binary: boolean): void {
|
||||
this.sendSessionMessageStrict({ type: "hosts.route.frame.request", routeId, data, binary });
|
||||
async openHostLink(channelId: string): Promise<string> {
|
||||
this.requireConnectedHosts();
|
||||
if (this.getLastServerInfoMessage()?.features?.hostMultiplexing !== true)
|
||||
throw Object.assign(new Error("Update the destination daemon"), {
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
return (
|
||||
await this.sendCorrelatedSessionRequest({
|
||||
message: { type: "hosts.link.open.request", channelId },
|
||||
responseType: "hosts.link.open.response",
|
||||
})
|
||||
).routeId;
|
||||
}
|
||||
sendHostFrame(routeId: string, data: string | Uint8Array): void {
|
||||
if (typeof data === "string") {
|
||||
this.sendSessionMessageStrict({
|
||||
type: "hosts.route.frame.request",
|
||||
routeId,
|
||||
data,
|
||||
binary: false,
|
||||
});
|
||||
} else {
|
||||
if (!this.isConnected || !this.transport) throw this.unavailableError();
|
||||
this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
|
||||
}
|
||||
}
|
||||
private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>();
|
||||
subscribeHostFrames(handler: (frame: HostRouteFrame) => void): () => void {
|
||||
this.hostFrameListeners.add(handler);
|
||||
return () => this.hostFrameListeners.delete(handler);
|
||||
}
|
||||
cancelHostRoute(scopeId: string): void {
|
||||
if (this.isConnected)
|
||||
if (this.isConnected && this.transport)
|
||||
this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId });
|
||||
}
|
||||
closeHostRoute(routeId: string): void {
|
||||
if (this.isConnected)
|
||||
if (this.isConnected && this.transport)
|
||||
this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId });
|
||||
}
|
||||
|
||||
@@ -6669,6 +6703,16 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
||||
const hostFrame = decodeHostRouteFrame(rawBytes);
|
||||
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
|
||||
this.disposeTransport(1008, "Invalid host routing frame");
|
||||
this.scheduleReconnect({ reason: "Invalid host routing frame", event: "INVALID_HOST_FRAME" });
|
||||
return true;
|
||||
}
|
||||
if (hostFrame) {
|
||||
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
||||
return true;
|
||||
}
|
||||
const fileFrame = decodeFileTransferFrame(rawBytes);
|
||||
if (fileFrame) {
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
@@ -6903,11 +6947,26 @@ export class DaemonClient {
|
||||
|
||||
private emitDisconnectedStateForReconnect(
|
||||
reason: string | undefined,
|
||||
input: { reason?: string; event?: string; reasonCode?: string } | undefined,
|
||||
input: { reason?: string; event?: string; reasonCode?: string; failure?: Error } | undefined,
|
||||
): void {
|
||||
this.updateConnectionState(
|
||||
{
|
||||
status: "disconnected",
|
||||
...(input?.failure && "code" in input.failure && typeof input.failure.code === "string"
|
||||
? { failureCode: input.failure.code }
|
||||
: {}),
|
||||
...(this.shouldReconnect && this.config.reconnect?.enabled !== false
|
||||
? {
|
||||
nextRetryAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(
|
||||
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
|
||||
2 ** this.reconnectAttempt,
|
||||
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
),
|
||||
).toISOString(),
|
||||
}
|
||||
: {}),
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,23 +14,13 @@ export interface PaseoHostActions {
|
||||
client(serverId: string): Promise<PaseoApi>;
|
||||
}
|
||||
|
||||
function encode(data: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
let result = "";
|
||||
for (let start = 0; start < bytes.length; start += 8192)
|
||||
result += String.fromCharCode(...bytes.subarray(start, start + 8192));
|
||||
return globalThis.btoa(result);
|
||||
}
|
||||
function decode(data: string): Uint8Array {
|
||||
return Uint8Array.from(globalThis.atob(data), (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function routeTransport(
|
||||
export function createHostChannelTransport(
|
||||
origin: DaemonClient,
|
||||
serverId: string,
|
||||
scopeId: string,
|
||||
initialRouteId: string,
|
||||
initialRouteId: string | null,
|
||||
state: { failure: Error | null },
|
||||
link = false,
|
||||
): DaemonTransportFactory {
|
||||
let first: string | null = initialRouteId;
|
||||
return () => {
|
||||
@@ -46,18 +36,19 @@ function routeTransport(
|
||||
closed = true;
|
||||
if (error instanceof Error) state.failure = Object.assign(error, { serverId });
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
if (routeId) origin.closeHostRoute(routeId);
|
||||
try {
|
||||
if (routeId) origin.closeHostRoute(routeId);
|
||||
else origin.cancelHostRoute(scopeId);
|
||||
} catch {
|
||||
/* Transport already closed. */
|
||||
}
|
||||
if (error) for (const handler of errors) handler(error);
|
||||
for (const handler of closes) handler(error);
|
||||
};
|
||||
cleanups.push(
|
||||
origin.subscribeRawMessages((message) => {
|
||||
if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) {
|
||||
for (const handler of messages)
|
||||
handler(
|
||||
message.payload.binary ? decode(message.payload.data) : message.payload.data,
|
||||
message.payload.binary,
|
||||
);
|
||||
for (const handler of messages) handler(message.payload.data, false);
|
||||
}
|
||||
if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId)
|
||||
close(
|
||||
@@ -68,12 +59,19 @@ function routeTransport(
|
||||
);
|
||||
}),
|
||||
);
|
||||
cleanups.push(
|
||||
origin.subscribeHostFrames((frame) => {
|
||||
if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true);
|
||||
}),
|
||||
);
|
||||
cleanups.push(
|
||||
origin.subscribeConnectionStatus((connectionState) => {
|
||||
if (connectionState.status !== "connected") close();
|
||||
}),
|
||||
);
|
||||
const opening = first ? Promise.resolve(first) : origin.openHostRoute(serverId, scopeId);
|
||||
let opening: Promise<string>;
|
||||
if (first) opening = Promise.resolve(first);
|
||||
else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId);
|
||||
first = null;
|
||||
void opening
|
||||
.then((id) => {
|
||||
@@ -96,8 +94,7 @@ function routeTransport(
|
||||
});
|
||||
origin.sendHostFrame(
|
||||
routeId,
|
||||
typeof data === "string" ? data : encode(data),
|
||||
typeof data !== "string",
|
||||
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
|
||||
);
|
||||
},
|
||||
close: () => close(),
|
||||
@@ -175,7 +172,7 @@ export function createHostActions(
|
||||
const client = new DaemonClient({
|
||||
url: "ws://routed-host/ws",
|
||||
clientId: scopeId,
|
||||
transportFactory: routeTransport(origin, serverId, scopeId, routeId, state),
|
||||
transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId, state),
|
||||
routedClient: true,
|
||||
routedServerId: serverId,
|
||||
routedFailure: () => state.failure,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Counts bytes until the socket's send callback, shared by all links owned by a daemon. */
|
||||
export class HostTransportBudget {
|
||||
private used = 0;
|
||||
constructor(readonly limit = 64 * 1024 * 1024) {}
|
||||
constructor(readonly limit = 256 * 1024 * 1024) {}
|
||||
get bufferedBytes(): number {
|
||||
return this.used;
|
||||
}
|
||||
|
||||
@@ -51,3 +51,24 @@ test("a slow consumer cannot exceed the shared byte budget and close releases re
|
||||
transport.close();
|
||||
await expect.poll(() => budget.bufferedBytes).toBe(0);
|
||||
});
|
||||
test("direct connector preserves 25 MiB binary frames in both directions", async () => {
|
||||
const server = await endpoint();
|
||||
const connected = once(server.wss, "connection");
|
||||
const transport = createHostTransportFactory(server.url)({ url: server.url });
|
||||
cleanup.push(async () => transport.close());
|
||||
const ready = new Promise<void>((resolve) => transport.onOpen(resolve));
|
||||
const received = new Promise<Uint8Array>((resolve, reject) => {
|
||||
transport.onMessage((bytes, binary) => binary && resolve(bytes as Uint8Array));
|
||||
transport.onError(reject);
|
||||
});
|
||||
const [socket] = (await connected) as [WebSocket];
|
||||
await ready;
|
||||
const bytes = new Uint8Array(25 * 1024 * 1024).fill(173);
|
||||
const echoed = once(socket, "message");
|
||||
transport.send(bytes);
|
||||
const [sent, binary] = await echoed;
|
||||
expect(binary).toBe(true);
|
||||
expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true);
|
||||
socket.send(bytes);
|
||||
expect(Buffer.from(await received).equals(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { createHostChannelTransport } from "../hosts/index.js";
|
||||
import { HostTransportBudget } from "./buffering.js";
|
||||
export { HostTransportBudget } from "./buffering.js";
|
||||
import { homedir } from "node:os";
|
||||
@@ -32,6 +33,7 @@ export function createHostTransportFactory(
|
||||
let closeEvent: unknown;
|
||||
let transport: DaemonTransport | undefined;
|
||||
let teardown = () => {};
|
||||
let disposeSocket = () => {};
|
||||
let sshTunnel: SshTunnel | null = null;
|
||||
const close = (code?: number, reason?: string) => {
|
||||
if (closed) return;
|
||||
@@ -40,6 +42,7 @@ export function createHostTransportFactory(
|
||||
transport?.close(code, reason);
|
||||
} finally {
|
||||
try {
|
||||
disposeSocket();
|
||||
teardown();
|
||||
} finally {
|
||||
for (const handler of closes) handler(closeEvent);
|
||||
@@ -66,9 +69,10 @@ export function createHostTransportFactory(
|
||||
{
|
||||
headers: options?.headers,
|
||||
...(socketPath ? { socketPath } : {}),
|
||||
maxPayload: 24 * 1024 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
disposeSocket = () => socket.terminate();
|
||||
const pending = new Set<() => void>();
|
||||
const send = socket.send.bind(socket);
|
||||
socket.send = (data, ...args: unknown[]) => {
|
||||
@@ -76,19 +80,12 @@ export function createHostTransportFactory(
|
||||
typeof data === "string"
|
||||
? Buffer.byteLength(data)
|
||||
: Buffer.byteLength(data as Uint8Array);
|
||||
if (socket.bufferedAmount + bytes > 24 * 1024 * 1024) {
|
||||
socket.terminate();
|
||||
if (socket.bufferedAmount + bytes > 128 * 1024 * 1024) {
|
||||
throw Object.assign(new Error("Host socket buffer limit reached"), {
|
||||
code: "resource_limit",
|
||||
});
|
||||
}
|
||||
let release: () => void;
|
||||
try {
|
||||
release = budget.reserve(bytes);
|
||||
} catch (error) {
|
||||
socket.terminate();
|
||||
throw error;
|
||||
}
|
||||
const release = budget.reserve(bytes);
|
||||
pending.add(release);
|
||||
const callback = args.find(
|
||||
(value): value is (error?: Error) => void => typeof value === "function",
|
||||
@@ -129,6 +126,7 @@ export function createHostTransportFactory(
|
||||
close();
|
||||
});
|
||||
transport.onError((error) => {
|
||||
if (closed) return;
|
||||
const detail = sshTunnel?.failureDetail() ?? "";
|
||||
const failure =
|
||||
/permission denied|authentication failed|host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(
|
||||
@@ -179,12 +177,13 @@ export function createHostTransportFactory(
|
||||
export function createHostClient(
|
||||
connection: string,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
|
||||
budget?: HostTransportBudget,
|
||||
): DaemonClient {
|
||||
const target = parseHostTarget(connection);
|
||||
return new DaemonClient({
|
||||
...config,
|
||||
url: "ws://host-connector/ws",
|
||||
...(target.password ? { password: target.password } : {}),
|
||||
transportFactory: createHostTransportFactory(connection),
|
||||
transportFactory: createHostTransportFactory(connection, budget),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user