[match] tachyon sandbox switch

This commit is contained in:
Devin Zuczek
2026-09-21 12:49:07 -04:00
parent cf0317ab5b
commit 58a794d4e2
7 changed files with 91 additions and 73 deletions
+5
View File
@@ -134,6 +134,11 @@ RECFLARE_DOMAIN=rec.example.com
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share # mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
# a host, as do tachyon-4/-5. # 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 # 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 # 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 # does default, to `us` (us-east1) — an instance stamped with an empty region is one the
+7
View File
@@ -68,6 +68,13 @@ export type Env = SharedHonoEnv & {
* listed twice to model two server slots on one box. * listed twice to model two server slots on one box.
*/ */
TACHYON_HOST_PORT?: string 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 * 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 * info and the one stamped on every room instance, which must agree. Optional; unlike the
+28 -41
View File
@@ -191,12 +191,12 @@ interface TachyonServer {
* never sends the id anywhere), but it is positional, so inserting an entry renames * never sends the id anywhere), but it is positional, so inserting an entry renames
* every server after it. * every server after it.
*/ */
function tachyonPool(env: Env): TachyonServer[] { function tachyonPool(hostPorts: string, idPrefix = 'tachyon-'): TachyonServer[] {
return varOr(env.TACHYON_HOST_PORT, '') return hostPorts
.split(',') .split(',')
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => entry !== '') .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. */ /** 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 * `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. * room, or named one that doesn't exist); they get no server rather than server one.
*/ */
function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer { function tachyonServerFor(pool: TachyonServer[], roomInstanceId: number): TachyonServer {
const pool = tachyonPool(env)
if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER
return pool[roomInstanceId % pool.length] ?? 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 * The prefix of a sandbox server's generated id (see {@link tachyonPoolFor}) — `dev-1`,
* instance resolved to. Developers connect to a separate Tachyon build running beside the * `dev-2` rather than the live pool's `tachyon-N`, so the (cosmetic) id the client
* live one on the same box, so only the PORT moves: the HOST stays whichever one * displays says which pool it is on.
* {@link tachyonServerFor} picked, rather than scattering developers off the pool.
*/ */
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 * The pool the caller's instance is picked out of. A SANDBOX account runs against its own
* names for entries in the pool ({@link tachyonPool}), and the dev build is not one of * Tachyon servers, apart from the live pool, so the whole list is swapped for
* them: it is a different server that happens to share a host. The id is cosmetic (the * `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same
* client displays it and never sends it anywhere), so it only has to say which server the * instance-id round-robin {@link tachyonServerFor} applies to the live pool. When that var
* player is on, and "dev" is that. * 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' function tachyonPoolFor(env: Env, isSandbox: boolean): TachyonServer[] {
const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '')
/** return isSandbox && sandbox.trim() !== ''
* `server` with its port replaced by {@link DEVELOPER_TACHYON_PORT} and its id by ? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX)
* {@link DEVELOPER_TACHYON_SERVER_ID}. An empty address is left alone — the pool is unset or : tachyonPool(varOr(env.TACHYON_HOST_PORT, ''))
* 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,
}
} }
/** /**
@@ -2907,8 +2894,8 @@ const app = new Hono<App>()
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is', '`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`,', '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', '…). 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', 'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the same way,',
'Tachyon build, with a `voiceServerId` of `dev`.', 'with ids `dev-1`, `dev-2`, ….',
'`experiments` carries the client’s networking flags.', '`experiments` carries the client’s networking flags.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
@@ -2949,11 +2936,11 @@ const app = new Hono<App>()
} }
const account = await getAccount(c.env.DB, id) const account = await getAccount(c.env.DB, id)
// The instance's server, the same one every other player in it is handed — except // 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 // that a SANDBOX account is picked out of the sandbox pool instead of the live one.
// `dev` rather than the pool entry's positional name. const tachyon = tachyonServerFor(
const assigned = tachyonServerFor(c.env, roomInstanceId) tachyonPoolFor(c.env, account?.isSandbox === true),
const tachyon = roomInstanceId
account?.isDeveloper === true ? developerTachyonServer(assigned) : assigned )
// Identifies the player to Photon. Signed with the shared JWT secret; the token's // 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 // `aud` is the realtime app it's for. Nothing verifies it while Photon is
@@ -2977,8 +2964,8 @@ const app = new Hono<App>()
photonRoomId, photonRoomId,
// The Tachyon server this instance runs on, picked out of the operator's // The Tachyon server this instance runs on, picked out of the operator's
// pool by {@link tachyonServerFor} — empty strings when the pool is empty // 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 // or the caller is in no instance, and out of the sandbox pool for a
// developer. Empty rather than null: the client's // sandbox account. Empty rather than null: the client's
// decoder is likelier to accept a missing-value string than a null on a // 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 // string field. The presence payload's NULL_CONNECTION_INFO keeps its
// nulls — that one never carries credentials. // nulls — that one never carries credentials.
+5 -5
View File
@@ -216,9 +216,9 @@ export const ConnectionExperiments = z.object({
* and `photonRoomId`, the Photon room of the instance their presence says they're in * 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 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 — * 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 * likewise the same for everyone in the session, except that a SANDBOX account is
* DEVELOPER role is sent to that host's port 7778 as `dev` — and are empty when the pool * picked out of the sandbox pool instead — and are empty when the pool is unset or the
* is unset or the caller is in no instance. `photonRegion` matches the one stamped * caller is in no instance. `photonRegion` matches the one stamped
* on every room instance, so the two can't disagree. * on every room instance, so the two can't disagree.
*/ */
export const ConnectionInfo = z.object({ export const ConnectionInfo = z.object({
@@ -231,12 +231,12 @@ export const ConnectionInfo = z.object({
voiceConnectionInfo: z voiceConnectionInfo: z
.string() .string()
.describe( .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 voiceServerId: z
.string() .string()
.describe( .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, experiments: ConnectionExperiments,
}) })
+38 -26
View File
@@ -1491,22 +1491,23 @@ describe('auth-gated endpoints', () => {
} }
}) })
test('a developer is sent to the same Tachyon host on the dev port', async () => { test('a sandbox account is served out of the sandbox Tachyon pool', async () => {
// Developers run against a Tachyon build listening on 7778 beside the live one, so // Sandbox accounts run against their own Tachyon servers, so the whole pool is swapped
// only the host carries over from the instance's assignment — the dev build is not an // for them; selection inside it is the same instance-id round-robin as the live pool.
// entry in the pool, so it is named `dev` rather than borrowing that entry's
// positional `tachyon-N`.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') 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() .run()
const original = env.TACHYON_HOST_PORT const original = env.TACHYON_HOST_PORT
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
try { 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 = '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( 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) } { headers: await bearer(player) }
) )
const body = (await res.json()) as { 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, { const instance = await createRoomInstance(env.DB, {
ownerAccountId: 974, ownerAccountId: 974,
roomId: 2, roomId: 2,
photonRoomId: 'tachyon-instance-dev', photonRoomId: 'tachyon-sandbox-unset',
maxCapacity: 12, maxCapacity: 12,
}) })
expect(await voiceFor('974', instance.roomInstanceId)).toEqual({ 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', voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1', 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 { } finally {
env.TACHYON_HOST_PORT = original env.TACHYON_HOST_PORT = original
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
} }
}) })
+2 -1
View File
@@ -68,7 +68,8 @@
}, },
// The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids // 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, // 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 // are deliberately NOT set here. They're injected at deploy time from
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or // the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
// pointing at your own Photon apps never means editing a versioned file. Unset — the // pointing at your own Photon apps never means editing a versioned file. Unset — the
+6
View File
@@ -123,6 +123,12 @@ export interface Account {
* `runx admin grant-moderator`. Absent/false means no role. * `runx admin grant-moderator`. Absent/false means no role.
*/ */
isModerator?: boolean 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 * 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. * `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.