Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/relay/src/host-tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/// <reference types="bun" />
import { expect, mock, test } from "bun:test";
import type { Connection } from "partyserver";

mock.module("partyserver", () => ({ Server: class {} }));

const { HostTunnel } = await import("./host-tunnel");

function socket(id: string, kind: "host" | "client" | "dial") {
return {
id,
tags: kind === "host" ? ["host"] : [],
state: { kind, hostId: "org:host", ticket: "ticket" },
readyState: WebSocket.OPEN,
close: mock(() => {}),
} as unknown as Connection;
}

function tunnelWith(connections: Connection[]) {
const tunnel = new HostTunnel({} as never, {} as never);
tunnel.getConnections = ((tag?: string) =>
connections
.filter((conn) => !tag || conn.tags.includes(tag))
.values()) as typeof tunnel.getConnections;
return tunnel;
}

test("a delayed old control close preserves streams on the replacement tunnel", async () => {
const oldHost = socket("old", "host");
const replacement = socket("replacement", "host");
const client = socket("terminal", "client");
const dial = socket("terminal-dial", "dial");
const tunnel = tunnelWith([replacement, client, dial]);

await tunnel.onClose(oldHost);
await tunnel.onError(oldHost);

expect(client.close).not.toHaveBeenCalled();
expect(dial.close).not.toHaveBeenCalled();
});

test("losing the current control connection still closes its streams", async () => {
const host = socket("current", "host");
const client = socket("terminal", "client");
const dial = socket("terminal-dial", "dial");
const tunnel = tunnelWith([client, dial]);

await tunnel.onClose(host);

expect(client.close).toHaveBeenCalledTimes(1);
expect(dial.close).toHaveBeenCalledTimes(1);
});
3 changes: 3 additions & 0 deletions apps/relay/src/host-tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ export class HostTunnel extends Server<RelayEnv> {
if (!state) return;

if (state.kind === "host") {
const currentHost = this.hostConn();
if (currentHost && currentHost.id !== conn.id) return;

// Tags, not state: tags are assigned synchronously at accept, so a
// replacement host socket still mid-onConnect is never torn down.
for (const other of this.getConnections()) {
Expand Down
21 changes: 15 additions & 6 deletions packages/host-service/src/tunnel/tunnel-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class TunnelClient {
private pingTimer: ReturnType<typeof setInterval> | null = null;
private watchdogTimer: ReturnType<typeof setInterval> | null = null;
private lastInboundAt = 0;
private connectedAt = 0;
private notOpenSince: number | null = null;
private relayUrl: string;
private lastAttemptOpened = true;
Expand Down Expand Up @@ -129,6 +130,7 @@ export class TunnelClient {

control.addEventListener("open", () => {
this.lastInboundAt = Date.now();
this.connectedAt = this.lastInboundAt;
this.lastAttemptOpened = true;
console.log(
`[host-service:tunnel] control connected for ${this.options.hostId}`,
Expand All @@ -149,12 +151,19 @@ export class TunnelClient {
});

control.addEventListener("close", (event) => {
const described = describeRelayClose(event.code) ?? "";
if (event.code === 1008 || described) {
console.warn(
`[host-service:tunnel] relay closed control (${event.code} ${described}): ${event.reason ?? ""}; partysocket will retry`,
);
}
const now = Date.now();
console.warn("[host-service:tunnel] control closed", {
timestamp: new Date(now).toISOString(),
hostId: this.options.hostId,
code: event.code,
description: describeRelayClose(event.code),
reason: event.reason,
wasClean: event.wasClean,
connectedForMs: this.connectedAt ? now - this.connectedAt : null,
inboundSilenceMs: this.lastInboundAt ? now - this.lastInboundAt : null,
retrying: !this.closed,
});
this.connectedAt = 0;
});

this.pingTimer = setInterval(() => {
Expand Down
Loading