From 58a794d4e2cd5bd82aa9ecdf95962d6af70b876d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 21 Sep 2026 12:49:07 -0400 Subject: [PATCH] [match] tachyon sandbox switch --- .env.example | 5 ++ apps/match/src/context.ts | 7 +++ apps/match/src/match.app.ts | 69 +++++++++------------ apps/match/src/openapi.ts | 10 +-- apps/match/src/test/integration/api.test.ts | 64 +++++++++++-------- apps/match/wrangler.jsonc | 3 +- packages/domain/src/accounts-db.ts | 6 ++ 7 files changed, 91 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index a422d22..4c22fc6 100644 --- a/.env.example +++ b/.env.example @@ -134,6 +134,11 @@ RECFLARE_DOMAIN=rec.example.com # mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share # a host, as do tachyon-4/-5. # RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778 +# +# The pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead, +# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts +# use the pool above. +# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778 # The Photon region every session is pinned to (`match`). Unlike the app ids above this # does default, to `us` (us-east1) — an instance stamped with an empty region is one the diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index bdad737..e9da14a 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -68,6 +68,13 @@ export type Env = SharedHonoEnv & { * listed twice to model two server slots on one box. */ TACHYON_HOST_PORT?: string + /** + * The Tachyon pool SANDBOX accounts are served from in place of `TACHYON_HOST_PORT` + * (`RECFLARE_TACHYON_HOST_PORT_SANDBOX` in `.env`) — the same comma-separated + * `host:port` list, with ids generated as `dev-1`, `dev-2`, …. Unset means sandbox + * accounts use the live pool. See `tachyonPoolFor` in match.app.ts. + */ + TACHYON_HOST_PORT_SANDBOX?: string /** * The Photon region every session is pinned to — both the region named in the connection * info and the one stamped on every room instance, which must agree. Optional; unlike the diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index c55f146..c6ad910 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -191,12 +191,12 @@ interface TachyonServer { * never sends the id anywhere), but it is positional, so inserting an entry renames * every server after it. */ -function tachyonPool(env: Env): TachyonServer[] { - return varOr(env.TACHYON_HOST_PORT, '') +function tachyonPool(hostPorts: string, idPrefix = 'tachyon-'): TachyonServer[] { + return hostPorts .split(',') .map((entry) => entry.trim()) .filter((entry) => entry !== '') - .map((hostPort, i) => ({ hostPort, serverId: `tachyon-${i + 1}` })) + .map((hostPort, i) => ({ hostPort, serverId: `${idPrefix}${i + 1}` })) } /** What the connection info carries when there is no Tachyon server to name. */ @@ -223,44 +223,31 @@ const NO_TACHYON_SERVER: TachyonServer = { hostPort: '', serverId: '' } * `roomInstanceId` 0 means the caller resolved to no instance at all (they're in no * room, or named one that doesn't exist); they get no server rather than server one. */ -function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer { - const pool = tachyonPool(env) +function tachyonServerFor(pool: TachyonServer[], roomInstanceId: number): TachyonServer { if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER } /** - * The Tachyon port a DEVELOPER is handed, in place of the port on the pool entry their - * instance resolved to. Developers connect to a separate Tachyon build running beside the - * live one on the same box, so only the PORT moves: the HOST stays whichever one - * {@link tachyonServerFor} picked, rather than scattering developers off the pool. + * The prefix of a sandbox server's generated id (see {@link tachyonPoolFor}) — `dev-1`, + * `dev-2` rather than the live pool's `tachyon-N`, so the (cosmetic) id the client + * displays says which pool it is on. */ -const DEVELOPER_TACHYON_PORT = '7778' +const SANDBOX_TACHYON_SERVER_ID_PREFIX = 'dev-' /** - * The id displayed for that dev server. Not `tachyon-N` — the generated ids are positional - * names for entries in the pool ({@link tachyonPool}), and the dev build is not one of - * them: it is a different server that happens to share a host. The id is cosmetic (the - * client displays it and never sends it anywhere), so it only has to say which server the - * player is on, and "dev" is that. + * The pool the caller's instance is picked out of. A SANDBOX account runs against its own + * Tachyon servers, apart from the live pool, so the whole list is swapped for + * `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same + * instance-id round-robin {@link tachyonServerFor} applies to the live pool. When that var + * is unset a sandbox account falls back to the live pool, so an operator who runs no + * sandbox servers doesn't leave those accounts without voice. */ -const DEVELOPER_TACHYON_SERVER_ID = 'dev' - -/** - * `server` with its port replaced by {@link DEVELOPER_TACHYON_PORT} and its id by - * {@link DEVELOPER_TACHYON_SERVER_ID}. An empty address is left alone — the pool is unset or - * the caller is in no instance, so there is no server to point at another port, and writing - * a bare `:7778` would hand the client an address that answers nothing (and name a server - * that isn't there). Splits on the LAST colon so a bracketed IPv6 host survives. - */ -function developerTachyonServer(server: TachyonServer): TachyonServer { - if (server.hostPort === '') return server - const colon = server.hostPort.lastIndexOf(':') - const host = colon === -1 ? server.hostPort : server.hostPort.slice(0, colon) - return { - hostPort: `${host}:${DEVELOPER_TACHYON_PORT}`, - serverId: DEVELOPER_TACHYON_SERVER_ID, - } +function tachyonPoolFor(env: Env, isSandbox: boolean): TachyonServer[] { + const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '') + return isSandbox && sandbox.trim() !== '' + ? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX) + : tachyonPool(varOr(env.TACHYON_HOST_PORT, '')) } /** @@ -2907,8 +2894,8 @@ const app = new Hono() '`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is', 'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,', '…). Both are empty when the pool is unset or the caller is in no instance. A', - 'caller holding the DEVELOPER role gets that same host on port 7778, the dev', - 'Tachyon build, with a `voiceServerId` of `dev`.', + 'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the same way,', + 'with ids `dev-1`, `dev-2`, ….', '`experiments` carries the client’s networking flags.', ].join(' '), security: AUTHED, @@ -2949,11 +2936,11 @@ const app = new Hono() } const account = await getAccount(c.env.DB, id) // The instance's server, the same one every other player in it is handed — except - // for a DEVELOPER, who is sent to the same host on the dev port, under the id - // `dev` rather than the pool entry's positional name. - const assigned = tachyonServerFor(c.env, roomInstanceId) - const tachyon = - account?.isDeveloper === true ? developerTachyonServer(assigned) : assigned + // that a SANDBOX account is picked out of the sandbox pool instead of the live one. + const tachyon = tachyonServerFor( + tachyonPoolFor(c.env, account?.isSandbox === true), + roomInstanceId + ) // Identifies the player to Photon. Signed with the shared JWT secret; the token's // `aud` is the realtime app it's for. Nothing verifies it while Photon is @@ -2977,8 +2964,8 @@ const app = new Hono() photonRoomId, // The Tachyon server this instance runs on, picked out of the operator's // pool by {@link tachyonServerFor} — empty strings when the pool is empty - // or the caller is in no instance, and on the dev port (id `dev`) for a - // developer. Empty rather than null: the client's + // or the caller is in no instance, and out of the sandbox pool for a + // sandbox account. Empty rather than null: the client's // decoder is likelier to accept a missing-value string than a null on a // string field. The presence payload's NULL_CONNECTION_INFO keeps its // nulls — that one never carries credentials. diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index b10086e..51c8c91 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -216,9 +216,9 @@ export const ConnectionExperiments = z.object({ * and `photonRoomId`, the Photon room of the instance their presence says they're in * — the same name every other player in that instance is handed. The voice fields name * the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool — - * likewise the same for everyone in the session, except that a caller holding the - * DEVELOPER role is sent to that host's port 7778 as `dev` — and are empty when the pool - * is unset or the caller is in no instance. `photonRegion` matches the one stamped + * likewise the same for everyone in the session, except that a SANDBOX account is + * picked out of the sandbox pool instead — and are empty when the pool is unset or the + * caller is in no instance. `photonRegion` matches the one stamped * on every room instance, so the two can't disagree. */ export const ConnectionInfo = z.object({ @@ -231,12 +231,12 @@ export const ConnectionInfo = z.object({ voiceConnectionInfo: z .string() .describe( - 'The instance’s Tachyon server, `host:port` (port 7778 for a developer); empty when none is configured' + 'The instance’s Tachyon server, `host:port` (out of the sandbox pool for a sandbox account); empty when none is configured' ), voiceServerId: z .string() .describe( - 'That server’s generated id (`tachyon-1`, …; `dev` for a developer); cosmetic, empty when there is none' + 'That server’s generated id (`tachyon-1`, …; `dev-1`, … for a sandbox account); cosmetic, empty when there is none' ), experiments: ConnectionExperiments, }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1f969e4..bec0aaa 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1491,22 +1491,23 @@ describe('auth-gated endpoints', () => { } }) - test('a developer is sent to the same Tachyon host on the dev port', async () => { - // Developers run against a Tachyon build listening on 7778 beside the live one, so - // only the host carries over from the instance's assignment — the dev build is not an - // entry in the pool, so it is named `dev` rather than borrowing that entry's - // positional `tachyon-N`. + test('a sandbox account is served out of the sandbox Tachyon pool', async () => { + // Sandbox accounts run against their own Tachyon servers, so the whole pool is swapped + // for them; selection inside it is the same instance-id round-robin as the live pool. await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') - .bind(JSON.stringify({ accountId: 974, username: 'Devver', isDeveloper: true })) + .bind(JSON.stringify({ accountId: 974, username: 'Sandboxer', isSandbox: true })) .run() const original = env.TACHYON_HOST_PORT + const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX try { - // One entry, so the assignment can't happen to land on 7778 by itself. env.TACHYON_HOST_PORT = '198.51.100.12:7777' + env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778' - const voiceFor = async (player: string, roomInstanceId: number) => { + const voiceFor = async (player: string, roomInstanceId?: number) => { const res = await exports.default.fetch( - `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`, + roomInstanceId === undefined + ? `${ORIGIN}/player/connection-info` + : `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`, { headers: await bearer(player) } ) const body = (await res.json()) as { @@ -1518,34 +1519,45 @@ describe('auth-gated endpoints', () => { } } + const sandboxPool = [ + { voiceConnectionInfo: '203.0.113.30:7777', voiceServerId: 'dev-1' }, + { voiceConnectionInfo: '203.0.113.30:7778', voiceServerId: 'dev-2' }, + ] + for (const photonRoomId of ['tachyon-sandbox-a', 'tachyon-sandbox-b']) { + const instance = await createRoomInstance(env.DB, { + ownerAccountId: 974, + roomId: 2, + photonRoomId, + maxCapacity: 12, + }) + expect(await voiceFor('974', instance.roomInstanceId)).toEqual( + sandboxPool[instance.roomInstanceId % sandboxPool.length] + ) + // Everyone else in that instance keeps the live pool. + expect(await voiceFor('975', instance.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + } + + // In no instance there is no server, sandbox or not. + expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' }) + + // With no sandbox pool configured, a sandbox account falls back to the live one. + env.TACHYON_HOST_PORT_SANDBOX = undefined const instance = await createRoomInstance(env.DB, { ownerAccountId: 974, roomId: 2, - photonRoomId: 'tachyon-instance-dev', + photonRoomId: 'tachyon-sandbox-unset', maxCapacity: 12, }) expect(await voiceFor('974', instance.roomInstanceId)).toEqual({ - voiceConnectionInfo: '198.51.100.12:7778', - voiceServerId: 'dev', - }) - // Everyone else in that instance keeps the pool entry's own port and id. - expect(await voiceFor('975', instance.roomInstanceId)).toEqual({ voiceConnectionInfo: '198.51.100.12:7777', voiceServerId: 'tachyon-1', }) - - // In no instance there is no server to move the port of: a bare ':7778' would be - // an address that answers nothing, named `dev`. - const none = await exports.default.fetch(`${ORIGIN}/player/connection-info`, { - headers: await bearer('974'), - }) - const body = (await none.json()) as { - value: { voiceConnectionInfo: string; voiceServerId: string } - } - expect(body.value.voiceConnectionInfo).toBe('') - expect(body.value.voiceServerId).toBe('') } finally { env.TACHYON_HOST_PORT = original + env.TACHYON_HOST_PORT_SANDBOX = originalSandbox } }) diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 948660b..f4cebea 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -68,7 +68,8 @@ }, // The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids // and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID, - // PHOTON_REGION), and the Tachyon server pool (TACHYON_HOST_PORT) — + // PHOTON_REGION), and the Tachyon server pools (TACHYON_HOST_PORT, + // TACHYON_HOST_PORT_SANDBOX) — // are deliberately NOT set here. They're injected at deploy time from // the gitignored .env (RECFLARE_, see .env.example), so swapping a room out or // pointing at your own Photon apps never means editing a versioned file. Unset — the diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index f81fe7f..92f2cfc 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -123,6 +123,12 @@ export interface Account { * `runx admin grant-moderator`. Absent/false means no role. */ isModerator?: boolean + /** + * Whether this account is a SANDBOX account — served out of the sandbox Tachyon pool + * rather than the live one (`match` `GET /player/connection-info`). Operator-set only; + * absent/false means the live pool. + */ + isSandbox?: boolean /** * Whether this account has Rec Room Plus — the paid tier the client's API calls a * `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.