From 7f6af6ee8185d7141e5f1f564b8ae4e617f7550d Mon Sep 17 00:00:00 2001 From: Zach Rutman Date: Mon, 10 Aug 2026 20:57:03 -0700 Subject: [PATCH 1/6] feat: add WebTransport option for multiplayer client --- .../systems/multiplayer/MultiplayerSystem.ts | 3 +- .../multiplayer/MultiplayerTransport.ts | 46 ++++++++ .../multiplayer/MultiplayerWebsocket.ts | 45 +------- .../multiplayer/MultiplayerWebtransport.ts | 108 ++++++++++++++++++ 4 files changed, 162 insertions(+), 40 deletions(-) create mode 100644 fission/src/systems/multiplayer/MultiplayerTransport.ts create mode 100644 fission/src/systems/multiplayer/MultiplayerWebtransport.ts diff --git a/fission/src/systems/multiplayer/MultiplayerSystem.ts b/fission/src/systems/multiplayer/MultiplayerSystem.ts index f094338de8..2772420b10 100644 --- a/fission/src/systems/multiplayer/MultiplayerSystem.ts +++ b/fission/src/systems/multiplayer/MultiplayerSystem.ts @@ -12,6 +12,7 @@ import { hashBuffer } from "@/util/Utility.ts" import { mirabuf } from "@/proto/mirabuf" import type { SceneObjectId } from "@/systems/scene/SceneRenderer.ts" import MatchMode from "../match_mode/MatchMode.ts" +import type { MultiplayerTransport } from "@/systems/multiplayer/MultiplayerTransport.ts" export const COLLISION_TIMEOUT = 500 @@ -24,7 +25,7 @@ export const multiplayerLogger = consolePrefixer({ const console = multiplayerLogger class MultiplayerSystem { - public readonly client: MultiplayerWebsocket + public readonly client: MultiplayerTransport public roomId: string = "" public clientId: string = "" private _initializationPromise: Promise diff --git a/fission/src/systems/multiplayer/MultiplayerTransport.ts b/fission/src/systems/multiplayer/MultiplayerTransport.ts new file mode 100644 index 0000000000..1518e959ea --- /dev/null +++ b/fission/src/systems/multiplayer/MultiplayerTransport.ts @@ -0,0 +1,46 @@ +import type { MessageWithTimestamp } from "@/systems/multiplayer/MultiplayerTypes.ts" +import type { ClientToServerMessage } from "@/systems/multiplayer/bindings/ClientToServerMessage.ts" +import type { ServerToClientMessage } from "@/systems/multiplayer/bindings/ServerToClientMessage.ts" + +export const CLIENT_PREFIX = 0b00000001 +export const SERVER_PREFIX = 0b00000011 + +export abstract class MultiplayerTransport { + public onServerMessage?: (msg: ServerToClientMessage) => void + public onPeerMessage?: (msg: MessageWithTimestamp) => void + public onOpen?: ((this: MultiplayerTransport, ev: Event | null) => unknown) | null + public onClose?: ((this: MultiplayerTransport, ev: CloseEvent | null) => unknown) | null + public onError?: ((this: MultiplayerTransport, ev: Event) => unknown) | null + + public abstract get ready(): boolean + + public static init(roomId: string | null, displayName: string, ws: MultiplayerTransport): MultiplayerTransport { + const initialMessage: ClientToServerMessage = { + type: "initializeconnection", + room_id: roomId, + name: displayName, + } + + if (ws.ready) { + ws.sendServer(initialMessage) + } else { + ws.onOpen = () => { + ws.sendServer(initialMessage) + } + } + + return ws + } + + protected abstract send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage, datagram: boolean): void + + public sendPeer(msg: MessageWithTimestamp, datagram: boolean = false): void { + return this.send(CLIENT_PREFIX, msg, datagram) + } + + public sendServer(msg: ClientToServerMessage, datagram: boolean = false): void { + return this.send(SERVER_PREFIX, msg, datagram) + } + + public abstract close(code?: number, reason?: string): void +} diff --git a/fission/src/systems/multiplayer/MultiplayerWebsocket.ts b/fission/src/systems/multiplayer/MultiplayerWebsocket.ts index 41799b70e4..2521b65266 100644 --- a/fission/src/systems/multiplayer/MultiplayerWebsocket.ts +++ b/fission/src/systems/multiplayer/MultiplayerWebsocket.ts @@ -3,9 +3,7 @@ import type { MessageWithTimestamp } from "@/systems/multiplayer/MultiplayerType import { Encoder, Decoder } from "@msgpack/msgpack" import type { ClientToServerMessage } from "@/systems/multiplayer/bindings/ClientToServerMessage.ts" import type { ServerToClientMessage } from "@/systems/multiplayer/bindings/ServerToClientMessage.ts" - -const CLIENT_PREFIX = 0b00000001 -const SERVER_PREFIX = 0b00000011 +import { MultiplayerTransport, SERVER_PREFIX } from "@/systems/multiplayer/MultiplayerTransport.ts" const console = consolePrefixer({ defaultPrefix: { @@ -14,24 +12,19 @@ const console = consolePrefixer({ }, }) -class MultiplayerWebsocket { +class MultiplayerWebsocket extends MultiplayerTransport { private readonly _ws: WebSocket private readonly _encoder: Encoder = new Encoder() private readonly _decoder: Decoder = new Decoder() private _prefixBuf = new Uint8Array(1) - public onServerMessage?: (msg: ServerToClientMessage) => void - public onPeerMessage?: (msg: MessageWithTimestamp) => void - public onOpen?: ((this: MultiplayerWebsocket, ev: Event) => unknown) | null - public onClose?: ((this: MultiplayerWebsocket, ev: CloseEvent) => unknown) | null - public onError?: ((this: MultiplayerWebsocket, ev: Event) => unknown) | null - - public get ready() { + public override get ready() { return this._ws.readyState === WebSocket.OPEN } constructor(url: string) { + super() this._ws = new WebSocket(url) console.log("Connecting to", url) this._ws.onopen = e => { @@ -68,40 +61,14 @@ class MultiplayerWebsocket { } } - public static init(roomId: string | null, displayName: string, ws: MultiplayerWebsocket): MultiplayerWebsocket { - const initialMessage: ClientToServerMessage = { - type: "initializeconnection", - room_id: roomId, - name: displayName, - } - - if (ws._ws.readyState == WebSocket.OPEN) { - ws.sendServer(initialMessage) - } else { - ws.onOpen = () => { - ws.sendServer(initialMessage) - } - } - - return ws - } - - private send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage): void { + override send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage): void { if (msg.type != "update") console.debug("Sending", msg) const encoded = this._encoder.encodeSharedRef(msg) this._prefixBuf[0] = prefix return this._ws.send(new Blob([this._prefixBuf, encoded])) } - public sendPeer(msg: MessageWithTimestamp): void { - return this.send(CLIENT_PREFIX, msg) - } - - public sendServer(msg: ClientToServerMessage): void { - return this.send(SERVER_PREFIX, msg) - } - - public close(code?: number, reason?: string) { + override close(code?: number, reason?: string) { return this._ws.close(code, reason) } } diff --git a/fission/src/systems/multiplayer/MultiplayerWebtransport.ts b/fission/src/systems/multiplayer/MultiplayerWebtransport.ts new file mode 100644 index 0000000000..5ad229032c --- /dev/null +++ b/fission/src/systems/multiplayer/MultiplayerWebtransport.ts @@ -0,0 +1,108 @@ +import { consolePrefixer } from "console-prefixer" +import type { MessageWithTimestamp } from "@/systems/multiplayer/MultiplayerTypes.ts" +import { Encoder, Decoder } from "@msgpack/msgpack" +import type { ClientToServerMessage } from "@/systems/multiplayer/bindings/ClientToServerMessage.ts" +import type { ServerToClientMessage } from "@/systems/multiplayer/bindings/ServerToClientMessage.ts" +import { MultiplayerTransport, SERVER_PREFIX } from "@/systems/multiplayer/MultiplayerTransport.ts" + +const console = consolePrefixer({ + defaultPrefix: { + text: "[Multiplayer WT]", + style: "background: linear-gradient(90deg,rgba(255, 165, 0, 1) 0%, rgba(199, 87, 87, 1) 100%); color: white;font-weight:bold; padding:2px; border-radius:2px;", + }, +}) + +type StreamReadWrite = { + writer: WritableStreamDefaultWriter + reader: ReadableStream +} +class MultiplayerWebtransport extends MultiplayerTransport { + private readonly _wt: WebTransport + + private readonly _encoder: Encoder = new Encoder() + private readonly _decoder: Decoder = new Decoder() + private _stream!: StreamReadWrite + private _datagrams!: StreamReadWrite + private _prefixBuf = new Uint8Array(1) + private _ready: boolean = false + + override get ready() { + return this._ready + } + + constructor(url: string) { + super() + this._wt = new WebTransport(url) + + this._wt.closed.then(() => { + console.info("Closed") + if (this.onClose) { + this.onClose.bind(this)(null) + } + }) + console.log("Connecting to", url) + this._wt.ready.then(async () => { + const stream = await this._wt.createBidirectionalStream() + this._stream = { + writer: stream.writable.getWriter(), + reader: stream.readable, + } + this._datagrams = { + writer: this._wt.datagrams.writable.getWriter(), + reader: this._wt.datagrams.readable, + } + this._ready = true + this.onOpen?.bind(this)?.(null) + setTimeout(() => this.listenDatagrams()) + setTimeout(() => this.listenStreams()) + }) + } + + private async decodeMessage(body: Uint8Array) { + const headerByte = body[0] + const data = body.slice(1) + const decoded = this._decoder.decode(data) as ServerToClientMessage | MessageWithTimestamp + const isServer = headerByte == SERVER_PREFIX + if (decoded.type != "update") console.debug("Recieving", isServer ? "server" : "client", decoded) + if (isServer) { + this.onServerMessage?.(decoded as ServerToClientMessage) + } else { + this.onPeerMessage?.(decoded as MessageWithTimestamp) + } + } + + private async listenStreams() { + for await (const message of this._stream.reader) { + console.log("[TCP]", message) + await this.decodeMessage(message) + } + } + + private async listenDatagrams() { + for await (const datagram of this._datagrams.reader) { + console.log("[UDP]", datagram) + await this.decodeMessage(datagram) + } + } + + override async send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage, datagram: boolean) { + if (msg.type != "update") console.debug("Sending", msg) + const encoded = this._encoder.encodeSharedRef(msg) + this._prefixBuf[0] = prefix + const data = new Blob([this._prefixBuf, encoded]) + if (datagram) { + await this._datagrams.writer.write(await data.bytes()) + } else { + await this._stream.writer.write(await data.bytes()) + } + } + + override async close(code?: number, reason?: string) { + this._wt.close?.({ closeCode: code, reason }) + await this._stream.reader.cancel("Transport closed by client") + await this._datagrams.reader.cancel("Transport closed by client") + await this._stream.writer.close() + await this._datagrams.writer.close() + } +} +export default MultiplayerWebtransport From 6d09ff11894cbd7e95ae395c75312819949e28ba Mon Sep 17 00:00:00 2001 From: Zach Rutman Date: Tue, 11 Aug 2026 00:28:41 -0700 Subject: [PATCH 2/6] feat: working web transports --- fission/package.json | 2 +- fission/src/Synthesis.tsx | 14 +- fission/src/mirabuf/MirabufSceneObject.ts | 8 +- .../systems/multiplayer/MessageHandlers.ts | 30 +- .../multiplayer/MultiplayerMessageTypes.ts | 43 +- .../systems/multiplayer/MultiplayerSystem.ts | 21 +- .../multiplayer/MultiplayerWebsocket.ts | 7 +- .../multiplayer/MultiplayerWebtransport.ts | 189 ++++-- .../systems/multiplayer/UpdatePhysicsData.ts | 22 +- fission/src/systems/physics/Mechanism.ts | 2 +- fission/src/systems/physics/PhysicsSystem.ts | 66 +- fission/src/systems/scene/DragModeSystem.ts | 6 +- .../ui/modals/multiplayer/ConnectionModal.tsx | 139 +---- .../multiplayer/MultiplayerStartModal.tsx | 4 +- .../src/ui/modals/multiplayer/RoomModal.tsx | 44 +- glueball/Cargo.lock | 565 +++++++++++++++--- glueball/Cargo.toml | 7 +- glueball/bindings/CertificateHash.ts | 12 + glueball/bindings/CertificateHashes.ts | 12 + glueball/bindings/ClientToServerMessage.ts | 5 +- glueball/bindings/RoomInfo.ts | 2 +- glueball/bindings/ServerToClientMessage.ts | 8 +- glueball/src/cert.rs | 81 ++- glueball/src/http.rs | 125 ++++ glueball/src/kick.rs | 10 +- glueball/src/main.rs | 96 +-- glueball/src/messaging.rs | 367 ++++++++---- glueball/src/model.rs | 30 + glueball/src/prefixed.rs | 124 ---- glueball/src/state.rs | 6 +- glueball/src/tests.rs | 276 ++++++--- glueball/src/util.rs | 12 +- glueball/src/wire.rs | 89 +++ 33 files changed, 1631 insertions(+), 793 deletions(-) create mode 100644 glueball/bindings/CertificateHash.ts create mode 100644 glueball/bindings/CertificateHashes.ts create mode 100644 glueball/src/http.rs delete mode 100644 glueball/src/prefixed.rs create mode 100644 glueball/src/wire.rs diff --git a/fission/package.json b/fission/package.json index a5d9651b46..5eaea63c88 100644 --- a/fission/package.json +++ b/fission/package.json @@ -24,7 +24,7 @@ "fmt:fix": "bunx biome format --write", "style": "bunx biome check", "style:fix": "bunx biome check --write", - "assetpack": "git lfs pull && (rm -rf public/Downloadables;tar -xf public/assetpack.zip -C public/)", + "assetpack": "git lfs pull && (rm -rf public/Downloadables;(unzip public/assetpack.zip -d public/ || tar -xf public/assetpack.zip -C public/))", "assetpack:update": "bun update_manifest.ts && cd public && zip -FS -r assetpack.zip Downloadables -x '**/.*' -x '**/__MACOSX'", "assetpack:merge": "git checkout --theirs public/assetpack.zip && rm -rf public/Downloadables && tar -xf public/assetpack.zip -C public/ && git checkout --ours public/assetpack.zip && tar -xf public/assetpack.zip -C public/ && bun run assetpack:update", "playwright:install": "bun x playwright install", diff --git a/fission/src/Synthesis.tsx b/fission/src/Synthesis.tsx index 799b028a8d..ee8bcb75bc 100644 --- a/fission/src/Synthesis.tsx +++ b/fission/src/Synthesis.tsx @@ -21,10 +21,10 @@ import { ThemeProvider } from "./ui/ThemeProvider.tsx" import { UIProvider } from "./ui/UIProvider.tsx" import CommandPalette from "@/ui/components/CommandPalette.tsx" import SessionStorage, { applyAutoToast } from "@/util/SessionStorage.ts" -import MultiplayerWebsocket from "@/systems/multiplayer/MultiplayerWebsocket.ts" import { globalOpenModal } from "@/components/GlobalUIControls.ts" import { startMultiplayerWorld } from "@/ui/helpers/StartMultiplayerWorld.ts" import { Stack } from "@mui/material" +import MultiplayerWebtransport from "@/systems/multiplayer/MultiplayerWebtransport.ts" const Synthesis = () => { const [consentPopupDisable, setConsentPopupDisable] = useState(true) @@ -54,11 +54,13 @@ const Synthesis = () => { if (urlParams.has("autojoin")) { const room = urlParams.get("autojoin")! const name = PreferencesSystem.getUserPreference("MultiplayerUsername") ?? "TestUser" - const ws = new MultiplayerWebsocket( - `ws${PreferencesSystem.getUserPreference("MultiplayerSecure") ? "s" : ""}://${PreferencesSystem.getUserPreference("MultiplayerHost") || "127.0.0.1"}:${PreferencesSystem.getUserPreference("MultiplayerPort")}` - ) - MultiplayerWebsocket.init(room || null, name, ws) - setTimeout(() => startMultiplayerWorld({ displayName: name, ws, keepAssets: false, isHost: false })) + MultiplayerWebtransport.create( + `https://${PreferencesSystem.getUserPreference("MultiplayerHost") || "127.0.0.1"}:${PreferencesSystem.getUserPreference("MultiplayerPort")}` + ).then(ws => { + if (ws === null) return + MultiplayerWebtransport.init(room || null, name, ws) + setTimeout(() => startMultiplayerWorld({ displayName: name, ws, keepAssets: false, isHost: false })) + }) } applyAutoToast() diff --git a/fission/src/mirabuf/MirabufSceneObject.ts b/fission/src/mirabuf/MirabufSceneObject.ts index 92d8dcd497..b3e8e31d3d 100644 --- a/fission/src/mirabuf/MirabufSceneObject.ts +++ b/fission/src/mirabuf/MirabufSceneObject.ts @@ -1374,10 +1374,14 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier { return data } - public getUpdateData(): UpdateObjectData { - const gamePiecesControlled: RigidNodeId[] = this.activeEjectables.map( + public getGamePiecesControlled(): RigidNodeId[] { + return this.activeEjectables.map( bodyId => (World.physicsSystem.getBodyAssociation(bodyId)).rigidNodeId ) + } + + public getUpdateData(): UpdateObjectData { + const gamePiecesControlled = this.getGamePiecesControlled() const bodies = this.getAllBodies() .map(body => World.physicsSystem.getBodyUpdateData(body)) diff --git a/fission/src/systems/multiplayer/MessageHandlers.ts b/fission/src/systems/multiplayer/MessageHandlers.ts index d66462116c..fe5a7a3d5d 100644 --- a/fission/src/systems/multiplayer/MessageHandlers.ts +++ b/fission/src/systems/multiplayer/MessageHandlers.ts @@ -96,7 +96,7 @@ function handleUpdateMessage(data: UpdateBody, peerId: string, timestamp: number // Handle Scene object update - const { sceneObjectKey, gamePiecesControlled, bodies } = data.sceneObject + const { objId: sceneObjectKey, gamePiecesControlled } = data const sceneObject = World.sceneRenderer.sceneObjects.get(sceneObjectKey) if (sceneObject == null) { @@ -131,22 +131,36 @@ function handleUpdateMessage(data: UpdateBody, peerId: string, timestamp: number return sceneObject.setEjectable(bodyId) }) } +} - handleUpdateObjectPhysics(sceneObject, bodies, peerId) +/// Last applied timestamp per body, keyed by scene object then rigid node. Bodies +/// now arrive as independent messages, so two updates for the same body can turn up +/// out of order and a stale one must not overwrite a fresher one. +const bodyToUpdateMap = new Map>() - data.touchedBodies.forEach(data => handleUpdatePhysicsBody(data, peerId, timestamp)) -} +function handleUpdatePhysicsBody(data: UpdatePhysicsBodyData, peerId: string, timestamp: number) { + const [objId, body] = data + const rnId = body[0] + + let bodyTimestamps = bodyToUpdateMap.get(objId) + if (bodyTimestamps == null) { + bodyTimestamps = new Map() + bodyToUpdateMap.set(objId, bodyTimestamps) + } + + const lastTimestamp = bodyTimestamps.get(rnId) + if (lastTimestamp != null && lastTimestamp > timestamp) return + bodyTimestamps.set(rnId, timestamp) -function handleUpdatePhysicsBody(data: UpdatePhysicsBodyData, peerId: string, _timestamp: number) { // We only want to send it through the mapping if it's not a game piece we own - const sceneObject = World.sceneRenderer.sceneObjects.get(data.sceneObjectId) as MirabufSceneObject - const bodyId = sceneObject.mechanism.getBodyByNodeId(data.rigidNodeId) + const sceneObject = World.sceneRenderer.sceneObjects.get(objId) as MirabufSceneObject | undefined + const bodyId = sceneObject?.mechanism.getBodyByNodeId(rnId) if (bodyId == null) { console.error(`BodyId: ${bodyId} sent by ${peerId} does not exist in bodyMap`) return } - applyPhysicsBodyData(bodyId, data) + applyPhysicsBodyData(bodyId, body) } function handleCollisionMessage() { diff --git a/fission/src/systems/multiplayer/MultiplayerMessageTypes.ts b/fission/src/systems/multiplayer/MultiplayerMessageTypes.ts index 9e429222c2..d4ce566d22 100644 --- a/fission/src/systems/multiplayer/MultiplayerMessageTypes.ts +++ b/fission/src/systems/multiplayer/MultiplayerMessageTypes.ts @@ -80,10 +80,15 @@ export type NeedAssemblyBody = { assemblyHash: string } +/** + * The part of a robot's state that isn't attached to any one body. Its bodies + * travel separately, as one `updatePhysicsBody` per body. + */ export type UpdateBody = { - sceneObject: UpdateObjectData - touchedBodies: UpdatePhysicsBodyData[] + objId: SceneObjectId + gamePiecesControlled: RigidNodeId[] // rnIds within the field, since there's only one } + export type CollisionBody = UpdateObjectData[] export type UpdateObjectData = { sceneObjectKey: SceneObjectId @@ -91,17 +96,29 @@ export type UpdateObjectData = { bodies: PhysicsBodyData[] } -export type UpdatePhysicsBodyData = { - sceneObjectId: SceneObjectId -} & PhysicsBodyData +/** + * One body's state, addressed by the scene object owning it. + * + * Each of these is sent as its own message so it fits in a datagram, and so that + * losing one costs a single body for a single tick. + */ +export type UpdatePhysicsBodyData = [objId: SceneObjectId, body: PhysicsBodyData] -export type PhysicsBodyData = { - rigidNodeId: RigidNodeId // rnIds are relative to their scene object, so be sure to send the id for that too - // {x, y, z, w?} - linearVelocityStr: string - angularVelocityStr: string - positionStr: string - rotationStr: string -} +/** + * One body's physics state. + * + * Packed as a tuple rather than an object: these go out per physics tick, and the + * field names cost more on the wire than the numbers they label. The order below + * *is* the wire schema, so a change here has to land on both the producer + * (`PhysicsSystem`) and the consumer (`UpdatePhysicsData`) at once. + */ +export type PhysicsBodyData = [ + // rnIds are relative to their scene object, so be sure to send the id for that too + rnId: RigidNodeId, + linVel: [x: number, y: number, z: number], + rotVel: [x: number, y: number, z: number], + pos: [x: number, y: number, z: number], + rot: [x: number, y: number, z: number, w: number], +] export type LatencyInfoBody = { latencyMS: number } diff --git a/fission/src/systems/multiplayer/MultiplayerSystem.ts b/fission/src/systems/multiplayer/MultiplayerSystem.ts index 2772420b10..1468c6a326 100644 --- a/fission/src/systems/multiplayer/MultiplayerSystem.ts +++ b/fission/src/systems/multiplayer/MultiplayerSystem.ts @@ -7,7 +7,6 @@ import type { ClientAndLatencyInfo, ClientInfo, Message, MessageWithTimestamp } import EventSystem from "@/systems/EventSystem.ts" import type { ServerToClientMessage } from "@/systems/multiplayer/bindings/ServerToClientMessage.ts" import { consolePrefixer } from "console-prefixer" -import type MultiplayerWebsocket from "@/systems/multiplayer/MultiplayerWebsocket.ts" import { hashBuffer } from "@/util/Utility.ts" import { mirabuf } from "@/proto/mirabuf" import type { SceneObjectId } from "@/systems/scene/SceneRenderer.ts" @@ -48,7 +47,7 @@ class MultiplayerSystem { public sinceLastUpdate = 0 - public static async setup(ws: MultiplayerWebsocket, displayName: string, isHost: boolean): Promise { + public static async setup(ws: MultiplayerTransport, displayName: string, isHost: boolean): Promise { MatchMode.getInstance().sandboxModeStart() console.group("Multiplayer initialization") @@ -62,7 +61,7 @@ class MultiplayerSystem { return initResult } - private constructor(ws: MultiplayerWebsocket, displayName: string, isHost: boolean) { + private constructor(ws: MultiplayerTransport, displayName: string, isHost: boolean) { this.isHost = isHost this.client = ws @@ -137,7 +136,6 @@ class MultiplayerSystem { } async handleServerMessage(message: ServerToClientMessage) { - console.debug(`Incoming server message ${message.type}`) switch (message.type) { case "sendinfo": this.roomId = message.room_id @@ -173,15 +171,12 @@ class MultiplayerSystem { console.info("Ignoring message for", message.recipientId) return } - if (message.type != "update") { - console.info(`Receiving Message ${message.type}`, message) + const baseHandler = peerMessageHandlers[message.type] + if (baseHandler == null) { + console.error("Unknown message recieved:", message.type, message.data) + return message.type } - - const handler = peerMessageHandlers[message.type].bind(this) as ( - data: unknown, - peerid: string, - time: number - ) => Promise | void + const handler = baseHandler.bind(this) as (data: unknown, peerid: string, time: number) => Promise | void await handler(message.data, message.clientId, message.timestamp) return message.type } @@ -197,7 +192,7 @@ class MultiplayerSystem { if (message.type == "newObject" && message.data.miraType == MiraType.FIELD) { this.fieldTransferLock = { ts: message.timestamp, id: message.data.sceneObjectId } } - this.client.sendPeer(message as MessageWithTimestamp) + this.client.sendPeer(message as MessageWithTimestamp, message.type == "update") } async introduceSelf(requestIntroductions: boolean, peerID?: string) { diff --git a/fission/src/systems/multiplayer/MultiplayerWebsocket.ts b/fission/src/systems/multiplayer/MultiplayerWebsocket.ts index 2521b65266..c63045d4b3 100644 --- a/fission/src/systems/multiplayer/MultiplayerWebsocket.ts +++ b/fission/src/systems/multiplayer/MultiplayerWebsocket.ts @@ -15,8 +15,8 @@ const console = consolePrefixer({ class MultiplayerWebsocket extends MultiplayerTransport { private readonly _ws: WebSocket - private readonly _encoder: Encoder = new Encoder() - private readonly _decoder: Decoder = new Decoder() + private readonly _encoder: Encoder = new Encoder({ forceFloat32: true }) + private readonly _decoder: Decoder = new Decoder() private _prefixBuf = new Uint8Array(1) public override get ready() { @@ -52,7 +52,7 @@ class MultiplayerWebsocket extends MultiplayerTransport { const data = msg.slice(1).stream() const decoded = (await this._decoder.decodeAsync(data)) as ServerToClientMessage | MessageWithTimestamp const isServer = headerByte == SERVER_PREFIX - if (decoded.type != "update") console.debug("Recieving", isServer ? "server" : "client", decoded) + // if (decoded.type != "update") console.debug("Recieving", isServer ? "server" : "client", decoded) if (isServer) { this.onServerMessage?.(decoded as ServerToClientMessage) } else { @@ -62,7 +62,6 @@ class MultiplayerWebsocket extends MultiplayerTransport { } override send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage): void { - if (msg.type != "update") console.debug("Sending", msg) const encoded = this._encoder.encodeSharedRef(msg) this._prefixBuf[0] = prefix return this._ws.send(new Blob([this._prefixBuf, encoded])) diff --git a/fission/src/systems/multiplayer/MultiplayerWebtransport.ts b/fission/src/systems/multiplayer/MultiplayerWebtransport.ts index 5ad229032c..27c371b2ae 100644 --- a/fission/src/systems/multiplayer/MultiplayerWebtransport.ts +++ b/fission/src/systems/multiplayer/MultiplayerWebtransport.ts @@ -4,6 +4,7 @@ import { Encoder, Decoder } from "@msgpack/msgpack" import type { ClientToServerMessage } from "@/systems/multiplayer/bindings/ClientToServerMessage.ts" import type { ServerToClientMessage } from "@/systems/multiplayer/bindings/ServerToClientMessage.ts" import { MultiplayerTransport, SERVER_PREFIX } from "@/systems/multiplayer/MultiplayerTransport.ts" +import type { CertificateHashes } from "@/systems/multiplayer/bindings/CertificateHashes.ts" const console = consolePrefixer({ defaultPrefix: { @@ -12,45 +13,90 @@ const console = consolePrefixer({ }, }) -type StreamReadWrite = { - writer: WritableStreamDefaultWriter - reader: ReadableStream -} +const OUTGOING_DATAGRAM_MAX_AGE_MS = 100 + +/// A deliberately shallow send queue, so backpressure shows up as a dropped +/// update instead of a growing backlog +const OUTGOING_DATAGRAM_HIGH_WATER_MARK = 8 + +const DEFAULT_SEND_ORDER = 0 +const UPDATE_SEND_ORDER_FLOOR = -Number.MAX_SAFE_INTEGER + class MultiplayerWebtransport extends MultiplayerTransport { private readonly _wt: WebTransport - private readonly _encoder: Encoder = new Encoder() - private readonly _decoder: Decoder = new Decoder() - private _stream!: StreamReadWrite - private _datagrams!: StreamReadWrite - private _prefixBuf = new Uint8Array(1) + private readonly _encoder: Encoder = new Encoder({ forceFloat32: true }) + private readonly _decoder: Decoder = new Decoder() + + private _datagramWriter!: WritableStreamDefaultWriter private _ready: boolean = false + private _closeNotified: boolean = false + private _warnedOversizedDatagram: boolean = false + + private _updateSendOrder: number = UPDATE_SEND_ORDER_FLOOR override get ready() { return this._ready } - constructor(url: string) { - super() - this._wt = new WebTransport(url) + private notifyClosed() { + if (this._closeNotified) return + this._closeNotified = true + this._ready = false + this.onClose?.bind(this)?.(null) + } - this._wt.closed.then(() => { - console.info("Closed") - if (this.onClose) { - this.onClose.bind(this)(null) - } + public static async create(url: string): Promise { + const certs = await this.getCerts(url) + if (certs == null) return null + return new MultiplayerWebtransport(url, certs) + } + + public static async getCerts(url: string): Promise { + const certURL = new URL(url) + certURL.protocol = "http:" + certURL.pathname = "/cert" + const certs = await fetch(certURL.href) + if (!certs.ok) { + return null + } + const data = (await certs.json().catch(() => null)) as CertificateHashes | null + if (data == null) { + return null + } + + return data.hashes.map(v => ({ + algorithm: v.algorithm, + value: Uint8Array.from(v.value), + })) + } + constructor(url: string, certs: WebTransportHash[]) { + super() + this._wt = new WebTransport(url, { + serverCertificateHashes: certs, }) + + this._wt.closed + .then(() => { + console.info("Closed") + }) + .catch((e: unknown) => { + console.info("Closed by server", e) + }) + .finally(() => { + this.notifyClosed() + }) console.log("Connecting to", url) - this._wt.ready.then(async () => { - const stream = await this._wt.createBidirectionalStream() - this._stream = { - writer: stream.writable.getWriter(), - reader: stream.readable, - } - this._datagrams = { - writer: this._wt.datagrams.writable.getWriter(), - reader: this._wt.datagrams.readable, - } + this._wt.ready.then(() => { + this._wt.datagrams.outgoingMaxAge = OUTGOING_DATAGRAM_MAX_AGE_MS + this._wt.datagrams.outgoingHighWaterMark = OUTGOING_DATAGRAM_HIGH_WATER_MARK + + const writableStream: WritableStream = + "createWritable" in this._wt.datagrams && typeof this._wt.datagrams.createWritable === "function" + ? this._wt.datagrams.createWritable() + : this._wt.datagrams.writable // Deprecated and non-standard. + + this._datagramWriter = writableStream.getWriter() this._ready = true this.onOpen?.bind(this)?.(null) setTimeout(() => this.listenDatagrams()) @@ -62,8 +108,11 @@ class MultiplayerWebtransport extends MultiplayerTransport { const headerByte = body[0] const data = body.slice(1) const decoded = this._decoder.decode(data) as ServerToClientMessage | MessageWithTimestamp + await this.dispatchMessage(headerByte, decoded) + } + + private async dispatchMessage(headerByte: number, decoded: ServerToClientMessage | MessageWithTimestamp) { const isServer = headerByte == SERVER_PREFIX - if (decoded.type != "update") console.debug("Recieving", isServer ? "server" : "client", decoded) if (isServer) { this.onServerMessage?.(decoded as ServerToClientMessage) } else { @@ -72,37 +121,87 @@ class MultiplayerWebtransport extends MultiplayerTransport { } private async listenStreams() { - for await (const message of this._stream.reader) { - console.log("[TCP]", message) - await this.decodeMessage(message) + try { + for await (const stream of this._wt.incomingUnidirectionalStreams) { + setTimeout(() => this.handleStream(stream)) + } + } catch { + console.warn("Connection closed") + this.notifyClosed() } } + private async handleStream(stream: ReadableStream) { + const reader = stream.getReader() + const { value: firstChunk } = await reader.read() + reader.releaseLock() + + if (!firstChunk) return + + const headerByte = firstChunk[0] + const remainder = firstChunk.subarray(1) + + async function* getPayload() { + if (remainder.byteLength > 0) yield remainder + for await (const chunk of stream) yield chunk + } + + const data = await this._decoder.decodeAsync(getPayload()) + await this.dispatchMessage(headerByte, data as ServerToClientMessage | MessageWithTimestamp) + } + private async listenDatagrams() { - for await (const datagram of this._datagrams.reader) { - console.log("[UDP]", datagram) + for await (const datagram of this._wt.datagrams.readable) { await this.decodeMessage(datagram) } } - override async send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage, datagram: boolean) { - if (msg.type != "update") console.debug("Sending", msg) + override send(prefix: number, msg: MessageWithTimestamp | ClientToServerMessage, datagram: boolean): void { const encoded = this._encoder.encodeSharedRef(msg) - this._prefixBuf[0] = prefix - const data = new Blob([this._prefixBuf, encoded]) - if (datagram) { - await this._datagrams.writer.write(await data.bytes()) - } else { - await this._stream.writer.write(await data.bytes()) + const bytes = new Uint8Array(encoded.length + 1) + bytes[0] = prefix + bytes.set(encoded, 1) + + // A datagram over the path limit is silently discarded by the browser, we should send as stream + const maxDatagramSize = this._wt.datagrams.maxDatagramSize + + let sendOrder = DEFAULT_SEND_ORDER + + if (datagram && bytes.length > maxDatagramSize) { + this._updateSendOrder += 1 + sendOrder = this._updateSendOrder + + if (!this._warnedOversizedDatagram) { + this._warnedOversizedDatagram = true + console.warn( + `A "${msg.type}" message is ${bytes.length} bytes, past the ${maxDatagramSize} byte datagram limit, so it is going over a stream instead. Shrink the payload to keep these unreliable.` + ) + } + } else if (datagram) { + const desiredSize = this._datagramWriter.desiredSize + if (desiredSize !== null && desiredSize <= 0) return + + void this._datagramWriter.write(bytes).catch(() => {}) + return } + + void this.sendStream(bytes, sendOrder).catch(() => { + console.warn("Failed to send", msg.type) + }) + } + + private async sendStream(bytes: Uint8Array, sendOrder: number) { + const stream = await this._wt.createUnidirectionalStream({ sendOrder }) + const writer = stream.getWriter() + + await writer.write(bytes) + await writer.close() } override async close(code?: number, reason?: string) { this._wt.close?.({ closeCode: code, reason }) - await this._stream.reader.cancel("Transport closed by client") - await this._datagrams.reader.cancel("Transport closed by client") - await this._stream.writer.close() - await this._datagrams.writer.close() + await this._wt.datagrams.readable.cancel("Transport closed by client") } } + export default MultiplayerWebtransport diff --git a/fission/src/systems/multiplayer/UpdatePhysicsData.ts b/fission/src/systems/multiplayer/UpdatePhysicsData.ts index 49d15b0f28..b138772001 100644 --- a/fission/src/systems/multiplayer/UpdatePhysicsData.ts +++ b/fission/src/systems/multiplayer/UpdatePhysicsData.ts @@ -9,36 +9,34 @@ import World from "../World" */ export function handleUpdateObjectPhysics(sceneObject: MirabufSceneObject, bodies: PhysicsBodyData[], peerId: string) { // Sets the physics data for each body in the assembly - for (const { rigidNodeId, ...physicsData } of bodies) { - const bodyId = sceneObject.mechanism.getBodyByNodeId(rigidNodeId) + for (const body of bodies) { + const bodyId = sceneObject.mechanism.getBodyByNodeId(body[0]) if (bodyId == null) { console.error(`BodyId: ${bodyId} sent by ${peerId} does not exist in bodyMap`) continue } - applyPhysicsBodyData(bodyId, physicsData) + applyPhysicsBodyData(bodyId, body) } } /** * Updates the physics data for a specific body */ -export function applyPhysicsBodyData(bodyId: Jolt.BodyID, data: Omit) { +export function applyPhysicsBodyData(bodyId: Jolt.BodyID, data: PhysicsBodyData) { const clientBody = World.physicsSystem.getBody(bodyId) if (!clientBody) { console.error(`Body ${bodyId} not found`) return } - const lin: { x: number; y: number; z: number } = JSON.parse(data.linearVelocityStr) - const ang: { x: number; y: number; z: number } = JSON.parse(data.angularVelocityStr) - const pos: { x: number; y: number; z: number } = JSON.parse(data.positionStr) - const rot: { x: number; y: number; z: number; w: number } = JSON.parse(data.rotationStr) + // The caller has already resolved `rnId` into `bodyId`, so skip past it + const [, [linX, linY, linZ], [angX, angY, angZ], [posX, posY, posZ], [rotX, rotY, rotZ, rotW]] = data - const linearVelocity = new JOLT.Vec3(lin.x, lin.y, lin.z) - const angularVelocity = new JOLT.Vec3(ang.x, ang.y, ang.z) - const position = new JOLT.RVec3(pos.x, pos.y, pos.z) - const rotation = new JOLT.Quat(rot.x, rot.y, rot.z, rot.w) + const linearVelocity = new JOLT.Vec3(linX, linY, linZ) + const angularVelocity = new JOLT.Vec3(angX, angY, angZ) + const position = new JOLT.RVec3(posX, posY, posZ) + const rotation = new JOLT.Quat(rotX, rotY, rotZ, rotW) clientBody.SetLinearVelocity(linearVelocity) clientBody.SetAngularVelocity(angularVelocity) diff --git a/fission/src/systems/physics/Mechanism.ts b/fission/src/systems/physics/Mechanism.ts index 9148843242..effd1c7469 100644 --- a/fission/src/systems/physics/Mechanism.ts +++ b/fission/src/systems/physics/Mechanism.ts @@ -2,7 +2,7 @@ import type Jolt from "@synthesis.adsk/jolt-physics" import type { RigidNodeId } from "@/mirabuf/MirabufParser" import type { mirabuf } from "@/proto/mirabuf" import type { LayerReserve } from "./PhysicsSystem" -import { SceneObjectId } from "../scene/SceneRenderer" +import type { SceneObjectId } from "../scene/SceneRenderer" export interface MechanismConstraint { parentBody: Jolt.BodyID diff --git a/fission/src/systems/physics/PhysicsSystem.ts b/fission/src/systems/physics/PhysicsSystem.ts index b5c092b68c..b0fdda470b 100644 --- a/fission/src/systems/physics/PhysicsSystem.ts +++ b/fission/src/systems/physics/PhysicsSystem.ts @@ -13,7 +13,12 @@ import { convertThreeVector3ToJoltVec3, } from "@/util/TypeConversions.ts" import type MirabufParser from "../../mirabuf/MirabufParser" -import { GAMEPIECE_SUFFIX, GROUNDED_JOINT_ID, RigidNodeId, type RigidNodeReadOnly } from "@/mirabuf/MirabufParser.ts" +import { + GAMEPIECE_SUFFIX, + GROUNDED_JOINT_ID, + type RigidNodeId, + type RigidNodeReadOnly, +} from "@/mirabuf/MirabufParser.ts" import { mirabuf } from "@/proto/mirabuf" import type { Message } from "../multiplayer/MultiplayerTypes.ts" import PreferencesSystem from "../preferences/PreferencesSystem" @@ -1609,16 +1614,34 @@ class PhysicsSystem extends WorldSystem { return } - const touchedObjects = clientSceneObject.mechanism.touchedBodies - - const message: Message = { + // A robot's bodies go out one message at a time rather than as one + // batch. A whole robot does not fit in a datagram, and split up they + // do, which keeps them on the unreliable path where they belong: + // a body's position is superseded a few ticks later anyway, so + // dropping one beats delaying everything behind it. + World.multiplayerSystem?.broadcast({ type: "update", data: { - sceneObject: clientSceneObject.getUpdateData(), - touchedBodies: touchedObjects.map(data => this.getRNUpdateData(...data)), + objId: clientSceneObjectId, + gamePiecesControlled: clientSceneObject.getGamePiecesControlled(), }, + }) + + for (const body of clientSceneObject.getAllBodies()) { + const message: Message = { + type: "updatePhysicsBody", + data: [clientSceneObjectId, this.getBodyUpdateData(body)], + } + World.multiplayerSystem?.broadcast(message) + } + + for (const touched of clientSceneObject.mechanism.touchedBodies) { + const message: Message = { + type: "updatePhysicsBody", + data: this.getRNUpdateData(...touched), + } + World.multiplayerSystem?.broadcast(message) } - World.multiplayerSystem?.broadcast(message) if (clientSceneObjectId != null) { clientSceneObject.mechanism.touchedBodies = [] @@ -1646,14 +1669,16 @@ class PhysicsSystem extends WorldSystem { const position = body.GetPosition() const rotation = body.GetRotation() - return { + return [ sceneObjectId, - rigidNodeId, - linearVelocityStr: `{"x": ${linearVelocity.GetX()}, "y": ${linearVelocity.GetY()}, "z": ${linearVelocity.GetZ()}}`, - angularVelocityStr: `{"x": ${angularVelocity.GetX()}, "y": ${angularVelocity.GetY()}, "z": ${angularVelocity.GetZ()}}`, - positionStr: `{"x": ${position.GetX()}, "y": ${position.GetY()}, "z": ${position.GetZ()}}`, - rotationStr: `{"x": ${rotation.GetX()}, "y": ${rotation.GetY()}, "z": ${rotation.GetZ()}, "w": ${rotation.GetW()}}`, - } + [ + rigidNodeId, + [linearVelocity.GetX(), linearVelocity.GetY(), linearVelocity.GetZ()], + [angularVelocity.GetX(), angularVelocity.GetY(), angularVelocity.GetZ()], + [position.GetX(), position.GetY(), position.GetZ()], + [rotation.GetX(), rotation.GetY(), rotation.GetZ(), rotation.GetW()], + ], + ] } public getBodyUpdateData(body: Jolt.Body): PhysicsBodyData { @@ -1662,14 +1687,13 @@ class PhysicsSystem extends WorldSystem { const angularVelocity = body.GetAngularVelocity() const position = body.GetPosition() const rotation = body.GetRotation() - - return { + return [ rigidNodeId, - linearVelocityStr: `{"x": ${linearVelocity.GetX()}, "y": ${linearVelocity.GetY()}, "z": ${linearVelocity.GetZ()}}`, - angularVelocityStr: `{"x": ${angularVelocity.GetX()}, "y": ${angularVelocity.GetY()}, "z": ${angularVelocity.GetZ()}}`, - positionStr: `{"x": ${position.GetX()}, "y": ${position.GetY()}, "z": ${position.GetZ()}}`, - rotationStr: `{"x": ${rotation.GetX()}, "y": ${rotation.GetY()}, "z": ${rotation.GetZ()}, "w": ${rotation.GetW()}}`, - } + [linearVelocity.GetX(), linearVelocity.GetY(), linearVelocity.GetZ()], + [angularVelocity.GetX(), angularVelocity.GetY(), angularVelocity.GetZ()], + [position.GetX(), position.GetY(), position.GetZ()], + [rotation.GetX(), rotation.GetY(), rotation.GetZ(), rotation.GetW()], + ] } /** diff --git a/fission/src/systems/scene/DragModeSystem.ts b/fission/src/systems/scene/DragModeSystem.ts index 07b0edfee4..4835427cfa 100644 --- a/fission/src/systems/scene/DragModeSystem.ts +++ b/fission/src/systems/scene/DragModeSystem.ts @@ -574,11 +574,7 @@ class DragModeSystem extends WorldSystem { if (World.multiplayerSystem && this._dragTarget.isGamePiece) { const message: Message = { type: "updatePhysicsBody", - data: { - sceneObjectId: this._dragTarget.sceneObjectId, - ...World.physicsSystem.getBodyUpdateData(body), - rigidNodeId: this._dragTarget.rn, - }, + data: [this._dragTarget.sceneObjectId, World.physicsSystem.getBodyUpdateData(body)], } World.multiplayerSystem.broadcast(message) } diff --git a/fission/src/ui/modals/multiplayer/ConnectionModal.tsx b/fission/src/ui/modals/multiplayer/ConnectionModal.tsx index 202ca3f915..a6d6da5466 100644 --- a/fission/src/ui/modals/multiplayer/ConnectionModal.tsx +++ b/fission/src/ui/modals/multiplayer/ConnectionModal.tsx @@ -4,40 +4,14 @@ import { Stack } from "@mui/system" import Label from "@/components/Label.tsx" import { Button, TextField } from "@mui/material" import { DEFAULT_MULTIPLAYER_PORT } from "@/systems/preferences/PreferenceTypes.ts" -import Checkbox from "@/components/Checkbox.tsx" -import { CustomTooltip } from "@/components/StyledComponents.tsx" import PreferencesSystem from "@/systems/preferences/PreferencesSystem.ts" import { globalAddToast } from "@/components/GlobalUIControls.ts" -import MultiplayerWebsocket from "@/systems/multiplayer/MultiplayerWebsocket.ts" -import { waitUntil, withTimeout } from "@/util/Utility.ts" -import SessionStorage from "@/util/SessionStorage.ts" +import { withTimeout } from "@/util/Utility.ts" import type { RoomInfo } from "@/systems/multiplayer/bindings/RoomInfo.ts" +import MultiplayerWebtransport from "@/systems/multiplayer/MultiplayerWebtransport.ts" const DEFAULT_HOST = "127.0.0.1" -async function promptCert(url: string): Promise { - const shouldAttemptCert = confirm( - "This issue may be caused by an unrecognized certificate. Would you like to try manually accepting the certificate?\n\nThis will open a new tab, you will need to manually accept the certificate for your server, as it is self-signed. \n\nIf the page completely fails to load, it is not a certificate error, but rather an inaccessible server.\n\nAfter proceeding, close the tab and press 'Test Connection' again" - ) - if (!shouldAttemptCert) return false - const urlObj = new URL(url) - urlObj.protocol = "https:" - urlObj.pathname = "/cert" - const windowHandle = window.open(urlObj.href, "_blank", "popup") - if (windowHandle) { - await waitUntil(() => windowHandle?.closed, 300) - SessionStorage.saveOnce("autoOpenMultiplayer", true) - SessionStorage.saveOnce("autoToast", { - type: "info", - lines: ["Multiplayer Certificate Update", "Try connecting again!"], - }) - window.location.reload() - } else { - globalAddToast("warning", "Could not open a new tab. Please visit the page manually", urlObj.href) - } - return false -} - interface ConnectionModalProps { setRoomList: (roomList: RoomInfo[]) => void setURL: (url: string) => void @@ -47,14 +21,12 @@ interface ConnectionModalProps { const ConnectionModal: React.FC = ({ setRoomList, setURL, onNext }) => { const [host, setHost] = useState(PreferencesSystem.getUserPreference("MultiplayerHost")) const [port, setPort] = useState(PreferencesSystem.getUserPreference("MultiplayerPort").toString()) - const [secure, setSecure] = useState(PreferencesSystem.getUserPreference("MultiplayerSecure")) const [testState, setTestState] = useState<"pass" | "fail" | "progress" | null>(null) - const [showCheckCertButton, setShowCheckCertButton] = useState(false) // biome-ignore lint/correctness/useExhaustiveDependencies: Should run whenever these change regardless of their values useEffect(() => { setTestState(null) - }, [port, host, secure]) + }, [port, host]) const validateServer = useCallback( (silent: boolean): string | undefined => { @@ -63,14 +35,13 @@ const ConnectionModal: React.FC = ({ setRoomList, setURL, !silent && globalAddToast("warning", "Invalid Port", "Must be an integer between 0 and 65535") return } - const url = `${secure ? "wss" : "ws"}://${host || DEFAULT_HOST}:${parsedPort}` + const url = `https://${host || DEFAULT_HOST}:${parsedPort}` if (URL.canParse != null && !URL.canParse(url)) { !silent && globalAddToast("warning", "Cannot Parse URL", url) return } PreferencesSystem.setUserPreference("MultiplayerPort", parsedPort) - PreferencesSystem.setUserPreference("MultiplayerSecure", secure) PreferencesSystem.setUserPreference("MultiplayerHost", host) PreferencesSystem.savePreferences() const cleanURL = new URL(url).href @@ -78,7 +49,7 @@ const ConnectionModal: React.FC = ({ setRoomList, setURL, return cleanURL }, - [host, port, secure, setURL] + [host, port, setURL] ) // biome-ignore lint/correctness/useExhaustiveDependencies: Only trying to run this on load @@ -94,63 +65,29 @@ const ConnectionModal: React.FC = ({ setRoomList, setURL, new Promise(resolve => { console.groupCollapsed("Connection Test") setTestState("progress") - const ws = new MultiplayerWebsocket(url.toString()) - ws.onOpen = () => { - resolve(true) - ws.sendServer({ - type: "requestrooms", - }) - } - ws.onError = async () => { - const urlObj = new URL(url) - urlObj.protocol = "http:" - // NOTE: Chrome is evil and for "security" this will always fail on Chrome. It works as intended on firefox - const reachable = await fetch(urlObj.href, { mode: "no-cors" }) - .then(() => true) - .catch(() => false) - - if (reachable && !silent) { - if (secure) { - globalAddToast( - "warning", - "WebSocket connection failed!", - "Server reachable, try manually accepting the certificate" - ) - await promptCert(url) - } else { - globalAddToast( - "warning", - "WebSocket connection failed!", - "Server reachable, check secure flag" - ) - } - } else { - if (secure) { - !silent && - globalAddToast("error", "Connection failed!", "Try pressing 'Load Certificate'") - setShowCheckCertButton(true) - } else { - !silent && globalAddToast("error", "Connection failed!") - setShowCheckCertButton(false) + MultiplayerWebtransport.create(url.toString()).then(ws => { + if (ws == null) return + ws.onOpen = () => { + resolve(true) + ws.sendServer({ + type: "requestrooms", + }) + } + ws.onClose = () => { + resolve(false) + console.groupEnd() + } + ws.onServerMessage = msg => { + console.log("Test server message", msg) + if (msg.type == "roomlist") { + setRoomList(msg.rooms) + resolve(true) } } - - resolve(false) - } - ws.onClose = () => { - resolve(false) - console.groupEnd() - } - ws.onServerMessage = msg => { - console.log("Test server message", msg) - if (msg.type == "roomlist") { - setRoomList(msg.rooms) - resolve(true) + ws.onPeerMessage = msg => { + console.log("Test peer message", msg) } - } - ws.onPeerMessage = msg => { - console.log("Test peer message", msg) - } + }) }), "Connection timed out", 10000 @@ -163,7 +100,7 @@ const ConnectionModal: React.FC = ({ setRoomList, setURL, } setTestState(success ? "pass" : "fail") }, - [validateServer, setRoomList, secure] + [validateServer, setRoomList] ) return ( @@ -193,12 +130,6 @@ const ConnectionModal: React.FC = ({ setRoomList, setURL, }} /> - setSecure(checked)} - /> - {secure && testState !== "pass" && ( - - - - - )} diff --git a/fission/src/ui/modals/multiplayer/MultiplayerStartModal.tsx b/fission/src/ui/modals/multiplayer/MultiplayerStartModal.tsx index 90deed8e6b..cef9e1212c 100644 --- a/fission/src/ui/modals/multiplayer/MultiplayerStartModal.tsx +++ b/fission/src/ui/modals/multiplayer/MultiplayerStartModal.tsx @@ -5,13 +5,13 @@ import { CloseType, useUIContext } from "../../helpers/UIProviderHelpers.ts" import ConnectionModal from "@/modals/multiplayer/ConnectionModal.tsx" import RoomModal from "@/modals/multiplayer/RoomModal.tsx" import type { RoomInfo } from "@/systems/multiplayer/bindings/RoomInfo.ts" -import type MultiplayerWebsocket from "@/systems/multiplayer/MultiplayerWebsocket.ts" import World from "@/systems/World.ts" import { Button } from "@/components/StyledComponents.tsx" +import type { MultiplayerTransport } from "@/systems/multiplayer/MultiplayerTransport.ts" export interface MultiplayerInitProps { displayName: string - ws: MultiplayerWebsocket + ws: MultiplayerTransport isHost: boolean keepAssets: boolean } diff --git a/fission/src/ui/modals/multiplayer/RoomModal.tsx b/fission/src/ui/modals/multiplayer/RoomModal.tsx index fe2f46e2eb..117441e6b1 100644 --- a/fission/src/ui/modals/multiplayer/RoomModal.tsx +++ b/fission/src/ui/modals/multiplayer/RoomModal.tsx @@ -8,8 +8,8 @@ import type { MultiplayerInitProps } from "@/modals/multiplayer/MultiplayerStart import { CloseType, useUIContext } from "@/ui/helpers/UIProviderHelpers.ts" import { withTimeout } from "@/util/Utility.ts" import type { RoomInfo } from "@/systems/multiplayer/bindings/RoomInfo.ts" -import MultiplayerWebsocket from "@/systems/multiplayer/MultiplayerWebsocket.ts" import { startMultiplayerWorld } from "@/ui/helpers/StartMultiplayerWorld.ts" +import MultiplayerWebtransport from "@/systems/multiplayer/MultiplayerWebtransport.ts" interface RoomModalProps { initialRoomList: RoomInfo[] @@ -22,7 +22,7 @@ const RoomModal: React.FC = ({ initialRoomList, url, onBack }) = const [name, setName] = useState(PreferencesSystem.getUserPreference("MultiplayerUsername")) const [roomList, setRoomList] = useState(initialRoomList) const [updatingRoomList, setUpdatingRoomList] = useState(false) - const wsRef = useRef(null) + const wsRef = useRef(null) const usernameRef = useRef(null) useEffect(() => { @@ -46,18 +46,28 @@ const RoomModal: React.FC = ({ initialRoomList, url, onBack }) = return withTimeout( new Promise(resolve => { if (wsRef.current == null) { - wsRef.current = new MultiplayerWebsocket(url) - wsRef.current.onOpen = () => { - wsRef.current!.sendServer({ type: "requestrooms" }) - } + MultiplayerWebtransport.create(url).then(ws => { + if (ws === null) { + return + } + wsRef.current = ws + wsRef.current.onOpen = () => { + wsRef.current!.sendServer({ type: "requestrooms" }) + } + wsRef.current.onServerMessage = msg => { + if (msg.type === "roomlist") { + setRoomList(msg.rooms) + resolve(true) + } + } + }) } else { wsRef.current.sendServer({ type: "requestrooms" }) - } - - wsRef.current.onServerMessage = msg => { - if (msg.type === "roomlist") { - setRoomList(msg.rooms) - resolve(true) + wsRef.current.onServerMessage = msg => { + if (msg.type === "roomlist") { + setRoomList(msg.rooms) + resolve(true) + } } } }), @@ -67,7 +77,7 @@ const RoomModal: React.FC = ({ initialRoomList, url, onBack }) = }, [url]) const validate = useCallback( - (room: string | undefined, keepAssets: boolean): MultiplayerInitProps | undefined => { + async (room: string | undefined, keepAssets: boolean): Promise => { if (name.length < 3) { globalAddToast("warning", "Invalid Username", "Must be at least 3 characters") usernameRef.current?.querySelector("input")?.focus() @@ -82,7 +92,11 @@ const RoomModal: React.FC = ({ initialRoomList, url, onBack }) = return { displayName: name, - ws: MultiplayerWebsocket.init(room ?? null, name, wsRef.current ?? new MultiplayerWebsocket(url)), + ws: MultiplayerWebtransport.init( + room ?? null, + name, + wsRef.current ?? (await MultiplayerWebtransport.create(url))! + ), isHost: room == undefined, keepAssets: keepAssets ?? false, } @@ -92,7 +106,7 @@ const RoomModal: React.FC = ({ initialRoomList, url, onBack }) = const joinRoom = useCallback( async (roomId: string | undefined, keepAssets: boolean) => { - const initData = validate(roomId, keepAssets) + const initData = await validate(roomId, keepAssets) if (initData == null) return const success = await withTimeout(startMultiplayerWorld(initData), "Multiplayer connect timed out") diff --git a/glueball/Cargo.lock b/glueball/Cargo.lock index 0f68ee07ca..2b1d64d689 100644 --- a/glueball/Cargo.lock +++ b/glueball/Cargo.lock @@ -172,6 +172,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -223,6 +229,16 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -429,6 +445,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -455,12 +480,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - [[package]] name = "futures-task" version = "0.3.33" @@ -475,7 +494,6 @@ checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", - "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -488,8 +506,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -499,9 +519,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", ] [[package]] @@ -520,18 +542,15 @@ dependencies = [ "futures-util", "rand", "ratatui", - "rcgen", "rmp-serde", "rtrb", - "rustls-pemfile", "serde", "serde_json", "tokio", - "tokio-rustls", - "tokio-tungstenite", "toml", "ts-rs", "uuid", + "wtransport", ] [[package]] @@ -564,20 +583,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" +name = "httlib-huffman" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" [[package]] name = "hybrid-array" @@ -612,12 +621,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -703,6 +815,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -727,6 +845,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" @@ -795,6 +919,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "octets" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" + [[package]] name = "oid-registry" version = "0.8.1" @@ -810,6 +940,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -855,12 +991,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -876,6 +1027,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -908,6 +1115,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -935,7 +1151,6 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ - "pem", "ring", "rustls-pki-types", "time", @@ -1002,6 +1217,12 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1030,7 +1251,6 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -1040,12 +1260,15 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.2.0" +name = "rustls-native-certs" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ + "openssl-probe", "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] @@ -1054,6 +1277,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -1080,12 +1304,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1139,10 +1395,10 @@ dependencies = [ ] [[package]] -name = "sha1" +name = "sha2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", @@ -1208,6 +1464,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1340,6 +1602,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1366,28 +1653,6 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -1427,6 +1692,37 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "ts-rs" version = "12.0.1" @@ -1449,22 +1745,6 @@ dependencies = [ "termcolor", ] -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror", -] - [[package]] name = "typenum" version = "1.20.1" @@ -1512,6 +1792,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.24.0" @@ -1574,6 +1872,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1761,6 +2069,48 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wtransport" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +dependencies = [ + "bytes", + "pem", + "quinn", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha2", + "socket2", + "thiserror", + "time", + "tokio", + "tracing", + "url", + "wtransport-proto", + "x509-parser", +] + +[[package]] +name = "wtransport-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +dependencies = [ + "httlib-huffman", + "octets", + "thiserror", + "url", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -1789,12 +2139,89 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/glueball/Cargo.toml b/glueball/Cargo.toml index fdcbb98b99..4ea22bcc69 100644 --- a/glueball/Cargo.toml +++ b/glueball/Cargo.toml @@ -20,18 +20,15 @@ futures-channel = "0.3.33" futures-util = "0.3.33" rand = "0.10.2" ratatui = "0.29" -rcgen = "0.14.8" rmp-serde = "1.3.1" rtrb = "0.3.4" -rustls-pemfile = "2" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" -tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "sync", "time", "macros"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } -tokio-tungstenite = "0.30.0" +tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "sync", "time", "macros", "io-util"] } toml = "1.1.4" ts-rs = "12.0.1" uuid = { version = "1.24.0", features = ["v4"] } +wtransport = "0.7.1" [lints.clippy] suspicious = { level = "deny", priority = -1 } diff --git a/glueball/bindings/CertificateHash.ts b/glueball/bindings/CertificateHash.ts new file mode 100644 index 0000000000..68dd8d23df --- /dev/null +++ b/glueball/bindings/CertificateHash.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One digest, shaped like the `WebTransportHash` dictionary a browser expects. + */ +export type CertificateHash = { + algorithm: string + /** + * The raw digest bytes, ready to be handed to `new Uint8Array(value)` + */ + value: Array +} diff --git a/glueball/bindings/CertificateHashes.ts b/glueball/bindings/CertificateHashes.ts new file mode 100644 index 0000000000..4be7e75c36 --- /dev/null +++ b/glueball/bindings/CertificateHashes.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CertificateHash } from "./CertificateHash" + +/** + * Answer to `GET /cert`, carrying what a client needs to pin this server's + * certificate with `serverCertificateHashes` when connecting. + * + * A browser will not offer to trust a self-signed certificate for + * `WebTransport` the way it does for HTTPS, so pinning the digest is the only + * way a self-signed server is reachable at all. + */ +export type CertificateHashes = { hashes: Array } diff --git a/glueball/bindings/ClientToServerMessage.ts b/glueball/bindings/ClientToServerMessage.ts index 238e09f54e..f3f824d831 100644 --- a/glueball/bindings/ClientToServerMessage.ts +++ b/glueball/bindings/ClientToServerMessage.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ClientToServerMessage = { "type": "requestrooms" } | { "type": "initializeconnection", room_id: string | null, name: string, } | { "type": "ping", timestamp: number, }; +export type ClientToServerMessage = + | { type: "requestrooms" } + | { type: "initializeconnection"; room_id: string | null; name: string } + | { type: "ping"; timestamp: number } diff --git a/glueball/bindings/RoomInfo.ts b/glueball/bindings/RoomInfo.ts index dc78533bd6..a8ce456ec5 100644 --- a/glueball/bindings/RoomInfo.ts +++ b/glueball/bindings/RoomInfo.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RoomInfo = { id: string, locked: boolean, host: string | null, }; +export type RoomInfo = { id: string; locked: boolean; host: string | null } diff --git a/glueball/bindings/ServerToClientMessage.ts b/glueball/bindings/ServerToClientMessage.ts index a3981ae40c..bcbf0adc66 100644 --- a/glueball/bindings/ServerToClientMessage.ts +++ b/glueball/bindings/ServerToClientMessage.ts @@ -1,4 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RoomInfo } from "./RoomInfo"; +import type { RoomInfo } from "./RoomInfo" -export type ServerToClientMessage = { "type": "kick", client_id: string, } | { "type": "roomlist", rooms: Array, } | { "type": "sendinfo", room_id: string, client_id: string, } | { "type": "pong", client_send_ts: number, server_ts: number, }; +export type ServerToClientMessage = + | { type: "kick"; client_id: string } + | { type: "roomlist"; rooms: Array } + | { type: "sendinfo"; room_id: string; client_id: string } + | { type: "pong"; client_send_ts: number; server_ts: number } diff --git a/glueball/src/cert.rs b/glueball/src/cert.rs index edbb52528e..edd6ddcfc0 100644 --- a/glueball/src/cert.rs +++ b/glueball/src/cert.rs @@ -1,45 +1,30 @@ -use std::{ - fs::{self, File}, - io::BufReader, - path::PathBuf, -}; - -use anyhow::{Result, bail}; -use rcgen::{CertifiedKey, generate_simple_self_signed}; -use tokio_rustls::rustls::{ - ServerConfig, - pki_types::{CertificateDer, PrivateKeyDer}, -}; - -/// Creates a TLS config for the server -/// Generates a certificate if one does not exist -pub fn build_tls_config(cert_directory: &PathBuf) -> Result { - ensure_certificate(cert_directory)?; - - let mut cert_reader = BufReader::new(File::open(cert_directory.join("cert.pem"))?); - let cert_chain: Vec = - rustls_pemfile::certs(&mut cert_reader).collect::>()?; - - let key_path = cert_directory.join("key.pem"); - let mut key_reader = BufReader::new(File::open(&key_path)?); - - let Some(Ok(key)) = rustls_pemfile::pkcs8_private_keys(&mut key_reader).next() else { - let Some(path) = key_path.to_str() else { - bail!("Certificate file must be unicode"); - }; - - bail!("Invalid PKCS#8 private key found in {path}"); - }; - - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKeyDer::Pkcs8(key))?; - - Ok(config) +use std::{fs, path::Path}; + +use anyhow::Result; +use wtransport::Identity; + +/// `WebTransport` runs over QUIC, so the server always needs a TLS identity. +/// Loads the certificate and key from `cert_directory`, generating a self-signed +/// pair there if none is present. +pub async fn build_identity(cert_directory: &Path) -> Result { + ensure_certificate(cert_directory).await?; + + let identity = Identity::load_pemfiles( + cert_directory.join("cert.pem"), + cert_directory.join("key.pem"), + ) + .await?; + + Ok(identity) } /// Writes a self-signed certificate and keypair to `path` if one isn't already present. -fn ensure_certificate(path: &PathBuf) -> Result<()> { +/// +/// The certificate is valid for two weeks, which is the longest a browser will +/// accept for a certificate pinned with `serverCertificateHashes` — the only way +/// a browser will trust a self-signed `WebTransport` certificate at all. Once it +/// expires the pair has to be regenerated by deleting it. +async fn ensure_certificate(path: &Path) -> Result<()> { if !fs::exists(path)? { fs::create_dir_all(path)?; } @@ -48,15 +33,17 @@ fn ensure_certificate(path: &PathBuf) -> Result<()> { return Ok(()); } - let subject_alt_names = vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "0.0.0.0".to_string(), - ]; - let CertifiedKey { cert, signing_key } = generate_simple_self_signed(subject_alt_names)?; + let identity = Identity::self_signed(["localhost", "127.0.0.1", "0.0.0.0", "::1"])?; + + identity + .certificate_chain() + .store_pemfile(path.join("cert.pem")) + .await?; - fs::write(path.join("cert.pem"), cert.pem())?; - fs::write(path.join("key.pem"), signing_key.serialize_pem())?; + identity + .private_key() + .store_secret_pemfile(path.join("key.pem")) + .await?; Ok(()) } diff --git a/glueball/src/http.rs b/glueball/src/http.rs new file mode 100644 index 0000000000..f4b3f5681f --- /dev/null +++ b/glueball/src/http.rs @@ -0,0 +1,125 @@ +//! A minimal HTTP/1.1 responder sharing the server's port over TCP. +//! +//! `WebTransport` is carried over QUIC, which is UDP, so nothing here touches +//! game traffic — the TCP half of the port would otherwise sit unused. Its job is +//! to answer the plain HTTP requests a browser makes before it connects: +//! +//! * `GET /cert` returns this server's certificate digests as JSON, which a +//! client passes to `serverCertificateHashes` to reach a self-signed server. +//! * Anything else gets a short body, so hitting the port in a browser says +//! something useful instead of hanging. +//! +//! Responses are plain HTTP rather than HTTPS on purpose: serving them over TLS +//! with the very certificate the client is trying to learn about would be +//! circular. Browsers treat `localhost` and `127.0.0.1` as trustworthy origins, +//! so mixed-content rules do not block this for the local servers it exists for. + +use crate::EventType; +use crate::logging::{LogDestination, LogSender}; +use crate::model::CertificateHashes; + +use anyhow::{Result, bail}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use std::net::SocketAddr; +use std::sync::Arc; + +/// Largest request head we will read. Anything a browser sends us fits well +/// inside this, and we have no use for a body. +const MAX_REQUEST_SIZE: usize = 1024; + +/// These responses are read by a page served from a different origin, so they +/// have to opt in to being read cross-origin. +const CORS_HEADERS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\n"; + +/// Binds the TCP half of `port` and starts answering HTTP requests on it. +/// +/// Binding happens before returning so a port conflict is reported to the caller +/// rather than swallowed by a background task. +pub async fn spawn_http_responder( + port: u16, + hashes: CertificateHashes, + logging_tx: LogSender, +) -> Result<()> { + let Ok(listener) = TcpListener::bind(format!("0.0.0.0:{port}")).await else { + bail!("Could not create TCP listener (the port is likely in use)"); + }; + + // The digests never change, so the response body is built once and shared + let certificate_response = Arc::new(json_response(&serde_json::to_string(&hashes)?)); + + tokio::spawn(async move { + while let Ok((stream, addr)) = listener.accept().await { + let certificate_response = certificate_response.clone(); + let logging_tx = logging_tx.clone(); + + // Each request gets a task so a slow client cannot hold up the others + tokio::spawn(async move { + handle_request(stream, addr, &certificate_response, &logging_tx).await; + }); + } + }); + + Ok(()) +} + +/// Reads one request and writes one response. +/// +/// Connections are never kept alive, so there is no need to find the end of the +/// request head: the first read tells us the method and path, which is all that +/// distinguishes the handful of responses we serve. +async fn handle_request( + mut stream: TcpStream, + addr: SocketAddr, + certificate_response: &str, + logging_tx: &LogSender, +) { + let mut buf = vec![0u8; MAX_REQUEST_SIZE]; + + let count = match stream.read(&mut buf).await { + Ok(0) => return, + Ok(count) => count, + Err(e) => { + error_global!(logging_tx, "Failed to read from {addr}: {e}"); + return; + } + }; + buf.truncate(count); + + let request = String::from_utf8_lossy(&buf).to_ascii_lowercase(); + + let response = if request.starts_with("get /cert ") { + certificate_response.to_string() + } else if request.starts_with("options ") { + // A plain `GET` should not provoke a preflight, but answering one costs + // nothing and saves a confusing failure if a client adds a header + text_response("") + } else { + text_response("Synthesis") + }; + + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +fn json_response(body: &str) -> String { + build_response("application/json", body) +} + +fn text_response(body: &str) -> String { + build_response("text/plain", body) +} + +fn build_response(content_type: &str, body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + {CORS_HEADERS}\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ) +} diff --git a/glueball/src/kick.rs b/glueball/src/kick.rs index 9f5ff6686d..900f9b73f0 100644 --- a/glueball/src/kick.rs +++ b/glueball/src/kick.rs @@ -3,10 +3,10 @@ use crate::{ model::ServerToClientMessage, state::{ClientId, ClientSender, RoomId, RoomStatus, State}, util::server_sent_msg, + wire::Outbound, }; use rtrb::{Consumer, Producer, RingBuffer}; use std::sync::Arc; -use tokio_tungstenite::tungstenite::Message; const MAX_PENDING_KICK_MESSAGES: usize = 12; @@ -52,16 +52,18 @@ async fn kick(state: Arc, client_id: ClientId) { info_global!(state.log_tx, "Kicked {}", client_name); - // The close message gets forwarded to the client getting kicked + // The kicked client's session gets torn down by its writer task // TODO Don't have send a close back / deal with double removal - let _ = client_tx.send(Message::Close(None)).await; + let _ = client_tx.send(Outbound::Close).await; // Send message to all other clients telling them `client_id` has been kicked let message = server_sent_msg(ServerToClientMessage::Kick { client_id: client_id.to_string(), }); - let outgoing = peer_senders.iter().map(|tx| tx.send(message.clone())); + let outgoing = peer_senders + .iter() + .map(|tx| tx.send(Outbound::Stream(message.clone()))); futures_util::future::join_all(outgoing).await; } diff --git a/glueball/src/main.rs b/glueball/src/main.rs index 9349c83678..b2af3484ed 100644 --- a/glueball/src/main.rs +++ b/glueball/src/main.rs @@ -3,34 +3,40 @@ mod cleanup; mod config; #[macro_use] mod logging; +mod http; mod kick; mod messaging; mod model; -mod prefixed; mod state; #[cfg(test)] mod tests; mod tui; #[macro_use] mod util; +mod wire; -use crate::cert::build_tls_config; +use crate::cert::build_identity; use crate::cleanup::Cleanup; use crate::config::retrieve_config; +use crate::http::spawn_http_responder; use crate::kick::setup_user_action_system; use crate::logging::{ EventType, LogDestination, LogRequest, Logger, MAX_LOG_LINES, print_global, print_room, spawn_log_receiver, }; -use crate::messaging::handle_connection; +use crate::messaging::handle_session; +use crate::model::{CertificateHash, CertificateHashes}; use crate::state::State; use crate::tui::start_tui_thread; use crate::util::get_local_ip; use anyhow::{Result, bail}; use std::sync::{Arc, Mutex}; -use tokio::net::TcpListener; +use std::time::Duration; use tokio::sync::mpsc; -use tokio_rustls::TlsAcceptor; +use wtransport::{Endpoint, ServerConfig}; + +/// How often to poke an otherwise idle connection so QUIC doesn't time it out. +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); #[tokio::main] async fn main() -> Result<()> { @@ -89,60 +95,54 @@ async fn main() -> Result<()> { state.new_permanent_room(room_id); } - // # Setup socket listener - - // `listener` will be used regardless of the security level specified - let Ok(listener) = TcpListener::bind(format!("0.0.0.0:{}", config.port)).await else { - bail!("Could not create TCP listener (the port is likely in use)"); - }; - - let local_ip = get_local_ip().unwrap_or_else(|| String::from("0.0.0.0")); - - // Run insecure server if !config.secure { - info_global!( + warn_global!( logging_tx, - "Server hosted on {local_ip} listening at port {} (insecure)", - config.port + "Ignoring the insecure setting: WebTransport traffic is always encrypted" ); + } - while let Ok((stream, addr)) = listener.accept().await { - tokio::spawn(handle_connection( - state.clone(), - stream, - addr, - logging_tx.clone(), - )); - } + // # Setup the WebTransport endpoint - return Ok(()); - } + let identity = build_identity(&config.cert_dir).await?; + + // Browsers will not offer to trust a self-signed WebTransport certificate the + // way they do for HTTPS, so clients pin these digests with + // `serverCertificateHashes` instead. They are served over `GET /cert` + let hashes = CertificateHashes { + hashes: identity + .certificate_chain() + .as_slice() + .iter() + .map(|certificate| CertificateHash::sha256(certificate.hash().as_ref())) + .collect(), + }; + + let server_config = ServerConfig::builder() + .with_bind_default(config.port) + .with_identity(identity) + .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) + .build(); - // Run secure server - let tls_config = build_tls_config(&config.cert_dir)?; - let acceptor = TlsAcceptor::from(Arc::new(tls_config)); + let Ok(endpoint) = Endpoint::server(server_config) else { + bail!("Could not create UDP listener (the port is likely in use)"); + }; + + // Serves the certificate digests over the TCP half of the same port + spawn_http_responder(config.port, hashes, logging_tx.clone()).await?; + + let local_ip = get_local_ip().unwrap_or_else(|| String::from("0.0.0.0")); info_global!( logging_tx, - "Server hosted on {local_ip} listening at port {} (secure)", + "Server hosted on {local_ip} listening at port {} (UDP), certificate at /cert (TCP)", config.port ); - while let Ok((stream, addr)) = listener.accept().await { - let acceptor = acceptor.clone(); - let state = state.clone(); - - // TLS handshake happens in task to avoid being held up by a slow client - let logging_tx = logging_tx.clone(); - tokio::spawn(async move { - match acceptor.accept(stream).await { - Ok(tls_stream) => handle_connection(state, tls_stream, addr, logging_tx).await, - Err(e) => { - error_global!(logging_tx, "Secure connection with client failed {}", e); - } - } - }); - } + loop { + let session = endpoint.accept().await; - Ok(()) + // The handshake happens in a task to avoid being held up by a slow client + tokio::spawn(handle_session(state.clone(), session, logging_tx.clone())); + } } diff --git a/glueball/src/messaging.rs b/glueball/src/messaging.rs index 0bbe150871..c18b6c1208 100644 --- a/glueball/src/messaging.rs +++ b/glueball/src/messaging.rs @@ -1,108 +1,202 @@ use crate::EventType; use crate::logging::{LogDestination, LogSender}; use crate::model::{ClientToServerMessage, MessagePrefix, ServerToClientMessage}; -use crate::prefixed::{ConnectionStatus, Prefixed, SynthesisStream, into_prefixed_or_respond}; use crate::state::{ClientId, ClientSender, State}; use crate::util::{deserialize_messagepack, server_sent_msg, trim_uuid}; +use crate::wire::{Delivery, Outbound, read_message, write_message}; use anyhow::{Result, bail}; use bytes::Bytes; use chrono::Utc; -use futures_util::stream::{SplitSink, SplitStream}; -use futures_util::{SinkExt, StreamExt}; -use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::mpsc::{self}; use tokio::time::timeout; -use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; +use wtransport::VarInt; +use wtransport::endpoint::IncomingSession; +use wtransport::error::SendDatagramError; +use wtransport::Connection; use std::net::SocketAddr; -use std::ops; use std::sync::Arc; use std::time::Duration; -type WsStream = WebSocketStream>; - +/// How long to wait for a client's next message before assuming it is gone. +/// Clients ping every five seconds, so a silent client is a dead one even while +/// its datagrams keep arriving. const TIMEOUT: Duration = Duration::from_secs(30); -pub async fn handle_connection( - state: Arc, - raw_stream: S, - addr: SocketAddr, - logging_tx: LogSender, -) where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let ConnectionStatus::Ws(stream) = - into_prefixed_or_respond(raw_stream, addr, logging_tx.clone()).await - else { - return; +/// Number of messages that may be queued for one client before writes to it +/// block (streams) or are dropped (datagrams). +const OUTBOUND_CAPACITY: usize = 64; + +/// Application error code sent to a client whose session the server terminates. +const CLOSED_BY_SERVER: VarInt = VarInt::from_u32(0); + +/// Completes the `WebTransport` handshake for an incoming QUIC connection, then +/// hands the session to [`handle_connection`]. +pub async fn handle_session(state: Arc, session: IncomingSession, logging_tx: LogSender) { + let addr = session.remote_address(); + + let request = match session.await { + Ok(request) => request, + Err(e) => { + error_global!(logging_tx, "QUIC handshake with {addr} failed: {e}"); + return; + } }; - let ws_stream = match tokio_tungstenite::accept_async(stream).await { - Ok(ws_stream) => ws_stream, + let connection = match request.accept().await { + Ok(connection) => connection, Err(e) => { - error_global!(logging_tx, "Websocket handshake with {addr} failed: {e}"); + error_global!(logging_tx, "WebTransport handshake with {addr} failed: {e}"); return; } }; - info_global!(logging_tx, "WS connection established with {addr}"); + handle_connection(state, connection, addr, logging_tx).await; +} + +pub async fn handle_connection( + state: Arc, + connection: Connection, + addr: SocketAddr, + logging_tx: LogSender, +) { + info_global!(logging_tx, "WT session established with {addr}"); // Each client gets an mpsc channel // Other client threads on the server can write to it - // Everything written gets dumped back to its client through the `write` sink - let (tx, mut rx) = mpsc::channel::(64); + // Everything written gets dumped back to its client by the writer task + let (tx, rx) = mpsc::channel::(OUTBOUND_CAPACITY); // Order of messages sent from a new client to the server: // 1-n. Any number of `RequestRooms` messages -> server will return a list of rooms // n..n+1. An `InitializationMessage`, indicating whether the client wishes to create or join a room -> server will return a room and client id // n+1..m. Any number of messages that will be forwarded to every other client in their room -> server will not respond, instead forwarding - let (mut write, mut read) = ws_stream.split(); - - let Some(client_id) = wait_for_initialization( - state.clone(), - &mut read, - &mut write, - tx.clone(), - addr, - logging_tx.clone(), - ) - .await + let Some(client_id) = + wait_for_initialization(&state, &connection, tx.clone(), addr, &logging_tx).await else { return; }; - // This task listens for messages to the channel and sends them down the sink to the client - tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if write.send(msg).await.is_err() { - break; - } - } - }); + spawn_writer(connection.clone(), rx, logging_tx.clone()); + spawn_datagram_reader( + connection.clone(), + state.clone(), + client_id, + logging_tx.clone(), + ); // Listen for and pass along messages to other client channels in the same room loop { - let result = timeout(TIMEOUT, read.next()).await; + // Messages are taken one at a time rather than concurrently: streams give + // no ordering guarantees between each other, so draining them in arrival + // order is the closest thing to the ordering clients used to rely on + let message = match timeout(TIMEOUT, accept_message(&connection)).await { + Ok(Ok(message)) => message, + Ok(Err(e)) => { + warn_global!(logging_tx, "{} disconnected: {e}", trim_uuid(&client_id)); + break; + } + Err(_) => { + warn_global!(logging_tx, "{} timed out", trim_uuid(&client_id)); + break; + } + }; - // If it's a timeout error, we print such - if result.is_err() { - warn_global!(logging_tx, "{} timed out", trim_uuid(&client_id)); - } + handle_client_message(message, Delivery::Stream, &state, client_id, &logging_tx).await; + } + + let _ = handle_client_close(client_id, &state, logging_tx).await; +} + +/// Waits for the client's next stream and reads the message off it. +/// +/// Only unidirectional streams count as messages. Replies are sent as their own +/// stream rather than written back onto the request's stream, so there is nothing +/// a bidirectional stream would buy, and ignoring them means a client that opens +/// one and leaves it empty cannot stall this loop. +async fn accept_message(connection: &Connection) -> Result { + let read = connection.accept_uni().await?; + + read_message(read).await +} - // If it's anything but a correct response, we disconnect - let Ok(Some(Ok(message))) = result else { break }; +/// Drains `rx` onto the client's session. +fn spawn_writer(connection: Connection, mut rx: mpsc::Receiver, logging_tx: LogSender) { + tokio::spawn(async move { + // Undeliverable datagrams come in floods rather than one at a time, so the + // reason is worth saying once and then never again for this session + let mut warned_undeliverable = false; + + while let Some(message) = rx.recv().await { + match message { + Outbound::Stream(payload) => { + if write_message(&connection, &payload).await.is_err() { + break; + } + } - let message_result = - handle_client_message(message, state.clone(), client_id, logging_tx.clone()).await; + // Datagrams are best-effort, so one that cannot be sent is dropped + // rather than retried. It is still worth saying so once: a payload + // that never fits looks exactly like a peer that has gone quiet, + // which is a miserable thing to debug + Outbound::Datagram(payload) => { + let size = payload.len(); + + match connection.send_datagram(payload) { + Ok(()) => {} + Err(SendDatagramError::NotConnected) => break, + Err(e) if warned_undeliverable => { + let _ = e; + } + Err(SendDatagramError::TooLarge) => { + warned_undeliverable = true; + warn_global!( + logging_tx, + "Dropping datagrams: {size} bytes exceeds the {} the path allows. Send these over a stream instead", + connection.max_datagram_size().unwrap_or_default() + ); + } + Err(SendDatagramError::UnsupportedByPeer) => { + warned_undeliverable = true; + warn_global!( + logging_tx, + "Dropping datagrams: the client does not accept them" + ); + } + } + } - if message_result.is_break() { - // We don't break here, to avoid double closing the connection - return; + Outbound::Close => { + connection.close(CLOSED_BY_SERVER, b"Closed by server"); + break; + } + } } - } + }); +} - let _ = handle_client_close(client_id, &state, logging_tx).await; +/// Reads datagrams for the lifetime of the session. +/// +/// Datagrams arrive outside of any stream, so they need a reader of their own. +fn spawn_datagram_reader( + connection: Connection, + state: Arc, + client_id: ClientId, + logging_tx: LogSender, +) { + tokio::spawn(async move { + while let Ok(datagram) = connection.receive_datagram().await { + handle_client_message( + datagram.payload(), + Delivery::Datagram, + &state, + client_id, + &logging_tx, + ) + .await; + } + }); } /// Waits for and handles messages from the client that are intended for the server. @@ -116,41 +210,30 @@ pub async fn handle_connection( /// The function then returns the generated `ClientId` /// /// If any message is unable to be parse, the function returns `None`. -async fn wait_for_initialization( - state: Arc, - read: &mut SplitStream>, - write: &mut SplitSink, Message>, +async fn wait_for_initialization( + state: &Arc, + connection: &Connection, tx: ClientSender, addr: SocketAddr, - logging_tx: LogSender, -) -> Option -where - S: SynthesisStream, -{ + logging_tx: &LogSender, +) -> Option { loop { - match parse_first_message(read, addr, logging_tx.clone()).await { + match parse_first_message(connection, addr, logging_tx).await { Some(ClientToServerMessage::RequestRooms) => { - handle_room_list_request(state.clone(), write).await; + handle_room_list_request(state, connection).await; } // When they ask to initialize a connection, then we add them to a room // Or create a room for them Some(ClientToServerMessage::InitializeConnection { room_id, name }) => { - let (client_id, room_id) = { - // The lock is relinquished at the end of this expression - let info = state.initialize_client_in_room(tx, room_id, &name); - match info { - Some(info) => info, - None => return None, - } - }; + let (client_id, room_id) = state.initialize_client_in_room(tx, room_id, &name)?; let message = server_sent_msg(ServerToClientMessage::SendInfo { room_id, client_id: client_id.to_string(), }); - if write.send(message).await.is_err() { + if write_message(connection, &message).await.is_err() { error_global!(logging_tx, "Failed to send back initial response"); return None; @@ -170,23 +253,32 @@ where } } -async fn parse_first_message( - read: &mut SplitStream>>, +async fn parse_first_message( + connection: &Connection, addr: SocketAddr, - logging_tx: LogSender, -) -> Option -where - S: SynthesisStream, -{ + logging_tx: &LogSender, +) -> Option { // Parse initial message, then user in correct room - let Some(Ok(Message::Binary(message_data))) = read.next().await else { - warn_global!( - logging_tx, - "Client disconnected before handshake (probably a test)" - ); - return None; + let message_data = match timeout(TIMEOUT, accept_message(connection)).await { + Ok(Ok(message_data)) => message_data, + Ok(Err(e)) => { + warn_global!(logging_tx, "{addr} disconnected before handshake: {e}"); + return None; + } + Err(_) => { + warn_global!( + logging_tx, + "{addr} did not complete a message before the handshake timed out. A client must finish each stream it writes, since that is what ends the message" + ); + return None; + } }; + if message_data.is_empty() { + error_global!(logging_tx, "{addr} sent an empty initial message"); + return None; + } + let Ok(message) = deserialize_messagepack::(&message_data[1..]) else { error_global!(logging_tx, "{addr} sent an invalid initial message"); return None; @@ -195,53 +287,74 @@ where Some(message) } -async fn handle_room_list_request( - state: Arc, - write: &mut SplitSink>, Message>, -) where - S: SynthesisStream, -{ +async fn handle_room_list_request(state: &Arc, connection: &Connection) { let message = server_sent_msg(ServerToClientMessage::RoomList { rooms: state.list_rooms(), }); - write.send(message).await.ok(); + let _ = write_message(connection, &message).await; } +/// Routes one message from a client. +/// +/// Messages carrying [`MessagePrefix::Server`] are for the server to answer; +/// everything else is forwarded verbatim to the client's roommates over the same +/// kind of channel it arrived on. async fn handle_client_message( - message: Message, - state: Arc, + payload: Bytes, + delivery: Delivery, + state: &Arc, client_id: ClientId, - logging_tx: LogSender, -) -> ops::ControlFlow<(), ()> { - match message { - Message::Binary(ref bytes) => { - if bytes.len() <= 1 { - return ops::ControlFlow::Continue(()); - } + logging_tx: &LogSender, +) { + // A prefix byte on its own carries nothing + if payload.len() <= 1 { + warn_global!( + logging_tx, + "Discarding {} byte message from {}", + payload.len(), + trim_uuid(&client_id) + ); + return; + } - if bytes[0] == MessagePrefix::Server as u8 { - handle_client_ping(bytes, &client_id, &state, logging_tx).await; - return ops::ControlFlow::Continue(()); - } + if payload[0] == MessagePrefix::Server as u8 { + if delivery == Delivery::Datagram { + // Answering still works, but the client should not be risking a + // dropped client-server message in the first place + warn_global!( + logging_tx, + "{} sent a client-server message as a datagram", + trim_uuid(&client_id) + ); + } - // If we're here, that means the message has a client-client prefix - // which we want anyway, so there's no need to prefix the message - // we can just forward it! - let senders: Vec = { state.get_senders_from_user_room(client_id) }; + handle_client_ping(&payload, &client_id, state, logging_tx.clone()).await; + return; + } - let tasks = senders.iter().map(|tx| tx.send(message.clone())); + // If we're here, that means the message has a client-client prefix + // which we want anyway, so there's no need to prefix the message + // we can just forward it! + let senders: Vec = { state.get_senders_from_user_room(client_id) }; + + match delivery { + // Guaranteed traffic waits for room in each peer's queue + Delivery::Stream => { + let tasks = senders + .iter() + .map(|tx| tx.send(delivery.queue(payload.clone()))); let _ = futures_util::future::join_all(tasks).await; - - ops::ControlFlow::Continue(()) } - Message::Close(_) => { - let _ = handle_client_close(client_id, &state, logging_tx).await; - - ops::ControlFlow::Break(()) + // Unreliable traffic is dropped instead of queued: one peer that cannot + // keep up must not stall every other peer's updates, and a stale physics + // update is worth less than the one behind it + Delivery::Datagram => { + for tx in &senders { + let _ = tx.try_send(delivery.queue(payload.clone())); + } } - _ => ops::ControlFlow::Continue(()), } } @@ -284,7 +397,7 @@ async fn handle_client_ping( tx }; - let _ = tx.send(message).await; + let _ = tx.send(Outbound::Stream(message)).await; } async fn handle_client_close( @@ -312,7 +425,9 @@ async fn handle_client_close( state.remove_client(&client_id); - let tasks = senders.iter().map(|tx| tx.send(message.clone())); + let tasks = senders + .iter() + .map(|tx| tx.send(Outbound::Stream(message.clone()))); let _ = futures_util::future::join_all(tasks).await; Ok(()) diff --git a/glueball/src/model.rs b/glueball/src/model.rs index f464146f5f..d6a5cb755d 100644 --- a/glueball/src/model.rs +++ b/glueball/src/model.rs @@ -17,6 +17,36 @@ pub enum ClientToServerMessage { }, } +/// Answer to `GET /cert`, carrying what a client needs to pin this server's +/// certificate with `serverCertificateHashes` when connecting. +/// +/// A browser will not offer to trust a self-signed certificate for +/// `WebTransport` the way it does for HTTPS, so pinning the digest is the only +/// way a self-signed server is reachable at all. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ts_rs::TS)] +#[ts(export)] +pub struct CertificateHashes { + pub hashes: Vec, +} + +/// One digest, shaped like the `WebTransportHash` dictionary a browser expects. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ts_rs::TS)] +#[ts(export)] +pub struct CertificateHash { + pub algorithm: String, + /// The raw digest bytes, ready to be handed to `new Uint8Array(value)` + pub value: Vec, +} + +impl CertificateHash { + pub fn sha256(digest: &[u8; 32]) -> Self { + Self { + algorithm: "sha-256".to_string(), + value: digest.to_vec(), + } + } +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ts_rs::TS)] #[ts(export)] pub struct RoomInfo { diff --git a/glueball/src/prefixed.rs b/glueball/src/prefixed.rs deleted file mode 100644 index ceccc6f69d..0000000000 --- a/glueball/src/prefixed.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::EventType; -use crate::logging::{LogDestination, LogSender}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; - -use std::net::SocketAddr; -use std::task::{Context, Poll}; -use std::{io::Cursor, pin::Pin}; - -/// Replays a buffer of already-read ("peeked") bytes before continuing to read -/// from the underlying stream. Writes pass straight through. Used to "un-read" -/// bytes we inspected so `accept_async` still sees the full request. -pub struct Prefixed { - prefix: Cursor>, - inner: S, -} - -impl Prefixed { - pub const fn new(prefix: Vec, inner: S) -> Self { - Self { - prefix: Cursor::new(prefix), - inner, - } - } -} - -impl AsyncRead for Prefixed { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - #[allow(clippy::expect_used)] - let pos = usize::try_from(self.prefix.position()).expect("32-bit machines not supported"); - let data = self.prefix.get_ref(); - - if pos < data.len() { - let n = (data.len() - pos).min(buf.remaining()); - buf.put_slice(&data[pos..pos + n]); - self.prefix.set_position((pos + n) as u64); - return Poll::Ready(Ok(())); - } - - Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -impl AsyncWrite for Prefixed { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -pub trait SynthesisStream: AsyncRead + AsyncWrite + Unpin + Send + 'static {} -impl SynthesisStream for S {} - -pub enum ConnectionStatus { - HungUp, - Error, - Http, - Ws(Prefixed), -} - -pub async fn into_prefixed_or_respond( - mut raw_stream: S, - addr: SocketAddr, - logging_tx: LogSender, -) -> ConnectionStatus -where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - // "Peek" at the request: read the first chunk, then replay it in front of - // the stream. `peek()` is an inherent method on `TcpStream` (not a trait), - // so it can't be called generically or on a `TlsStream` — this replays the - // bytes instead, letting us both inspect the request and recover the stream. - let mut buf = vec![0u8; 1024]; - let n = match raw_stream.read(&mut buf).await { - Ok(0) => return ConnectionStatus::HungUp, - Ok(n) => n, - Err(e) => { - error_global!(logging_tx, "Failed to read from {addr}: {e}"); - return ConnectionStatus::Error; - } - }; - buf.truncate(n); - - let message = String::from_utf8_lossy(&buf).to_ascii_lowercase(); - let mut stream = Prefixed::new(buf, raw_stream); - - // Respond to plain HTTP requests properly, rather than failing the handshake. - let is_ws = message.contains("upgrade: websocket"); - if !is_ws { - let resp = if message.starts_with("get /cert ") { - let body = "You may now close this page."; - format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - } else { - let body = "Synthesis"; - format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len(), - ) - }; - - let _ = stream.write_all(resp.as_bytes()).await; - let _ = stream.flush().await; - return ConnectionStatus::Http; - } - - ConnectionStatus::Ws(stream) -} diff --git a/glueball/src/state.rs b/glueball/src/state.rs index cf4986b6c4..e1ed009943 100644 --- a/glueball/src/state.rs +++ b/glueball/src/state.rs @@ -1,12 +1,12 @@ use crate::logging::{EventType, LogDestination, LogSender}; use crate::model::RoomInfo; +use crate::wire::Outbound; use anyhow::{Result, bail}; use dashmap::DashMap; use dashmap::mapref::one::{Ref, RefMut}; use rand::RngExt; use tokio::sync::mpsc::{self}; -use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; /// Maximum number of log lines retained in each log (both per-room and system logs) @@ -282,7 +282,7 @@ fn generate_6_digit_code() -> String { pub type ClientId = Uuid; pub type ClientMap = DashMap; -pub type ClientSender = mpsc::Sender; +pub type ClientSender = mpsc::Sender; pub type RoomId = String; pub type RoomMap = DashMap; @@ -485,7 +485,7 @@ mod tests { let client_id = state .add_client_to_room("Alice", client_tx(), &room_id) .unwrap(); - state.remove_client(client_id); + state.remove_client(&client_id); assert_eq!(state.room_count(), 1); } diff --git a/glueball/src/tests.rs b/glueball/src/tests.rs index 21d98ad494..7d7cfdfd3b 100644 --- a/glueball/src/tests.rs +++ b/glueball/src/tests.rs @@ -1,104 +1,153 @@ -/// The purpose of these tests is to test the `handle_connection` function -/// In the future, more shared functionality (such as spawning an insecure server) extracted +/// The purpose of these tests is to test the `handle_session` function +/// In the future, more shared functionality (such as spawning a server) extracted use std::sync::Arc; -use futures_util::{SinkExt, StreamExt}; -use tokio::net::TcpListener; +use bytes::Bytes; use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; +use wtransport::{ClientConfig, Connection, Endpoint, Identity, ServerConfig, VarInt}; +use crate::kick::{UserAction, setup_user_action_system}; use crate::logging::LogSender; -use crate::messaging::handle_connection; +use crate::messaging::handle_session; use crate::model::{ClientToServerMessage, MessagePrefix, ServerToClientMessage}; use crate::state::State; use crate::util::{deserialize_messagepack, serialize_and_prefix}; +use crate::wire::{read_message, write_message}; const RECV_TIMEOUT: Duration = Duration::from_secs(3); -/// Binds a server on a random port and returns the `ws://` URL. -async fn spawn_server_insecure() -> String { +/// Binds a server on a random port and returns its URL alongside the SHA-256 +/// digest of its certificate, which clients pin instead of validating a chain. +async fn spawn_server() -> (String, wtransport::tls::Sha256Digest) { + let (url, hash, _state) = spawn_server_with_state().await; + (url, hash) +} + +/// As [`spawn_server`], but also hands back the server's state so a test can act +/// on it the way the TUI does. +async fn spawn_server_with_state() -> (String, wtransport::tls::Sha256Digest, Arc) { let (log_tx, _log_rx): (LogSender, _) = mpsc::channel(128); let state = Arc::new(State::new(log_tx.clone())); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); + let identity = Identity::self_signed(["localhost", "127.0.0.1", "::1"]).unwrap(); + let hash = identity.certificate_chain().as_slice()[0].hash(); + + let config = ServerConfig::builder() + .with_bind_default(0) + .with_identity(identity) + .build(); + + let endpoint = Endpoint::server(config).unwrap(); + let port = endpoint.local_addr().unwrap().port(); + + let accept_state = state.clone(); tokio::spawn(async move { - while let Ok((stream, addr)) = listener.accept().await { - tokio::spawn(handle_connection( - state.clone(), - stream, - addr, + loop { + let session = endpoint.accept().await; + tokio::spawn(handle_session( + accept_state.clone(), + session, log_tx.clone(), )); } }); - format!("ws://127.0.0.1:{port}") + (format!("https://127.0.0.1:{port}"), hash, state) } -fn create_msg_to_server(msg: ClientToServerMessage) -> Message { - serialize_and_prefix(msg, MessagePrefix::Server) +/// A `WebTransport` client speaking the same wire protocol as the browser client: +/// one unidirectional stream per message, plus datagrams. +struct TestClient { + /// Held so the endpoint outlives the connection it created + _endpoint: Endpoint, + connection: Connection, } -fn parse_msg_to_server(msg: Message) -> ServerToClientMessage { - let Message::Binary(bytes) = msg else { - panic!("Expected binary message from server"); - }; - deserialize_messagepack::(&bytes[1..]).unwrap() -} +impl TestClient { + async fn connect(url: &str, hash: wtransport::tls::Sha256Digest) -> Self { + let config = ClientConfig::builder() + .with_bind_default() + .with_server_certificate_hashes([hash]) + .build(); -/// Receive and parse a message from the server over a websocket connections -async fn recv( - ws: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, -) -> ServerToClientMessage { - let msg = timeout(RECV_TIMEOUT, ws.next()) - .await - .expect("Timed out") - .expect("Stream ended") - .expect("WS error"); - parse_msg_to_server(msg) + let endpoint = Endpoint::client(config).unwrap(); + let connection = endpoint.connect(url).await.unwrap(); + + Self { + _endpoint: endpoint, + connection, + } + } + + async fn send_server(&self, message: ClientToServerMessage) { + let payload = serialize_and_prefix(message, MessagePrefix::Server); + write_message(&self.connection, &payload).await.unwrap(); + } + + async fn send_peer_stream(&self, payload: &[u8]) { + write_message(&self.connection, payload).await.unwrap(); + } + + fn send_peer_datagram(&self, payload: &[u8]) { + self.connection.send_datagram(payload).unwrap(); + } + + /// Receives the next message the server sends on a stream + async fn recv_stream(&self) -> Bytes { + let read = timeout(RECV_TIMEOUT, self.connection.accept_uni()) + .await + .expect("Timed out") + .expect("Session ended"); + + read_message(read).await.expect("Stream error") + } + + async fn recv_server(&self) -> ServerToClientMessage { + let payload = self.recv_stream().await; + deserialize_messagepack::(&payload[1..]).unwrap() + } + + async fn recv_datagram(&self) -> Bytes { + timeout(RECV_TIMEOUT, self.connection.receive_datagram()) + .await + .expect("Timed out") + .expect("Session ended") + .payload() + } } -/// Connect and initialize, returning the WebSocket and the assigned room/client IDs. +/// Connect and initialize, returning the client and the assigned room/client IDs. async fn connect_and_init( url: &str, + hash: wtransport::tls::Sha256Digest, name: &str, room_id: Option, -) -> ( - tokio_tungstenite::WebSocketStream>, - String, - String, -) { - let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(create_msg_to_server( - ClientToServerMessage::InitializeConnection { +) -> (TestClient, String, String) { + let client = TestClient::connect(url, hash).await; + + client + .send_server(ClientToServerMessage::InitializeConnection { room_id, name: name.to_string(), - }, - )) - .await - .unwrap(); + }) + .await; - let ServerToClientMessage::SendInfo { room_id, client_id } = recv(&mut ws).await else { + let ServerToClientMessage::SendInfo { room_id, client_id } = client.recv_server().await else { panic!("Expected SendInfo"); }; - (ws, room_id, client_id) + (client, room_id, client_id) } #[tokio::test] async fn request_rooms_returns_empty_list() { - let url = spawn_server_insecure().await; - let (mut ws, _) = connect_async(&url).await.unwrap(); + let (url, hash) = spawn_server().await; + let client = TestClient::connect(&url, hash).await; - ws.send(create_msg_to_server(ClientToServerMessage::RequestRooms)) - .await - .unwrap(); + client.send_server(ClientToServerMessage::RequestRooms).await; - let ServerToClientMessage::RoomList { rooms } = recv(&mut ws).await else { + let ServerToClientMessage::RoomList { rooms } = client.recv_server().await else { panic!("Expected RoomList"); }; assert!(rooms.is_empty()); @@ -106,82 +155,117 @@ async fn request_rooms_returns_empty_list() { #[tokio::test] async fn initialize_creates_room_and_returns_send_info() { - let url = spawn_server_insecure().await; - let (mut ws, _) = connect_async(&url).await.unwrap(); + let (url, hash) = spawn_server().await; + let client = TestClient::connect(&url, hash).await; - ws.send(create_msg_to_server( - ClientToServerMessage::InitializeConnection { + client + .send_server(ClientToServerMessage::InitializeConnection { room_id: None, name: "Alice".to_string(), - }, - )) - .await - .unwrap(); + }) + .await; assert!(matches!( - recv(&mut ws).await, + client.recv_server().await, ServerToClientMessage::SendInfo { .. } )); } #[tokio::test] async fn second_client_joins_existing_room() { - let url = spawn_server_insecure().await; - let (_ws_a, alices_room, _) = connect_and_init(&url, "Alice", None).await; - let (_, bobs_room, _) = connect_and_init(&url, "Bob", Some(alices_room.clone())).await; + let (url, hash) = spawn_server().await; + let (_alice, alices_room, _) = connect_and_init(&url, hash.clone(), "Alice", None).await; + let (_, bobs_room, _) = connect_and_init(&url, hash, "Bob", Some(alices_room.clone())).await; assert_eq!(bobs_room, alices_room); } #[tokio::test] -async fn messages_forwarded_to_peers_in_room() { - let url = spawn_server_insecure().await; +async fn stream_messages_forwarded_to_peers_in_room() { + let (url, hash) = spawn_server().await; - let (mut ws_a, alices_room, _) = connect_and_init(&url, "Alice", None).await; - let (mut ws_b, _, _) = connect_and_init(&url, "Bob", Some(alices_room)).await; + let (alice, alices_room, _) = connect_and_init(&url, hash.clone(), "Alice", None).await; + let (bob, _, _) = connect_and_init(&url, hash, "Bob", Some(alices_room)).await; // Alice sends a peer-to-peer message (CLIENT_PREFIX byte + arbitrary payload) let payload: Vec = vec![MessagePrefix::Client as u8, 0xDA, 0x15, 0x7]; - ws_a.send(Message::Binary(payload.clone().into())) - .await - .unwrap(); + alice.send_peer_stream(&payload).await; - // Bob receives it unchanged - let received = timeout(RECV_TIMEOUT, ws_b.next()) - .await - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(received, Message::Binary(payload.into())); + // Bob receives it unchanged, and on a stream rather than as a datagram + assert_eq!(bob.recv_stream().await, Bytes::from(payload)); +} + +#[tokio::test] +async fn datagram_messages_forwarded_to_peers_as_datagrams() { + let (url, hash) = spawn_server().await; + + let (alice, alices_room, _) = connect_and_init(&url, hash.clone(), "Alice", None).await; + let (bob, _, _) = connect_and_init(&url, hash, "Bob", Some(alices_room)).await; + + let payload: Vec = vec![MessagePrefix::Client as u8, 0xDA, 0x15, 0x7]; + + // Datagrams are unreliable, so keep resending until one lands + let received = loop { + alice.send_peer_datagram(&payload); + + if let Ok(datagram) = timeout(Duration::from_millis(200), bob.recv_datagram()).await { + break datagram; + } + }; + + assert_eq!(received, Bytes::from(payload)); } #[tokio::test] async fn ping_returns_pong_with_matching_timestamp() { - let url = spawn_server_insecure().await; - let (mut ws, _, _) = connect_and_init(&url, "Alice", None).await; + let (url, hash) = spawn_server().await; + let (client, _, _) = connect_and_init(&url, hash, "Alice", None).await; let ts = 987_654_321_u64; - ws.send(create_msg_to_server(ClientToServerMessage::Ping { - timestamp: ts, - })) - .await - .unwrap(); + client + .send_server(ClientToServerMessage::Ping { timestamp: ts }) + .await; - let ServerToClientMessage::Pong { client_send_ts, .. } = recv(&mut ws).await else { + let ServerToClientMessage::Pong { client_send_ts, .. } = client.recv_server().await else { panic!("Expected Pong"); }; assert_eq!(client_send_ts, ts); } +#[tokio::test] +async fn kicked_client_session_is_closed() { + let (url, hash, state) = spawn_server_with_state().await; + let user_action_tx = setup_user_action_system(&state); + + let (alice, alices_room, alice_id) = connect_and_init(&url, hash.clone(), "Alice", None).await; + let (bob, _, _) = connect_and_init(&url, hash, "Bob", Some(alices_room)).await; + + let mut user_action_tx = user_action_tx; + user_action_tx + .push(UserAction::Kick(alice_id.parse().unwrap())) + .unwrap(); + + // Bob is told Alice is gone + let ServerToClientMessage::Kick { client_id } = bob.recv_server().await else { + panic!("Expected Kick"); + }; + assert_eq!(client_id, alice_id); + + // And Alice's whole session is torn down, not just her streams + timeout(RECV_TIMEOUT, alice.connection.closed()) + .await + .expect("Kicked client's session was never closed"); +} + #[tokio::test] async fn client_disconnect_notifies_peers() { - let url = spawn_server_insecure().await; + let (url, hash) = spawn_server().await; - let (mut ws_a, alices_room, alice_id) = connect_and_init(&url, "Alice", None).await; - let (mut ws_b, _, _) = connect_and_init(&url, "Bob", Some(alices_room)).await; + let (alice, alices_room, alice_id) = connect_and_init(&url, hash.clone(), "Alice", None).await; + let (bob, _, _) = connect_and_init(&url, hash, "Bob", Some(alices_room)).await; - ws_a.close(None).await.unwrap(); + alice.connection.close(VarInt::from_u32(0), b"Goodbye"); - let ServerToClientMessage::Kick { client_id } = recv(&mut ws_b).await else { + let ServerToClientMessage::Kick { client_id } = bob.recv_server().await else { panic!("Expected Kick"); }; assert_eq!(client_id, alice_id); diff --git a/glueball/src/util.rs b/glueball/src/util.rs index 0fdeca9f9b..cd985b35ab 100644 --- a/glueball/src/util.rs +++ b/glueball/src/util.rs @@ -1,8 +1,8 @@ use std::{env::home_dir, net::UdpSocket, ops::Deref, path::PathBuf}; use anyhow::{Result, bail}; +use bytes::Bytes; use serde::{Deserialize, Serialize}; -use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; use crate::model::{MessagePrefix, ServerToClientMessage}; @@ -43,11 +43,11 @@ pub fn tilde_expansion(path: &mut PathBuf) -> Result<()> { Ok(()) } -pub fn server_sent_msg(message: ServerToClientMessage) -> Message { +pub fn server_sent_msg(message: ServerToClientMessage) -> Bytes { serialize_and_prefix(message, MessagePrefix::Server) } -pub fn serialize_and_prefix(message: M, prefix: MessagePrefix) -> Message +pub fn serialize_and_prefix(message: M, prefix: MessagePrefix) -> Bytes where M: Serialize, { @@ -55,9 +55,9 @@ where prefix_message(bytes, prefix) } -/// Creates a new `Message::Binary` containing `bytes`, +/// Creates a new payload containing `bytes`, /// prefixed with the byte value of `MessagePrefix` -fn prefix_message(bytes: M, prefix: MessagePrefix) -> Message +fn prefix_message(bytes: M, prefix: MessagePrefix) -> Bytes where M: Deref, { @@ -65,7 +65,7 @@ where buf[1..].copy_from_slice(&bytes); buf[0] = prefix as u8; - Message::Binary(buf.into()) + buf.into() } fn serialize_messagepack(message: M) -> Vec diff --git a/glueball/src/wire.rs b/glueball/src/wire.rs new file mode 100644 index 0000000000..e8cd30f647 --- /dev/null +++ b/glueball/src/wire.rs @@ -0,0 +1,89 @@ +//! Message delivery for the `WebTransport` wire protocol. +//! +//! A message on the wire is unchanged from the `WebSocket` protocol: a single +//! [`MessagePrefix`](crate::model::MessagePrefix) byte followed by a +//! `MessagePack` body. What changes is how one message is delimited from the +//! next. +//! +//! * Every message travelling over a stream gets its own unidirectional stream. +//! The sender writes the payload and finishes the stream; that finish *is* the +//! message boundary, so no length prefix is needed. Replies go out as their own +//! stream rather than back down the one that carried the request. +//! * Datagrams already arrive as whole messages, so they are sent as-is. +//! +//! Streams are independent, so ordering is only guaranteed within a message, +//! never between two of them. + +use anyhow::{Result, bail}; +use bytes::Bytes; +use wtransport::{Connection, RecvStream}; + +/// Size of the buffer a stream is drained into. +const READ_CHUNK_SIZE: usize = 8 * 1024; + +/// Largest message we are willing to read off a stream. A peer that writes more +/// than this without finishing is either broken or hostile, so we give up rather +/// than buffer for it. +const MAX_MESSAGE_SIZE: usize = 4 * 1024 * 1024; + +/// How a message travelled, and therefore how anything derived from it should +/// travel back out. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Delivery { + /// On a stream of its own: ordered and guaranteed. + Stream, + /// As a datagram: unordered, and dropped rather than retransmitted. + Datagram, +} + +impl Delivery { + /// Queues `payload` for delivery over this same kind of channel. + pub const fn queue(self, payload: Bytes) -> Outbound { + match self { + Self::Stream => Outbound::Stream(payload), + Self::Datagram => Outbound::Datagram(payload), + } + } +} + +/// A payload queued for delivery to a single client. +#[derive(Clone)] +pub enum Outbound { + /// Send to the client on a stream of its own. + Stream(Bytes), + /// Send to the client as a datagram. + Datagram(Bytes), + /// Terminate the client's session. + Close, +} + +/// Reads a whole message: everything the peer wrote to `read` before finishing +/// the stream. +pub async fn read_message(mut read: RecvStream) -> Result { + let mut payload = Vec::new(); + let mut chunk = [0u8; READ_CHUNK_SIZE]; + + // A `None` read is the peer finishing the stream, which ends the message + while let Some(count) = read.read(&mut chunk).await? { + if payload.len() + count > MAX_MESSAGE_SIZE { + bail!("message exceeds the {MAX_MESSAGE_SIZE} byte limit"); + } + + payload.extend_from_slice(&chunk[..count]); + } + + Ok(payload.into()) +} + +/// Sends `payload` to the client as a message of its own. +/// +/// The stream is finished before returning, because an unfinished stream leaves +/// the client waiting for a boundary that never arrives. +pub async fn write_message(connection: &Connection, payload: &[u8]) -> Result<()> { + let mut write = connection.open_uni().await?.await?; + + write.write_all(payload).await?; + write.finish().await?; + + Ok(()) +} From 58fe4234c8c4ca8d8d1625553163bf0392715e03 Mon Sep 17 00:00:00 2001 From: Zach Rutman Date: Tue, 11 Aug 2026 00:50:00 -0700 Subject: [PATCH 3/6] feat: make updates more frequent --- fission/src/systems/physics/PhysicsSystem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fission/src/systems/physics/PhysicsSystem.ts b/fission/src/systems/physics/PhysicsSystem.ts index b0fdda470b..5911d8e0e4 100644 --- a/fission/src/systems/physics/PhysicsSystem.ts +++ b/fission/src/systems/physics/PhysicsSystem.ts @@ -1650,7 +1650,7 @@ class PhysicsSystem extends WorldSystem { } } - World.multiplayerSystem.sinceLastUpdate = (World.multiplayerSystem.sinceLastUpdate + 1) % 5 + World.multiplayerSystem.sinceLastUpdate = (World.multiplayerSystem.sinceLastUpdate + 1) % 2 } this._physicsEventQueue.forEach(x => { From ae51eac88d5685c7478760fe89dae4aa9a326438 Mon Sep 17 00:00:00 2001 From: Zach Rutman Date: Tue, 11 Aug 2026 10:07:06 -0700 Subject: [PATCH 4/6] temp: run workflow --- .github/workflows/GlueballBuild.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/GlueballBuild.yml b/.github/workflows/GlueballBuild.yml index f04f3481ef..d334583e6b 100644 --- a/.github/workflows/GlueballBuild.yml +++ b/.github/workflows/GlueballBuild.yml @@ -4,6 +4,7 @@ on: types: - created workflow_dispatch: {} + push: permissions: id-token: write From f981b8109ce2838c0f8e50d8e51d353c3494c3e2 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Tue, 11 Aug 2026 10:55:57 -0700 Subject: [PATCH 5/6] refactor(glueball): start fixing claude's mess --- glueball/src/cert.rs | 16 ++ glueball/src/config.rs | 30 ++- glueball/src/connection.rs | 234 +++++++++++++++++++++ glueball/src/http.rs | 30 +-- glueball/src/kick.rs | 7 +- glueball/src/logging.rs | 50 +++-- glueball/src/main.rs | 159 ++++++--------- glueball/src/messaging.rs | 405 +++++++++---------------------------- glueball/src/state.rs | 131 ++++++------ glueball/src/tui.rs | 9 +- glueball/src/wire.rs | 20 -- 11 files changed, 530 insertions(+), 561 deletions(-) create mode 100644 glueball/src/connection.rs diff --git a/glueball/src/cert.rs b/glueball/src/cert.rs index edd6ddcfc0..c4596b7ad1 100644 --- a/glueball/src/cert.rs +++ b/glueball/src/cert.rs @@ -3,6 +3,8 @@ use std::{fs, path::Path}; use anyhow::Result; use wtransport::Identity; +use crate::model::{CertificateHash, CertificateHashes}; + /// `WebTransport` runs over QUIC, so the server always needs a TLS identity. /// Loads the certificate and key from `cert_directory`, generating a self-signed /// pair there if none is present. @@ -47,3 +49,17 @@ async fn ensure_certificate(path: &Path) -> Result<()> { Ok(()) } + +/// Browsers will not offer to trust a self-signed WebTransport certificate the +/// way they do for HTTPS, so clients pin these digests with +/// `serverCertificateHashes` instead. They are served over `GET /cert` +pub fn build_hashes(identity: &Identity) -> CertificateHashes { + CertificateHashes { + hashes: identity + .certificate_chain() + .as_slice() + .iter() + .map(|certificate| CertificateHash::sha256(certificate.hash().as_ref())) + .collect(), + } +} diff --git a/glueball/src/config.rs b/glueball/src/config.rs index 0617d3eefe..3cf87ae2d9 100644 --- a/glueball/src/config.rs +++ b/glueball/src/config.rs @@ -1,17 +1,22 @@ use std::{ fs::read_to_string, path::{Path, PathBuf}, + time::Duration, }; use anyhow::{Result, bail}; use argh::FromArgs; use directories::ProjectDirs; use toml::{Table, Value}; +use wtransport::{Identity, ServerConfig}; use crate::util::tilde_expansion; pub const DEFAULT_PORT: u16 = 2610; +/// How often to poke an otherwise idle connection so QUIC doesn't time it out. +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); + pub fn certification_directory() -> Result { let dir = match ProjectDirs::from("com", "Autodesk", "synthesis-glueball") { Some(dirs) => dirs.data_dir().join("secrets"), @@ -32,7 +37,7 @@ pub struct CliConfig { #[argh( option, - description = "directory in which to store certificates and key files (only for --secure mode)" + description = "directory in which to store certificates and key files" )] pub cert_dir: Option, @@ -48,13 +53,6 @@ pub struct CliConfig { )] pub headless: bool, - #[argh( - switch, - short = 's', - description = "encrypt websocket traffic using TLS. self-signed PEM certificates will be automatically generated" - )] - pub secure: bool, - #[argh( option, short = 'r', @@ -95,12 +93,6 @@ where old_config.headless = *headless; } - if let Some(Value::Boolean(secure)) = &table.get("secure") - && !old_config.secure - { - old_config.secure = *secure; - } - if let Some(Value::String(room_id)) = &table.get("permanent_room") && old_config.permanent_room.is_none() { @@ -115,7 +107,6 @@ pub struct AppConfig { pub permanent_room: Option, pub port: u16, pub headless: bool, - pub secure: bool, } fn config_or_default(config: CliConfig) -> Result { @@ -132,7 +123,6 @@ fn config_or_default(config: CliConfig) -> Result { permanent_room: config.permanent_room, port, headless: config.headless, - secure: config.secure, }) } @@ -145,3 +135,11 @@ pub fn retrieve_config() -> Result { config_or_default(config) } + +pub fn build_server_config(port: u16, identity: Identity) -> ServerConfig { + ServerConfig::builder() + .with_bind_default(port) + .with_identity(identity) + .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) + .build() +} diff --git a/glueball/src/connection.rs b/glueball/src/connection.rs new file mode 100644 index 0000000000..1ee466d7dd --- /dev/null +++ b/glueball/src/connection.rs @@ -0,0 +1,234 @@ +//! Module for handling all connections with a client +//! Heavily utilizes methods from the `messaging` module + +use anyhow::{Result, bail}; +use futures_util::never::Never; +use std::net::SocketAddr; +use std::{sync::Arc, time::Duration}; +use tokio::sync::mpsc::Receiver; +use tokio::{ + sync::mpsc::{self}, + time::timeout, +}; +use wtransport::error::SendDatagramError; +use wtransport::{Connection, Endpoint, Identity, endpoint::IncomingSession}; + +use crate::messaging::{ + CLOSED_BY_SERVER, accept_message, handle_client_close, handle_client_message_datagram, + handle_client_message_stream, handle_first_message, handle_room_list_request, +}; +use crate::model::{ClientToServerMessage, ServerToClientMessage}; +use crate::state::{ClientId, ClientSender}; +use crate::util::{server_sent_msg, trim_uuid}; +use crate::wire::write_message; +use crate::{ + config::build_server_config, error_global, info_global, state::State, util::get_local_ip, + warn_global, wire::Outbound, +}; + +/// How long to wait for a client's next message before assuming it is gone. +/// Clients ping every five seconds, so a silent client is a dead one even while +/// its datagrams keep arriving. +pub const TIMEOUT: Duration = Duration::from_secs(30); + +/// Number of messages that may be queued for one client before writes to it +/// block (streams) or are dropped (datagrams). +const OUTBOUND_CAPACITY: usize = 64; + +pub async fn spawn_webtransport_responder( + state: Arc, + port: u16, + identity: Identity, +) -> Result { + let server_config = build_server_config(port, identity); + + let Ok(wt_endpoint) = Endpoint::server(server_config) else { + bail!("Could not create UDP listener (the port is likely in use)"); + }; + + let local_ip = get_local_ip().unwrap_or_else(|| String::from("0.0.0.0")); + + info_global!( + "Server hosted on {local_ip} listening at port {} (UDP), certificate at /cert (TCP)", + port + ); + + loop { + tokio::spawn(accept_incoming_session( + state.clone(), + wt_endpoint.accept().await, + )); + } +} + +/// Completes the `WebTransport` handshake for an incoming QUIC connection +/// Then hands the session to [`handle_connection`]. +async fn accept_incoming_session(state: Arc, session: IncomingSession) { + let addr = session.remote_address(); + + let request = match session.await { + Ok(request) => request, + Err(e) => { + error_global!("QUIC handshake with {addr} failed: {e}"); + return; + } + }; + + let connection = match request.accept().await { + Ok(connection) => connection, + Err(e) => { + error_global!("WebTransport handshake with {addr} failed: {e}"); + return; + } + }; + + handle_connection(state, connection, addr).await; +} + +async fn handle_connection(state: Arc, connection: Connection, addr: SocketAddr) { + info_global!("WT session established with {addr}"); + + // Each client gets an mpsc channel + // Other client threads on the server can write to it + // Everything written gets dumped back to its client by the writer task + let (tx, rx) = mpsc::channel::(OUTBOUND_CAPACITY); + + // Order of messages sent from a new client to the server: + // 1-n. Any number of `RequestRooms` messages -> server will return a list of rooms + // n..n+1. An `InitializationMessage`, indicating whether the client wishes to create or join a room -> server will return a room and client id + // n+1..m. Any number of messages that will be forwarded to every other client in their room -> server will not respond, instead forwarding + let Some(client_id) = wait_for_initialization(&state, &connection, tx.clone(), addr).await + else { + return; + }; + + spawn_client_sink(connection.clone(), rx); + spawn_datagram_reader(connection.clone(), state.clone(), client_id); + + // Listen for and pass along meskesages to other client channels in the same room + loop { + // Messages are taken one at a time rather than concurrently: streams give + // no ordering guarantees between each other, so draining them in arrival + // order is the closest thing to the ordering clients used to rely on + let message = match timeout(TIMEOUT, accept_message(&connection)).await { + Ok(Ok(message)) => message, + Ok(Err(e)) => { + warn_global!("{} disconnected: {e}", trim_uuid(&client_id)); + break; + } + Err(_) => { + warn_global!("{} timed out", trim_uuid(&client_id)); + break; + } + }; + + handle_client_message_stream(message, &state, &client_id).await; + } + + let _ = handle_client_close(client_id, &state).await; +} + +/// Waits for and handles messages from the client that are intended for the server. +/// +/// If the message is `ClientToServerMessage::RequestRooms`, +/// the function handles the request and keeps listening +/// +/// If the message is `ClientToServerMessage::InitializeConnection`, +/// the function handles the request by generating a client id, and putting +/// the client in the correct room. This may involve creating a new room depending on the request +/// The function then returns the generated `ClientId` +/// +/// If any message is unable to be parse, the function returns `None`. +async fn wait_for_initialization( + state: &Arc, + connection: &Connection, + tx: ClientSender, + addr: SocketAddr, +) -> Option { + loop { + match handle_first_message(connection, addr).await { + Some(ClientToServerMessage::RequestRooms) => { + handle_room_list_request(state, connection).await; + } + + // When they ask to initialize a connection + // we add them to that room or create one for them + Some(ClientToServerMessage::InitializeConnection { room_id, name }) => { + let (client_id, room_id) = state.initialize_client_in_room(tx, room_id, &name)?; + + let message = server_sent_msg(ServerToClientMessage::SendInfo { + room_id, + client_id: client_id.to_string(), + }); + + if write_message(connection, &message).await.is_err() { + error_global!("Failed to send back initial response"); + + return None; + } + + break Some(client_id); + } + + Some(ClientToServerMessage::Ping { timestamp: _ }) => { + error_global!("Received ping from client during initialization"); + return None; + } + None => return None, + } + } +} + +fn spawn_client_sink(connection: Connection, mut rx: Receiver) { + tokio::spawn(async move { + // Undeliverable datagrams come in floods rather than one at a time, so the + // reason is worth saying once and then never again for this session + let mut warned_undeliverable = false; + + while let Some(message) = rx.recv().await { + match message { + Outbound::Stream(payload) => { + if write_message(&connection, &payload).await.is_err() { + break; + } + } + + Outbound::Datagram(ref payload) => match connection.send_datagram(payload) { + Ok(()) => {} + Err(SendDatagramError::NotConnected) => break, + Err(e) if warned_undeliverable => { + let _ = e; + } + + Err(SendDatagramError::TooLarge) => { + warned_undeliverable = true; + warn_global!( + "Dropping datagrams: {} bytes exceeds the {} the path allows. Send these over a stream instead", + payload.len(), + connection.max_datagram_size().unwrap_or_default() + ); + } + + Err(SendDatagramError::UnsupportedByPeer) => { + warned_undeliverable = true; + warn_global!("Dropping datagrams: the client does not accept them"); + } + }, + + Outbound::Close => { + connection.close(CLOSED_BY_SERVER, b"Closed by server"); + break; + } + } + } + }); +} + +/// Reads datagrams, extracting their payload and sending them to [`handle_client_message`] +fn spawn_datagram_reader(connection: Connection, state: Arc, client_id: ClientId) { + tokio::spawn(async move { + while let Ok(datagram) = connection.receive_datagram().await { + handle_client_message_datagram(datagram.payload(), &state, &client_id).await; + } + }); +} diff --git a/glueball/src/http.rs b/glueball/src/http.rs index f4b3f5681f..f9fb68c4d1 100644 --- a/glueball/src/http.rs +++ b/glueball/src/http.rs @@ -14,8 +14,6 @@ //! circular. Browsers treat `localhost` and `127.0.0.1` as trustworthy origins, //! so mixed-content rules do not block this for the local servers it exists for. -use crate::EventType; -use crate::logging::{LogDestination, LogSender}; use crate::model::CertificateHashes; use anyhow::{Result, bail}; @@ -31,32 +29,27 @@ const MAX_REQUEST_SIZE: usize = 1024; /// These responses are read by a page served from a different origin, so they /// have to opt in to being read cross-origin. -const CORS_HEADERS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\n"; +const CORS_HEADERS: &str = + "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\n"; /// Binds the TCP half of `port` and starts answering HTTP requests on it. /// /// Binding happens before returning so a port conflict is reported to the caller /// rather than swallowed by a background task. -pub async fn spawn_http_responder( - port: u16, - hashes: CertificateHashes, - logging_tx: LogSender, -) -> Result<()> { +pub async fn spawn_http_responder(port: u16, hashes: CertificateHashes) -> Result<()> { let Ok(listener) = TcpListener::bind(format!("0.0.0.0:{port}")).await else { bail!("Could not create TCP listener (the port is likely in use)"); }; - // The digests never change, so the response body is built once and shared let certificate_response = Arc::new(json_response(&serde_json::to_string(&hashes)?)); tokio::spawn(async move { while let Ok((stream, addr)) = listener.accept().await { let certificate_response = certificate_response.clone(); - let logging_tx = logging_tx.clone(); // Each request gets a task so a slow client cannot hold up the others tokio::spawn(async move { - handle_request(stream, addr, &certificate_response, &logging_tx).await; + handle_request(stream, addr, &certificate_response).await; }); } }); @@ -69,25 +62,20 @@ pub async fn spawn_http_responder( /// Connections are never kept alive, so there is no need to find the end of the /// request head: the first read tells us the method and path, which is all that /// distinguishes the handful of responses we serve. -async fn handle_request( - mut stream: TcpStream, - addr: SocketAddr, - certificate_response: &str, - logging_tx: &LogSender, -) { - let mut buf = vec![0u8; MAX_REQUEST_SIZE]; +async fn handle_request(mut stream: TcpStream, addr: SocketAddr, certificate_response: &str) { + let mut buf = [0u8; MAX_REQUEST_SIZE]; let count = match stream.read(&mut buf).await { Ok(0) => return, Ok(count) => count, Err(e) => { - error_global!(logging_tx, "Failed to read from {addr}: {e}"); + error_global!("Failed to read from {addr}: {e}"); return; } }; - buf.truncate(count); - let request = String::from_utf8_lossy(&buf).to_ascii_lowercase(); + let truncated = &buf[0..count]; + let request = String::from_utf8_lossy(truncated).to_ascii_lowercase(); let response = if request.starts_with("get /cert ") { certificate_response.to_string() diff --git a/glueball/src/kick.rs b/glueball/src/kick.rs index 900f9b73f0..1efabfa4d5 100644 --- a/glueball/src/kick.rs +++ b/glueball/src/kick.rs @@ -1,5 +1,4 @@ use crate::{ - EventType, LogDestination, model::ServerToClientMessage, state::{ClientId, ClientSender, RoomId, RoomStatus, State}, util::server_sent_msg, @@ -50,7 +49,7 @@ async fn kick(state: Arc, client_id: ClientId) { return; }; - info_global!(state.log_tx, "Kicked {}", client_name); + info_global!("Kicked {}", client_name); // The kicked client's session gets torn down by its writer task // TODO Don't have send a close back / deal with double removal @@ -82,12 +81,12 @@ impl State { let client_tx = room.get_sender(client_id)?; let peer_senders = room.get_peer_senders(client_id); - let room_closed = room.remove_client(client_id, &self.log_tx) == RoomStatus::Closed; + let room_closed = room.remove_client(client_id) == RoomStatus::Closed; drop(room); // Relinquish room lock if room_closed { self.rooms.remove(&room_id); - remove_room!(self.log_tx, room_id); + remove_room!(room_id); } self.users.remove(client_id); diff --git a/glueball/src/logging.rs b/glueball/src/logging.rs index 0b34132c3c..d81d1d60bb 100644 --- a/glueball/src/logging.rs +++ b/glueball/src/logging.rs @@ -5,67 +5,74 @@ use ratatui::{ style::{Style, Stylize}, text::Line, }; -use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::mpsc::{self, Receiver}; -use crate::state::RoomId; +use crate::{LOG_TX, state::RoomId}; pub const MAX_LOG_LINES: usize = 500; pub type RoomLogs = HashMap>; pub type GlobalLog = VecDeque; pub type LogSnapshot = (GlobalLog, RoomLogs); -pub type LogSender = Sender; pub type LogRequest = (String, EventType, LogDestination); #[macro_export] macro_rules! info_global { - ($log_tx:expr, $($arg:tt)*) => { - let _ = $log_tx.try_send((format!($($arg)*), EventType::Info, LogDestination::Global)); + ($($arg:tt)*) => { + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Info, LogDestination::Global)); } } #[macro_export] macro_rules! warn_global { - ($log_tx:expr, $($arg:tt)*) => { - let _ = $log_tx.try_send((format!($($arg)*), EventType::Warning, LogDestination::Global)); + ($($arg:tt)*) => { + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Warning, LogDestination::Global)); } } #[macro_export] macro_rules! error_global { - ($log_tx:expr, $($arg:tt)*) => { - let _ = $log_tx.try_send((format!($($arg)*), EventType::Error, LogDestination::Global)); + ($($arg:tt)*) => { + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Error, LogDestination::Global)); } } /// Takes a lock on `state` #[macro_export] macro_rules! info_room { - ($log_tx:expr, $room_id:expr, $($arg:tt)*) => {{ - let _ = $log_tx.try_send((format!($($arg)*), EventType::Info, LogDestination::Room($room_id.clone()))); + ($room_id:expr, $($arg:tt)*) => {{ + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Info, LogDestination::Room($room_id.clone()))); }}; } /// Takes a lock on `state` #[macro_export] macro_rules! warn_room { - ($log_tx:expr, $room_id:expr, $($arg:tt)*) => {{ - let _ = $log_tx.try_send((format!($($arg)*), EventType::Warning, LogDestination::Room($room_id.clone()))); + ($room_id:expr, $($arg:tt)*) => {{ + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Warning, LogDestination::Room($room_id.clone()))); }}; } /// Takes a lock on `state` #[macro_export] macro_rules! error_room { - ($log_tx:expr, $room_id:expr, $($arg:tt)*) => {{ - let _ = $log_tx.try_send((format!($($arg)*), EventType::Error, LogDestination::Room($room_id.clone()))); + ($room_id:expr, $($arg:tt)*) => {{ + use $crate::{LOG_TX, EventType, LogDestination}; + let _ = LOG_TX.get().unwrap().try_send((format!($($arg)*), EventType::Error, LogDestination::Room($room_id.clone()))); }}; } #[macro_export] macro_rules! remove_room { - ($log_tx:expr, $room_id:expr) => {{ - let _ = $log_tx.try_send(( + ($room_id:expr) => {{ + use $crate::{EventType, LOG_TX, LogDestination}; + + let _ = LOG_TX.get().unwrap().try_send(( "".to_string(), EventType::Info, LogDestination::RemoveRoom($room_id), @@ -179,6 +186,15 @@ impl Logger { } } +pub fn create_logging_channel() -> Receiver { + let (logging_tx, logging_rx) = mpsc::channel::(MAX_LOG_LINES); + + #[allow(clippy::expect_used)] + LOG_TX.set(logging_tx).expect("Failed to set LOG_TX"); + + logging_rx +} + pub fn spawn_log_receiver(mut logging_rx: Receiver, handle_log: F) where F: Fn(String, EventType, LogDestination) + Send + 'static, diff --git a/glueball/src/main.rs b/glueball/src/main.rs index b2af3484ed..39fd90b028 100644 --- a/glueball/src/main.rs +++ b/glueball/src/main.rs @@ -1,6 +1,7 @@ mod cert; mod cleanup; mod config; +mod connection; #[macro_use] mod logging; mod http; @@ -15,134 +16,90 @@ mod tui; mod util; mod wire; -use crate::cert::build_identity; +use crate::cert::{build_hashes, build_identity}; use crate::cleanup::Cleanup; use crate::config::retrieve_config; +use crate::connection::spawn_webtransport_responder; use crate::http::spawn_http_responder; use crate::kick::setup_user_action_system; use crate::logging::{ - EventType, LogDestination, LogRequest, Logger, MAX_LOG_LINES, print_global, print_room, - spawn_log_receiver, + EventType, LogDestination, LogRequest, Logger, MAX_LOG_LINES, create_logging_channel, + print_global, print_room, spawn_log_receiver, }; -use crate::messaging::handle_session; -use crate::model::{CertificateHash, CertificateHashes}; use crate::state::State; use crate::tui::start_tui_thread; -use crate::util::get_local_ip; -use anyhow::{Result, bail}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tokio::sync::mpsc; -use wtransport::{Endpoint, ServerConfig}; +use anyhow::Result; +use std::sync::{Arc, Mutex, OnceLock}; +use tokio::sync::mpsc::{Receiver, Sender}; -/// How often to poke an otherwise idle connection so QUIC doesn't time it out. -const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); +/// Please copy out of this once lock when you want to log +static LOG_TX: OnceLock> = OnceLock::new(); #[tokio::main] async fn main() -> Result<()> { let _cleanup_trigger = Cleanup; - // # Parse and create defaults for the application configuration let config = retrieve_config()?; - // # Setup logging, state, channels and listeners, etc + let logging_rx = create_logging_channel(); - // In my mind, this is the best way to handling an interface that could be sending message to a - // tui running on a different OS thread or just printing them - let (logging_tx, logging_rx) = mpsc::channel::(MAX_LOG_LINES); - - let state = Arc::new(State::new(logging_tx.clone())); - - let user_action_tx = setup_user_action_system(&state); + let state = Arc::new(State::new()); if config.headless { - // Read the logging channel and immediantly print result - let print_to_terminal = - move |message: String, kind: EventType, log_destination: LogDestination| { - match log_destination { - LogDestination::Global => print_global(&message, &kind), - LogDestination::Room(id) => print_room(&message, &kind, &id), - // This can be a no-op, because no room is ever created, - // as the logger isn't used - LogDestination::RemoveRoom(_) => {} - } - }; - - spawn_log_receiver(logging_rx, print_to_terminal); + setup_cli_logging(logging_rx); } else { - // Only use the `logger` in tui mode - // `logger` will be jointly owned by the main thread and the tui thread - // Tokio tasks will pass their log messages down the logging channel - // instead of having to take a lock to log - let logger = Arc::new(Mutex::new(Logger::new(MAX_LOG_LINES))); - - start_tui_thread(&state, user_action_tx, logger.clone()); - - // Read the logging channel and write every message to the `logger` - let send_to_logger = - move |message: String, kind: EventType, log_destination: LogDestination| { - match log_destination { - LogDestination::Global => lock!(logger).push_global(message, kind), - LogDestination::Room(id) => lock!(logger).push_room(message, kind, id), - LogDestination::RemoveRoom(id) => lock!(logger).remove_room(&id), - } - }; - - spawn_log_receiver(logging_rx, send_to_logger); + setup_tui_with_logger(&state, logging_rx); } if let Some(room_id) = config.permanent_room { state.new_permanent_room(room_id); } - if !config.secure { - warn_global!( - logging_tx, - "Ignoring the insecure setting: WebTransport traffic is always encrypted" - ); - } + let identity = build_identity(&config.cert_dir).await?; + let hashes = build_hashes(&identity); - // # Setup the WebTransport endpoint + spawn_http_responder(config.port, hashes).await?; - let identity = build_identity(&config.cert_dir).await?; + spawn_webtransport_responder(state, config.port, identity).await?; - // Browsers will not offer to trust a self-signed WebTransport certificate the - // way they do for HTTPS, so clients pin these digests with - // `serverCertificateHashes` instead. They are served over `GET /cert` - let hashes = CertificateHashes { - hashes: identity - .certificate_chain() - .as_slice() - .iter() - .map(|certificate| CertificateHash::sha256(certificate.hash().as_ref())) - .collect(), - }; - - let server_config = ServerConfig::builder() - .with_bind_default(config.port) - .with_identity(identity) - .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) - .build(); - - let Ok(endpoint) = Endpoint::server(server_config) else { - bail!("Could not create UDP listener (the port is likely in use)"); - }; - - // Serves the certificate digests over the TCP half of the same port - spawn_http_responder(config.port, hashes, logging_tx.clone()).await?; - - let local_ip = get_local_ip().unwrap_or_else(|| String::from("0.0.0.0")); - - info_global!( - logging_tx, - "Server hosted on {local_ip} listening at port {} (UDP), certificate at /cert (TCP)", - config.port - ); - - loop { - let session = endpoint.accept().await; - - // The handshake happens in a task to avoid being held up by a slow client - tokio::spawn(handle_session(state.clone(), session, logging_tx.clone())); - } + Ok(()) +} + +fn setup_cli_logging(logging_rx: Receiver) { + // Read the logging channel and immediantly print result + let print_to_terminal = + move |message: String, kind: EventType, log_destination: LogDestination| { + match log_destination { + LogDestination::Global => print_global(&message, &kind), + LogDestination::Room(id) => print_room(&message, &kind, &id), + // This can be a no-op, because no room is ever created, + // as the logger isn't used + LogDestination::RemoveRoom(_) => {} + } + }; + + spawn_log_receiver(logging_rx, print_to_terminal); +} + +fn setup_tui_with_logger(state: &Arc, logging_rx: Receiver) { + // Only use the `logger` in tui mode + // `logger` will be jointly owned by the main thread and the tui thread + // Tokio tasks will pass their log messages down the logging channel + // instead of having to take a lock to log + let logger = Arc::new(Mutex::new(Logger::new(MAX_LOG_LINES))); + let user_action_tx = setup_user_action_system(state); + + start_tui_thread(state, user_action_tx, logger.clone()); + + // Read the logging channel and write every message to the `logger` + let send_to_logger = + move |message: String, kind: EventType, log_destination: LogDestination| { + match log_destination { + LogDestination::Global => lock!(logger).push_global(message, kind), + LogDestination::Room(id) => lock!(logger).push_room(message, kind, id), + LogDestination::RemoveRoom(id) => lock!(logger).remove_room(&id), + } + }; + + spawn_log_receiver(logging_rx, send_to_logger); } diff --git a/glueball/src/messaging.rs b/glueball/src/messaging.rs index c18b6c1208..23f2c15be3 100644 --- a/glueball/src/messaging.rs +++ b/glueball/src/messaging.rs @@ -1,113 +1,22 @@ -use crate::EventType; -use crate::logging::{LogDestination, LogSender}; +use crate::connection::TIMEOUT; use crate::model::{ClientToServerMessage, MessagePrefix, ServerToClientMessage}; use crate::state::{ClientId, ClientSender, State}; use crate::util::{deserialize_messagepack, server_sent_msg, trim_uuid}; -use crate::wire::{Delivery, Outbound, read_message, write_message}; +use crate::wire::{Outbound, read_message, write_message}; use anyhow::{Result, bail}; use bytes::Bytes; use chrono::Utc; -use tokio::sync::mpsc::{self}; use tokio::time::timeout; -use wtransport::VarInt; -use wtransport::endpoint::IncomingSession; -use wtransport::error::SendDatagramError; use wtransport::Connection; +use wtransport::VarInt; use std::net::SocketAddr; +use std::ops::ControlFlow; use std::sync::Arc; -use std::time::Duration; - -/// How long to wait for a client's next message before assuming it is gone. -/// Clients ping every five seconds, so a silent client is a dead one even while -/// its datagrams keep arriving. -const TIMEOUT: Duration = Duration::from_secs(30); - -/// Number of messages that may be queued for one client before writes to it -/// block (streams) or are dropped (datagrams). -const OUTBOUND_CAPACITY: usize = 64; /// Application error code sent to a client whose session the server terminates. -const CLOSED_BY_SERVER: VarInt = VarInt::from_u32(0); - -/// Completes the `WebTransport` handshake for an incoming QUIC connection, then -/// hands the session to [`handle_connection`]. -pub async fn handle_session(state: Arc, session: IncomingSession, logging_tx: LogSender) { - let addr = session.remote_address(); - - let request = match session.await { - Ok(request) => request, - Err(e) => { - error_global!(logging_tx, "QUIC handshake with {addr} failed: {e}"); - return; - } - }; - - let connection = match request.accept().await { - Ok(connection) => connection, - Err(e) => { - error_global!(logging_tx, "WebTransport handshake with {addr} failed: {e}"); - return; - } - }; - - handle_connection(state, connection, addr, logging_tx).await; -} - -pub async fn handle_connection( - state: Arc, - connection: Connection, - addr: SocketAddr, - logging_tx: LogSender, -) { - info_global!(logging_tx, "WT session established with {addr}"); - - // Each client gets an mpsc channel - // Other client threads on the server can write to it - // Everything written gets dumped back to its client by the writer task - let (tx, rx) = mpsc::channel::(OUTBOUND_CAPACITY); - - // Order of messages sent from a new client to the server: - // 1-n. Any number of `RequestRooms` messages -> server will return a list of rooms - // n..n+1. An `InitializationMessage`, indicating whether the client wishes to create or join a room -> server will return a room and client id - // n+1..m. Any number of messages that will be forwarded to every other client in their room -> server will not respond, instead forwarding - let Some(client_id) = - wait_for_initialization(&state, &connection, tx.clone(), addr, &logging_tx).await - else { - return; - }; - - spawn_writer(connection.clone(), rx, logging_tx.clone()); - spawn_datagram_reader( - connection.clone(), - state.clone(), - client_id, - logging_tx.clone(), - ); - - // Listen for and pass along messages to other client channels in the same room - loop { - // Messages are taken one at a time rather than concurrently: streams give - // no ordering guarantees between each other, so draining them in arrival - // order is the closest thing to the ordering clients used to rely on - let message = match timeout(TIMEOUT, accept_message(&connection)).await { - Ok(Ok(message)) => message, - Ok(Err(e)) => { - warn_global!(logging_tx, "{} disconnected: {e}", trim_uuid(&client_id)); - break; - } - Err(_) => { - warn_global!(logging_tx, "{} timed out", trim_uuid(&client_id)); - break; - } - }; - - handle_client_message(message, Delivery::Stream, &state, client_id, &logging_tx).await; - } - - let _ = handle_client_close(client_id, &state, logging_tx).await; -} +pub const CLOSED_BY_SERVER: VarInt = VarInt::from_u32(0); /// Waits for the client's next stream and reads the message off it. /// @@ -115,159 +24,29 @@ pub async fn handle_connection( /// stream rather than written back onto the request's stream, so there is nothing /// a bidirectional stream would buy, and ignoring them means a client that opens /// one and leaves it empty cannot stall this loop. -async fn accept_message(connection: &Connection) -> Result { +pub async fn accept_message(connection: &Connection) -> Result { let read = connection.accept_uni().await?; read_message(read).await } -/// Drains `rx` onto the client's session. -fn spawn_writer(connection: Connection, mut rx: mpsc::Receiver, logging_tx: LogSender) { - tokio::spawn(async move { - // Undeliverable datagrams come in floods rather than one at a time, so the - // reason is worth saying once and then never again for this session - let mut warned_undeliverable = false; - - while let Some(message) = rx.recv().await { - match message { - Outbound::Stream(payload) => { - if write_message(&connection, &payload).await.is_err() { - break; - } - } - - // Datagrams are best-effort, so one that cannot be sent is dropped - // rather than retried. It is still worth saying so once: a payload - // that never fits looks exactly like a peer that has gone quiet, - // which is a miserable thing to debug - Outbound::Datagram(payload) => { - let size = payload.len(); - - match connection.send_datagram(payload) { - Ok(()) => {} - Err(SendDatagramError::NotConnected) => break, - Err(e) if warned_undeliverable => { - let _ = e; - } - Err(SendDatagramError::TooLarge) => { - warned_undeliverable = true; - warn_global!( - logging_tx, - "Dropping datagrams: {size} bytes exceeds the {} the path allows. Send these over a stream instead", - connection.max_datagram_size().unwrap_or_default() - ); - } - Err(SendDatagramError::UnsupportedByPeer) => { - warned_undeliverable = true; - warn_global!( - logging_tx, - "Dropping datagrams: the client does not accept them" - ); - } - } - } - - Outbound::Close => { - connection.close(CLOSED_BY_SERVER, b"Closed by server"); - break; - } - } - } - }); -} - -/// Reads datagrams for the lifetime of the session. -/// -/// Datagrams arrive outside of any stream, so they need a reader of their own. -fn spawn_datagram_reader( - connection: Connection, - state: Arc, - client_id: ClientId, - logging_tx: LogSender, -) { - tokio::spawn(async move { - while let Ok(datagram) = connection.receive_datagram().await { - handle_client_message( - datagram.payload(), - Delivery::Datagram, - &state, - client_id, - &logging_tx, - ) - .await; - } - }); -} - -/// Waits for and handles messages from the client that are intended for the server. -/// -/// If the message is `ClientToServerMessage::RequestRooms`, -/// the function handles the request and keeps listening -/// -/// If the message is `ClientToServerMessage::InitializeConnection`, -/// the function handles the request by generating a client id, and putting -/// the client in the correct room. This may involve creating a new room depending on the request -/// The function then returns the generated `ClientId` -/// -/// If any message is unable to be parse, the function returns `None`. -async fn wait_for_initialization( - state: &Arc, - connection: &Connection, - tx: ClientSender, - addr: SocketAddr, - logging_tx: &LogSender, -) -> Option { - loop { - match parse_first_message(connection, addr, logging_tx).await { - Some(ClientToServerMessage::RequestRooms) => { - handle_room_list_request(state, connection).await; - } - - // When they ask to initialize a connection, then we add them to a room - // Or create a room for them - Some(ClientToServerMessage::InitializeConnection { room_id, name }) => { - let (client_id, room_id) = state.initialize_client_in_room(tx, room_id, &name)?; - - let message = server_sent_msg(ServerToClientMessage::SendInfo { - room_id, - client_id: client_id.to_string(), - }); - - if write_message(connection, &message).await.is_err() { - error_global!(logging_tx, "Failed to send back initial response"); - - return None; - } - - break Some(client_id); - } - Some(ClientToServerMessage::Ping { timestamp: _ }) => { - error_global!( - logging_tx, - "Received ping from client during initialization" - ); - return None; - } - None => return None, - } - } -} - -async fn parse_first_message( +/// Passes all messages sent down the corresponding `tx` to the client +pub async fn handle_first_message( connection: &Connection, addr: SocketAddr, - logging_tx: &LogSender, ) -> Option { - // Parse initial message, then user in correct room - let message_data = match timeout(TIMEOUT, accept_message(connection)).await { + let maybe_timed_out_response = timeout(TIMEOUT, accept_message(connection)).await; + + let message_data = match maybe_timed_out_response { Ok(Ok(message_data)) => message_data, + Ok(Err(e)) => { - warn_global!(logging_tx, "{addr} disconnected before handshake: {e}"); + warn_global!("{addr} disconnected before handshake: {e}"); return None; } + Err(_) => { warn_global!( - logging_tx, "{addr} did not complete a message before the handshake timed out. A client must finish each stream it writes, since that is what ends the message" ); return None; @@ -275,19 +54,19 @@ async fn parse_first_message( }; if message_data.is_empty() { - error_global!(logging_tx, "{addr} sent an empty initial message"); + error_global!("{addr} sent an empty initial message"); return None; } let Ok(message) = deserialize_messagepack::(&message_data[1..]) else { - error_global!(logging_tx, "{addr} sent an invalid initial message"); + error_global!("{addr} sent an invalid initial message"); return None; }; Some(message) } -async fn handle_room_list_request(state: &Arc, connection: &Connection) { +pub async fn handle_room_list_request(state: &Arc, connection: &Connection) { let message = server_sent_msg(ServerToClientMessage::RoomList { rooms: state.list_rooms(), }); @@ -295,82 +74,101 @@ async fn handle_room_list_request(state: &Arc, connection: &Connection) { let _ = write_message(connection, &message).await; } -/// Routes one message from a client. -/// -/// Messages carrying [`MessagePrefix::Server`] are for the server to answer; -/// everything else is forwarded verbatim to the client's roommates over the same -/// kind of channel it arrived on. -async fn handle_client_message( +pub async fn handle_client_message_datagram( payload: Bytes, - delivery: Delivery, state: &Arc, - client_id: ClientId, - logging_tx: &LogSender, + client_id: &ClientId, ) { - // A prefix byte on its own carries nothing + let handle_datagram = async |payload: Bytes, senders: Vec| { + for tx in &senders { + let _ = tx.try_send(Outbound::Datagram(payload.clone())); + } + }; + + handle_client_message_generic(payload, state, client_id, handle_datagram).await; +} + +pub async fn handle_client_message_stream( + payload: Bytes, + state: &Arc, + client_id: &ClientId, +) { + let handle_datagram = async |payload: Bytes, senders: Vec| { + let responses = senders + .iter() + .map(|tx| tx.send(Outbound::Stream(payload.clone()))); + let _ = futures_util::future::join_all(responses).await; + }; + + handle_client_message_generic(payload, state, client_id, handle_datagram).await; +} + +/// Handles a message from the client. +/// +/// All messages carry a prefix byte ([`MessagePrefix`]) that indicates whether the message was +/// meant as a client-client message ([`MessagePrefix::Client`]) or as a server-client message ([`MessagePrefix::Server`]) +/// +/// If it's a client-client message, it gets sent down the appropriate client's sinks (handled by [`spawn_client_sink`]) +/// If it's a client-server message, an appropriate response is sent. +/// +/// Client-client messages can be sent either via datagrams or streams +/// Client-server messages are expected to be sent via streams +async fn handle_client_message_generic( + payload: Bytes, + state: &Arc, + client_id: &ClientId, + payload_handler: F, +) where + F: Fn(Bytes, Vec) -> Fut, + Fut: Future, +{ + let result = check_message_prefix(state, &payload, client_id).await; + + if result.is_break() { + return; + } + + // If we're here, that means the message has a client-client prefix + // which we want anyway, so there's no need to prefix the message: + // we can just forward it! + let senders: Vec = { state.get_senders_from_user_room(*client_id) }; + + payload_handler(payload, senders).await; +} + +/// Checks the message prefix +/// +/// If its a client-server message, passes it off to [`handle_client_ping`] +async fn check_message_prefix( + state: &Arc, + payload: &Bytes, + client_id: &ClientId, +) -> ControlFlow<()> { if payload.len() <= 1 { warn_global!( - logging_tx, "Discarding {} byte message from {}", payload.len(), - trim_uuid(&client_id) + trim_uuid(client_id) ); - return; + return ControlFlow::Break(()); } - if payload[0] == MessagePrefix::Server as u8 { - if delivery == Delivery::Datagram { - // Answering still works, but the client should not be risking a - // dropped client-server message in the first place - warn_global!( - logging_tx, - "{} sent a client-server message as a datagram", - trim_uuid(&client_id) - ); - } + let prefix = payload[0]; - handle_client_ping(&payload, &client_id, state, logging_tx.clone()).await; - return; - } + if prefix == MessagePrefix::Server as u8 { + handle_client_ping(payload, client_id, state).await; - // If we're here, that means the message has a client-client prefix - // which we want anyway, so there's no need to prefix the message - // we can just forward it! - let senders: Vec = { state.get_senders_from_user_room(client_id) }; - - match delivery { - // Guaranteed traffic waits for room in each peer's queue - Delivery::Stream => { - let tasks = senders - .iter() - .map(|tx| tx.send(delivery.queue(payload.clone()))); - let _ = futures_util::future::join_all(tasks).await; - } - - // Unreliable traffic is dropped instead of queued: one peer that cannot - // keep up must not stall every other peer's updates, and a stale physics - // update is worth less than the one behind it - Delivery::Datagram => { - for tx in &senders { - let _ = tx.try_send(delivery.queue(payload.clone())); - } - } + return ControlFlow::Break(()); } + + ControlFlow::Continue(()) } -async fn handle_client_ping( - bytes: &Bytes, - client_id: &ClientId, - state: &Arc, - logging_tx: LogSender, -) { +async fn handle_client_ping(bytes: &Bytes, client_id: &ClientId, state: &Arc) { let Ok(ClientToServerMessage::Ping { timestamp }) = deserialize_messagepack::(&bytes[1..]) else { - error_global!( - logging_tx, - "Got invalid client to server message while client was in room" - ); + error_global!("Got invalid client to server message while client was in room"); return; }; @@ -387,10 +185,7 @@ async fn handle_client_ping( // Because Mutex locks are not Send let tx = { let Some(tx) = state.get_client_tx(client_id) else { - error_global!( - logging_tx, - "Received client-server message from client not in room" - ); + error_global!("Received client-server message from client not in room"); return; }; @@ -400,11 +195,7 @@ async fn handle_client_ping( let _ = tx.send(Outbound::Stream(message)).await; } -async fn handle_client_close( - client_id: ClientId, - state: &Arc, - logging_tx: LogSender, -) -> Result<()> { +pub async fn handle_client_close(client_id: ClientId, state: &Arc) -> Result<()> { // Send message to all other clients telling them `client_id` has been kicked let message = server_sent_msg(ServerToClientMessage::Kick { client_id: client_id.to_string(), @@ -413,12 +204,12 @@ async fn handle_client_close( let Some(room) = state.get_room_of_client_mut(&client_id) else { let err = "Client attempted to leave when they were not in a room "; - error_global!(logging_tx, "{}", err); + error_global!("{}", err); bail!(err); }; let client_name = room.get_client_name(&client_id)?; - warn_global!(logging_tx, "Connection with {client_name} closed"); + warn_global!("Connection with {client_name} closed"); let senders = room.get_peer_senders(&client_id); drop(room); diff --git a/glueball/src/state.rs b/glueball/src/state.rs index e1ed009943..5dc0abc3db 100644 --- a/glueball/src/state.rs +++ b/glueball/src/state.rs @@ -1,4 +1,5 @@ -use crate::logging::{EventType, LogDestination, LogSender}; +use std::fmt::Display; + use crate::model::RoomInfo; use crate::wire::Outbound; @@ -17,17 +18,13 @@ const MAX_ROOM_COUNT: usize = 32; pub struct State { pub users: ClientMap, pub rooms: RoomMap, - /// Server-wide events not tied to a specific room - /// (e.g. connections, handshakes, failed joins) - pub log_tx: LogSender, } impl State { - pub fn new(log_tx: LogSender) -> Self { + pub fn new() -> Self { Self { users: DashMap::new(), rooms: DashMap::new(), - log_tx, } } @@ -39,7 +36,7 @@ impl State { ) -> Option<(ClientId, RoomId)> { match room_id { None if self.room_count() == MAX_ROOM_COUNT => None, - None => Some(self.add_room_and_host(name.to_string(), tx).ok()?), + None => Some(self.add_room_and_host(name, tx)), Some(room_id) => self .add_client_to_room(name, tx, &room_id) .map(|client_id| (client_id, room_id)), @@ -53,12 +50,12 @@ impl State { pub fn add_room_and_host( &self, - host_name: String, + host_name: impl ToString + Display, host_tx: ClientSender, - ) -> Result<(ClientId, RoomId)> { + ) -> (ClientId, RoomId) { let host_id = Uuid::new_v4(); let room = Room { - members: vec![Client::new(host_id, host_name.clone(), host_tx)], + members: vec![Client::new(host_id, host_name.to_string(), host_tx)], host: Some(host_id), locked: false, permanent: false, @@ -71,16 +68,12 @@ impl State { } }; - info_room!( - self.log_tx, - room_id, - "{host_name} [H] created room {room_id}", - ); + info_room!(room_id, "{host_name} [H] created room {room_id}",); self.users.insert(host_id, room_id.clone()); self.rooms.insert(room_id.clone(), room); - Ok((host_id, room_id)) + (host_id, room_id) } /// # Safety @@ -95,12 +88,12 @@ impl State { return; }; - let room_closed = room.remove_client(client_id, &self.log_tx) == RoomStatus::Closed; + let room_closed = room.remove_client(client_id) == RoomStatus::Closed; drop(room); if room_closed { self.rooms.remove(&room_id); - remove_room!(self.log_tx, room_id); + remove_room!(room_id); } } @@ -112,10 +105,7 @@ impl State { ) -> Option { let client_id = Uuid::new_v4(); let Some(mut room) = self.rooms.get_mut(room_id) else { - warn_global!( - self.log_tx, - "Attempted to add {client_id} into non-existant room {room_id}" - ); + warn_global!("Attempted to add {client_id} into non-existant room {room_id}"); return None; }; @@ -128,11 +118,11 @@ impl State { self.users.insert(client_id, room_id.clone()); if room.host.is_none() { - info_room!(self.log_tx, room_id, "{client_name} became host of room",); + info_room!(room_id, "{client_name} became host of room",); room.host = Some(client_id); } - info_room!(self.log_tx, room_id, "{client_name} joined room",); + info_room!(room_id, "{client_name} joined room",); Some(client_id) } @@ -140,7 +130,6 @@ impl State { pub fn new_permanent_room(&self, room_id: RoomId) { if !is_valid_room_id(&room_id) { error_global!( - self.log_tx, "Invalid permanent room id: {room_id}, must be 6 characters and each character must match `[0-9A-Z]`" ); return; @@ -157,7 +146,7 @@ impl State { pub fn get_room_of_client_mut(&self, client_id: &ClientId) -> Option> { let Some(room_id) = self.users.get(client_id) else { - warn_global!(self.log_tx, "Attempted to get client that does not exist"); + warn_global!("Attempted to get client that does not exist"); return None; }; @@ -184,11 +173,12 @@ impl State { let mut room = self.rooms.get_mut(room_id)?; room.locked = !room.locked; let locked = room.locked; + drop(room); if locked { - info_room!(self.log_tx, room_id, "Room Locked"); + info_room!(room_id, "Room Locked"); } else { - info_room!(self.log_tx, room_id, "Room Unlocked"); + info_room!(room_id, "Room Unlocked"); } Some(locked) @@ -328,17 +318,14 @@ impl Room { .map(|client| client.tx.clone()) } - pub fn remove_client(&mut self, client_id: &ClientId, logging_tx: &LogSender) -> RoomStatus { + pub fn remove_client(&mut self, client_id: &ClientId) -> RoomStatus { let Some(idx) = self .members .iter() .map(|client| client.id) .position(|id| id == *client_id) else { - warn_global!( - logging_tx, - "Attempted to remove client from room they are not in" - ); + warn_global!("Attempted to remove client from room they are not in"); return RoomStatus::Open; }; @@ -383,12 +370,12 @@ pub struct RoomSnapshot { #[cfg(test)] mod tests { use super::{ClientSender, MAX_ROOM_COUNT, State, is_valid_room_id}; - use crate::logging::LogSender; use tokio::sync::mpsc; - fn log_tx() -> LogSender { + fn log_tx() { let (tx, _rx) = mpsc::channel(64); - tx + + crate::LOG_TX.set(tx).unwrap() } fn client_tx() -> ClientSender { @@ -398,10 +385,10 @@ mod tests { #[test] fn create_room_adds_host() { - let state = State::new(log_tx()); - state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + state.add_room_and_host("Alice", client_tx()); assert_eq!(state.room_count(), 1); let rooms = state.list_rooms(); @@ -411,10 +398,10 @@ mod tests { #[test] fn join_existing_room() { - let state = State::new(log_tx()); - let (_, room_id) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + let (_, room_id) = state.add_room_and_host("Alice", client_tx()); assert!( state @@ -426,7 +413,9 @@ mod tests { #[test] fn join_nonexistent_room_returns_none() { - let state = State::new(log_tx()); + log_tx(); + + let state = State::new(); assert!( state .add_client_to_room("Bob", client_tx(), &"ZZZZZZ".to_string()) @@ -436,10 +425,10 @@ mod tests { #[test] fn join_locked_room_returns_none() { - let state = State::new(log_tx()); - let (_, room_id) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + let (_, room_id) = state.add_room_and_host("Alice", client_tx()); state.toggle_room_lock(&room_id); assert!( @@ -451,10 +440,10 @@ mod tests { #[test] fn last_client_leaving_closes_room() { - let state = State::new(log_tx()); - let (client_id, _) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + let (client_id, _) = state.add_room_and_host("Alice", client_tx()); state.remove_client(&client_id); assert_eq!(state.room_count(), 0); @@ -462,10 +451,10 @@ mod tests { #[test] fn host_leaving_transfers_to_next_member() { - let state = State::new(log_tx()); - let (host_id, room_id) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + let (host_id, room_id) = state.add_room_and_host("Alice", client_tx()); state .add_client_to_room("Bob", client_tx(), &room_id) @@ -477,7 +466,9 @@ mod tests { #[test] fn permanent_room_stays_open_when_empty() { - let state = State::new(log_tx()); + log_tx(); + + let state = State::new(); let room_id = "PERM01".to_string(); state.new_permanent_room(room_id.clone()); @@ -492,12 +483,12 @@ mod tests { #[test] fn list_rooms_shows_host_and_lock_status() { - let state = State::new(log_tx()); + log_tx(); + + let state = State::new(); assert!(state.list_rooms().is_empty()); - let (_, room_id) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + let (_, room_id) = state.add_room_and_host("Alice", client_tx()); state.toggle_room_lock(&room_id); let rooms = state.list_rooms(); @@ -508,10 +499,10 @@ mod tests { #[test] fn toggle_lock_flips_state() { - let state = State::new(log_tx()); - let (_, room_id) = state - .add_room_and_host("Alice".to_string(), client_tx()) - .unwrap(); + log_tx(); + + let state = State::new(); + let (_, room_id) = state.add_room_and_host("Alice", client_tx()); assert_eq!(state.toggle_room_lock(&room_id), Some(true)); assert_eq!(state.toggle_room_lock(&room_id), Some(false)); @@ -519,11 +510,11 @@ mod tests { #[test] fn max_rooms_prevents_new_room() { - let state = State::new(log_tx()); + log_tx(); + + let state = State::new(); for i in 0..MAX_ROOM_COUNT { - state - .add_room_and_host(format!("Client{i}"), client_tx()) - .unwrap(); + state.add_room_and_host(format!("Client{i}"), client_tx()); } assert!( diff --git a/glueball/src/tui.rs b/glueball/src/tui.rs index f21eff2e4b..e95ee92cee 100644 --- a/glueball/src/tui.rs +++ b/glueball/src/tui.rs @@ -104,13 +104,12 @@ fn run_app( let room_logs_len = app .focused_room .clone() - .map(|room_id| { + .and_then(|room_id| { logger_snapshot .1 .get::(room_id.as_ref()) - .map(|logs| logs.len()) + .map(VecDeque::len) }) - .flatten() .unwrap_or(0); app.on_key( @@ -265,7 +264,7 @@ impl App { // Clamping to valid range is handled in render_logs. KeyCode::Char('[') => { self.room_log_cursor = - usize::min(self.room_log_cursor + 1, room_log_len.saturating_sub(1)) + usize::min(self.room_log_cursor + 1, room_log_len.saturating_sub(1)); } KeyCode::Char(']') => self.room_log_cursor = self.room_log_cursor.saturating_sub(1), @@ -273,7 +272,7 @@ impl App { // Clamping to valid range is handled in render_logs. KeyCode::Char('{') => { self.system_log_cursor = - usize::min(self.system_log_cursor + 1, sys_log_len.saturating_sub(1)) + usize::min(self.system_log_cursor + 1, sys_log_len.saturating_sub(1)); } KeyCode::Char('}') => self.system_log_cursor = self.system_log_cursor.saturating_sub(1), diff --git a/glueball/src/wire.rs b/glueball/src/wire.rs index e8cd30f647..70b5f5a711 100644 --- a/glueball/src/wire.rs +++ b/glueball/src/wire.rs @@ -26,26 +26,6 @@ const READ_CHUNK_SIZE: usize = 8 * 1024; /// than buffer for it. const MAX_MESSAGE_SIZE: usize = 4 * 1024 * 1024; -/// How a message travelled, and therefore how anything derived from it should -/// travel back out. -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum Delivery { - /// On a stream of its own: ordered and guaranteed. - Stream, - /// As a datagram: unordered, and dropped rather than retransmitted. - Datagram, -} - -impl Delivery { - /// Queues `payload` for delivery over this same kind of channel. - pub const fn queue(self, payload: Bytes) -> Outbound { - match self { - Self::Stream => Outbound::Stream(payload), - Self::Datagram => Outbound::Datagram(payload), - } - } -} - /// A payload queued for delivery to a single client. #[derive(Clone)] pub enum Outbound { From e0926ca9da811fa21a70ee105cd171ac395cfdde Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Wed, 12 Aug 2026 08:05:23 -0700 Subject: [PATCH 6/6] refactor(glueball): just a bit of cleanup --- glueball/src/connection.rs | 38 ++++++++++++++++++++------------------ glueball/src/http.rs | 13 ++++++++----- glueball/src/main.rs | 37 ++++++++++++++++++++++--------------- glueball/src/messaging.rs | 4 ++-- glueball/src/state.rs | 2 ++ glueball/src/tests.rs | 26 ++++++++++++-------------- glueball/src/wire.rs | 9 ++++----- 7 files changed, 70 insertions(+), 59 deletions(-) diff --git a/glueball/src/connection.rs b/glueball/src/connection.rs index 1ee466d7dd..8316df57e1 100644 --- a/glueball/src/connection.rs +++ b/glueball/src/connection.rs @@ -1,18 +1,6 @@ //! Module for handling all connections with a client //! Heavily utilizes methods from the `messaging` module -use anyhow::{Result, bail}; -use futures_util::never::Never; -use std::net::SocketAddr; -use std::{sync::Arc, time::Duration}; -use tokio::sync::mpsc::Receiver; -use tokio::{ - sync::mpsc::{self}, - time::timeout, -}; -use wtransport::error::SendDatagramError; -use wtransport::{Connection, Endpoint, Identity, endpoint::IncomingSession}; - use crate::messaging::{ CLOSED_BY_SERVER, accept_message, handle_client_close, handle_client_message_datagram, handle_client_message_stream, handle_first_message, handle_room_list_request, @@ -20,11 +8,22 @@ use crate::messaging::{ use crate::model::{ClientToServerMessage, ServerToClientMessage}; use crate::state::{ClientId, ClientSender}; use crate::util::{server_sent_msg, trim_uuid}; -use crate::wire::write_message; +use crate::wire::send_message; use crate::{ config::build_server_config, error_global, info_global, state::State, util::get_local_ip, warn_global, wire::Outbound, }; +use anyhow::{Result, bail}; +use futures_util::never::Never; +use std::net::SocketAddr; +use std::{sync::Arc, time::Duration}; +use tokio::sync::mpsc::Receiver; +use tokio::{ + sync::mpsc::{self}, + time::timeout, +}; +use wtransport::error::SendDatagramError; +use wtransport::{Connection, Endpoint, Identity, endpoint::IncomingSession}; /// How long to wait for a client's next message before assuming it is gone. /// Clients ping every five seconds, so a silent client is a dead one even while @@ -62,8 +61,9 @@ pub async fn spawn_webtransport_responder( } /// Completes the `WebTransport` handshake for an incoming QUIC connection +/// /// Then hands the session to [`handle_connection`]. -async fn accept_incoming_session(state: Arc, session: IncomingSession) { +pub async fn accept_incoming_session(state: Arc, session: IncomingSession) { let addr = session.remote_address(); let request = match session.await { @@ -103,7 +103,7 @@ async fn handle_connection(state: Arc, connection: Connection, addr: Sock }; spawn_client_sink(connection.clone(), rx); - spawn_datagram_reader(connection.clone(), state.clone(), client_id); + spawn_datagram_listener(connection.clone(), state.clone(), client_id); // Listen for and pass along meskesages to other client channels in the same room loop { @@ -161,7 +161,7 @@ async fn wait_for_initialization( client_id: client_id.to_string(), }); - if write_message(connection, &message).await.is_err() { + if send_message(connection, &message).await.is_err() { error_global!("Failed to send back initial response"); return None; @@ -179,6 +179,8 @@ async fn wait_for_initialization( } } +/// Client sink passes all messages from `rx` down the client's `connection`, +/// back to the client machine. fn spawn_client_sink(connection: Connection, mut rx: Receiver) { tokio::spawn(async move { // Undeliverable datagrams come in floods rather than one at a time, so the @@ -188,7 +190,7 @@ fn spawn_client_sink(connection: Connection, mut rx: Receiver) { while let Some(message) = rx.recv().await { match message { Outbound::Stream(payload) => { - if write_message(&connection, &payload).await.is_err() { + if send_message(&connection, &payload).await.is_err() { break; } } @@ -225,7 +227,7 @@ fn spawn_client_sink(connection: Connection, mut rx: Receiver) { } /// Reads datagrams, extracting their payload and sending them to [`handle_client_message`] -fn spawn_datagram_reader(connection: Connection, state: Arc, client_id: ClientId) { +fn spawn_datagram_listener(connection: Connection, state: Arc, client_id: ClientId) { tokio::spawn(async move { while let Ok(datagram) = connection.receive_datagram().await { handle_client_message_datagram(datagram.payload(), &state, &client_id).await; diff --git a/glueball/src/http.rs b/glueball/src/http.rs index f9fb68c4d1..e226ffc1f2 100644 --- a/glueball/src/http.rs +++ b/glueball/src/http.rs @@ -1,7 +1,8 @@ //! A minimal HTTP/1.1 responder sharing the server's port over TCP. //! -//! `WebTransport` is carried over QUIC, which is UDP, so nothing here touches -//! game traffic — the TCP half of the port would otherwise sit unused. Its job is +//! `WebTransport` is carried over QUIC, which is UDP. +//! +//! The TCP half of the port would otherwise sit unused. Its job is //! to answer the plain HTTP requests a browser makes before it connects: //! //! * `GET /cert` returns this server's certificate digests as JSON, which a @@ -9,9 +10,11 @@ //! * Anything else gets a short body, so hitting the port in a browser says //! something useful instead of hanging. //! -//! Responses are plain HTTP rather than HTTPS on purpose: serving them over TLS -//! with the very certificate the client is trying to learn about would be -//! circular. Browsers treat `localhost` and `127.0.0.1` as trustworthy origins, +//! Responses are plain HTTP rather than HTTPS on purpose: +//! serving them over TLS with the very certificate the client +//! is trying to learn about would be circular! +//! +//! Browsers treat `localhost` and `127.0.0.1` as trustworthy origins, //! so mixed-content rules do not block this for the local servers it exists for. use crate::model::CertificateHashes; diff --git a/glueball/src/main.rs b/glueball/src/main.rs index 39fd90b028..9f4a40fc24 100644 --- a/glueball/src/main.rs +++ b/glueball/src/main.rs @@ -33,6 +33,8 @@ use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::mpsc::{Receiver, Sender}; /// Please copy out of this once lock when you want to log +/// This variable is a once lock instead of a lazy lock because we want to return more than one +/// thing from the [`create_logging_channel`] function and so want it called in our main function. static LOG_TX: OnceLock> = OnceLock::new(); #[tokio::main] @@ -65,25 +67,14 @@ async fn main() -> Result<()> { Ok(()) } -fn setup_cli_logging(logging_rx: Receiver) { - // Read the logging channel and immediantly print result - let print_to_terminal = - move |message: String, kind: EventType, log_destination: LogDestination| { - match log_destination { - LogDestination::Global => print_global(&message, &kind), - LogDestination::Room(id) => print_room(&message, &kind, &id), - // This can be a no-op, because no room is ever created, - // as the logger isn't used - LogDestination::RemoveRoom(_) => {} - } - }; - - spawn_log_receiver(logging_rx, print_to_terminal); -} +// The reason these functions are in the main module is because `setup_tui_with_logger` touches +// two different systems and `setup_cli_logging`, despite only affecting logging, is the former +// function's counterpart, and thus I believe it is clearer to have them on the same (top) level. fn setup_tui_with_logger(state: &Arc, logging_rx: Receiver) { // Only use the `logger` in tui mode // `logger` will be jointly owned by the main thread and the tui thread + // // Tokio tasks will pass their log messages down the logging channel // instead of having to take a lock to log let logger = Arc::new(Mutex::new(Logger::new(MAX_LOG_LINES))); @@ -103,3 +94,19 @@ fn setup_tui_with_logger(state: &Arc, logging_rx: Receiver) { spawn_log_receiver(logging_rx, send_to_logger); } + +fn setup_cli_logging(logging_rx: Receiver) { + // Read the logging channel and immediantly print result + let print_to_terminal = + move |message: String, kind: EventType, log_destination: LogDestination| { + match log_destination { + LogDestination::Global => print_global(&message, &kind), + LogDestination::Room(id) => print_room(&message, &kind, &id), + // This can be a no-op, because no room is ever created, + // as the logger isn't used + LogDestination::RemoveRoom(_) => {} + } + }; + + spawn_log_receiver(logging_rx, print_to_terminal); +} diff --git a/glueball/src/messaging.rs b/glueball/src/messaging.rs index 23f2c15be3..8009d8e3d3 100644 --- a/glueball/src/messaging.rs +++ b/glueball/src/messaging.rs @@ -2,7 +2,7 @@ use crate::connection::TIMEOUT; use crate::model::{ClientToServerMessage, MessagePrefix, ServerToClientMessage}; use crate::state::{ClientId, ClientSender, State}; use crate::util::{deserialize_messagepack, server_sent_msg, trim_uuid}; -use crate::wire::{Outbound, read_message, write_message}; +use crate::wire::{Outbound, read_message, send_message}; use anyhow::{Result, bail}; use bytes::Bytes; @@ -71,7 +71,7 @@ pub async fn handle_room_list_request(state: &Arc, connection: &Connectio rooms: state.list_rooms(), }); - let _ = write_message(connection, &message).await; + let _ = send_message(connection, &message).await; } pub async fn handle_client_message_datagram( diff --git a/glueball/src/state.rs b/glueball/src/state.rs index 5dc0abc3db..ae7df4cfd5 100644 --- a/glueball/src/state.rs +++ b/glueball/src/state.rs @@ -36,7 +36,9 @@ impl State { ) -> Option<(ClientId, RoomId)> { match room_id { None if self.room_count() == MAX_ROOM_COUNT => None, + None => Some(self.add_room_and_host(name, tx)), + Some(room_id) => self .add_client_to_room(name, tx, &room_id) .map(|client_id| (client_id, room_id)), diff --git a/glueball/src/tests.rs b/glueball/src/tests.rs index 7d7cfdfd3b..3976d262cc 100644 --- a/glueball/src/tests.rs +++ b/glueball/src/tests.rs @@ -3,17 +3,16 @@ use std::sync::Arc; use bytes::Bytes; -use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; use wtransport::{ClientConfig, Connection, Endpoint, Identity, ServerConfig, VarInt}; +use crate::connection::accept_incoming_session; use crate::kick::{UserAction, setup_user_action_system}; -use crate::logging::LogSender; -use crate::messaging::handle_session; +use crate::logging::create_logging_channel; use crate::model::{ClientToServerMessage, MessagePrefix, ServerToClientMessage}; use crate::state::State; use crate::util::{deserialize_messagepack, serialize_and_prefix}; -use crate::wire::{read_message, write_message}; +use crate::wire::{read_message, send_message}; const RECV_TIMEOUT: Duration = Duration::from_secs(3); @@ -27,8 +26,9 @@ async fn spawn_server() -> (String, wtransport::tls::Sha256Digest) { /// As [`spawn_server`], but also hands back the server's state so a test can act /// on it the way the TUI does. async fn spawn_server_with_state() -> (String, wtransport::tls::Sha256Digest, Arc) { - let (log_tx, _log_rx): (LogSender, _) = mpsc::channel(128); - let state = Arc::new(State::new(log_tx.clone())); + let _log_rx = create_logging_channel(); + + let state = Arc::new(State::new()); let identity = Identity::self_signed(["localhost", "127.0.0.1", "::1"]).unwrap(); let hash = identity.certificate_chain().as_slice()[0].hash(); @@ -45,11 +45,7 @@ async fn spawn_server_with_state() -> (String, wtransport::tls::Sha256Digest, Ar tokio::spawn(async move { loop { let session = endpoint.accept().await; - tokio::spawn(handle_session( - accept_state.clone(), - session, - log_tx.clone(), - )); + tokio::spawn(accept_incoming_session(accept_state.clone(), session)); } }); @@ -82,11 +78,11 @@ impl TestClient { async fn send_server(&self, message: ClientToServerMessage) { let payload = serialize_and_prefix(message, MessagePrefix::Server); - write_message(&self.connection, &payload).await.unwrap(); + send_message(&self.connection, &payload).await.unwrap(); } async fn send_peer_stream(&self, payload: &[u8]) { - write_message(&self.connection, payload).await.unwrap(); + send_message(&self.connection, payload).await.unwrap(); } fn send_peer_datagram(&self, payload: &[u8]) { @@ -145,7 +141,9 @@ async fn request_rooms_returns_empty_list() { let (url, hash) = spawn_server().await; let client = TestClient::connect(&url, hash).await; - client.send_server(ClientToServerMessage::RequestRooms).await; + client + .send_server(ClientToServerMessage::RequestRooms) + .await; let ServerToClientMessage::RoomList { rooms } = client.recv_server().await else { panic!("Expected RoomList"); diff --git a/glueball/src/wire.rs b/glueball/src/wire.rs index 70b5f5a711..410f96676b 100644 --- a/glueball/src/wire.rs +++ b/glueball/src/wire.rs @@ -1,9 +1,8 @@ -//! Message delivery for the `WebTransport` wire protocol. +//! This module is responsible for all `WebTransport` network I/O //! //! A message on the wire is unchanged from the `WebSocket` protocol: a single //! [`MessagePrefix`](crate::model::MessagePrefix) byte followed by a -//! `MessagePack` body. What changes is how one message is delimited from the -//! next. +//! `MessagePack` body. //! //! * Every message travelling over a stream gets its own unidirectional stream. //! The sender writes the payload and finishes the stream; that finish *is* the @@ -46,7 +45,7 @@ pub async fn read_message(mut read: RecvStream) -> Result { // A `None` read is the peer finishing the stream, which ends the message while let Some(count) = read.read(&mut chunk).await? { if payload.len() + count > MAX_MESSAGE_SIZE { - bail!("message exceeds the {MAX_MESSAGE_SIZE} byte limit"); + bail!("Message exceeds the {MAX_MESSAGE_SIZE} byte limit"); } payload.extend_from_slice(&chunk[..count]); @@ -59,7 +58,7 @@ pub async fn read_message(mut read: RecvStream) -> Result { /// /// The stream is finished before returning, because an unfinished stream leaves /// the client waiting for a boundary that never arrives. -pub async fn write_message(connection: &Connection, payload: &[u8]) -> Result<()> { +pub async fn send_message(connection: &Connection, payload: &[u8]) -> Result<()> { let mut write = connection.open_uni().await?.await?; write.write_all(payload).await?;