[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
# 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
+7
View File
@@ -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
+28 -41
View File
@@ -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<App>()
'`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<App>()
}
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<App>()
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.
+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
* — 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,
})
+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 () => {
// 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
}
})
+2 -1
View File
@@ -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_<VAR>, see .env.example), so swapping a room out or
// 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.
*/
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.