From 8d167b96b2da0326afd9c3e0bd5b4e050cdc4165 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Wed, 7 Oct 2026 20:13:07 +0000 Subject: [PATCH 01/21] Release: host installer runs the agent as root (0.2.4) (#367) Brings #366 to prod. --- apps/api/install/install.sh | 51 +++++++++++++++++++++++++++++-------- build/Containerfile | 35 +++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 2f842a55..ee548c95 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -4,14 +4,17 @@ # This file is the source of what that URL serves, kept in the public # repository so anyone about to pipe it into a shell can read it first. # -# curl -fsSL https://api.nestri.io/install.sh | sh -s -- +# curl -fsSL https://api.nestri.io/install.sh | sudo sh -s -- # # What it does, in order: check this is 64-bit Linux, ask where box images # should live, download the host agent for this platform, verify it against the -# published SHA256SUMS, install it to ~/.local/bin, and hand over to -# the agent's own onboarding, which checks the machine, registers it with the -# token and starts the agent as a systemd user service. It never asks for sudo: the agent -# runs as the user who ran this. +# published SHA256SUMS, install it to /usr/local/bin, and hand over to the +# agent's own onboarding, which checks the machine, registers it with the token +# and starts the agent as a systemd service. +# +# It runs as root because the agent does: each box's VMM is jailed under a uid +# of its own, which only root can give it, and a box's network needs +# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. # # The token comes from the dashboard's Installation page. It registers one # machine, works once and lapses after an hour. @@ -21,9 +24,10 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.3" +DEFAULT_VERSION="0.2.4" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" -BIN_DIR="${NESTRI_BIN_DIR:-$HOME/.local/bin}" +BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" +STATE_DIR=/var/lib/nestri say() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } @@ -37,7 +41,31 @@ case "$(uname -m)" in x86_64|amd64) target=x86_64-unknown-linux-gnu ;; *) die "no host build for $(uname -m) yet." ;; esac -[ "$(id -u)" -ne 0 ] || die "run this as the user that will run boxes, not as root." +[ "$(id -u)" -eq 0 ] || die "run this as root: pipe it into \`sudo sh -s -- \`, as the dashboard shows." + +# --- an install from before the agent ran as root --------------------------- +# Hosts used to run the agent as the user who installed it, from a state +# directory in their home and a user unit. That user is the one sudo was run by. Their state is copied, +# never moved: the machine keeps its identity and what it already downloaded, +# and the old directory stays as it was. +OLD_HOME="" +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then + OLD_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)" +fi +OLD_STATE="${OLD_HOME:+$OLD_HOME/.nestri}" +if [ -n "$OLD_STATE" ] && [ -f "$OLD_STATE"/onboarding.json ] && [ ! -e "$STATE_DIR" ]; then + say "Moving this host's agent from $SUDO_USER to a system service…" + if command -v systemctl >/dev/null 2>&1; then + systemctl --user -M "$SUDO_USER@" disable --now nestri-host.service >/dev/null 2>&1 || true + fi + mkdir -p "$(dirname "$STATE_DIR")" + cp -a "$OLD_STATE" "$STATE_DIR" + # The box store that install was given, unless this run names another. + if [ -z "${NESTRI_BOX_STORE:-}" ] && [ -f "$OLD_HOME/.config/nestri-host/env" ]; then + NESTRI_BOX_STORE="$(sed -n 's/^NESLET_BOX_STORE=//p' "$OLD_HOME/.config/nestri-host/env" | head -n1)" + fi + say "Copied $OLD_STATE to $STATE_DIR." +fi # --- fetch ------------------------------------------------------------------ if command -v curl >/dev/null 2>&1; then @@ -106,15 +134,16 @@ say "" # again by onboarding on the new binary. A host with nothing running is # unaffected. if command -v systemctl >/dev/null 2>&1 && - systemctl --user is-active --quiet nestri-host.service 2>/dev/null; then + systemctl is-active --quiet nestri-host.service 2>/dev/null; then say "Stopping the running host agent to upgrade it…" - systemctl --user stop nestri-host.service + systemctl stop nestri-host.service fi # --- onboard ---------------------------------------------------------------- # The token goes through the environment rather than argv, so it is not in # `ps` for the length of the run. -export NESTRI_INSTALL_TOKEN="$TOKEN" NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API" +export NESTRI_INSTALL_TOKEN="$TOKEN" NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API" \ + NESLET_STATE_DIR="$STATE_DIR" if tty_ok; then exec "$BIN_DIR/nestri-host" onboard /artifacts/nvgpu/.manifest +# ─────────────────────────────────────────────────────────── +# virtio-nvgpu's host half — shipped beside the guest's, taken out on the host +# +# `vhost-user-nvgpu` serves a box's forwarded GPU and `nvgpu-userspace` +# stages the host's NVIDIA driver for its guests. They run on the host, never +# in a guest: the host agent's onboarding moves /boot/host out of this image +# the way it moves the kernel, and turns forwarding on when it finds the +# NVIDIA driver loaded. In this image so they are always from the same +# NVGPU_REF as the guest driver and the layer above. +# +# Static (musl), because they run on whatever distribution the host is, and +# this one's glibc is newer than most of them. +# ─────────────────────────────────────────────────────────── +FROM builder AS nvgpu-host-build +ARG NVGPU_GIT=https://github.com/nestrilabs/virtio-nvgpu.git +ARG NVGPU_REF=dev +RUN rustup target add x86_64-unknown-linux-musl +RUN --mount=type=cache,target=/root/.cargo/registry \ + git init -q /build/virtio-nvgpu && cd /build/virtio-nvgpu && \ + git fetch -q --depth=1 "${NVGPU_GIT}" "${NVGPU_REF}" && git checkout -q FETCH_HEAD && \ + cargo build --release --target x86_64-unknown-linux-musl -p device --features vhost-user \ + --bin vhost-user-nvgpu --bin nvgpu-userspace && \ + for b in vhost-user-nvgpu nvgpu-userspace; do \ + install -Dm755 "target/x86_64-unknown-linux-musl/release/$b" "/artifacts/nvgpu-host/boot/host/$b" && \ + strip "/artifacts/nvgpu-host/boot/host/$b"; \ + done && \ + git rev-parse HEAD > /artifacts/nvgpu-host/boot/host/virtio-nvgpu.rev && \ + cd / && rm -rf /build/virtio-nvgpu + # ─────────────────────────────────────────────────────────── # The guest kernel — shipped inside the image it boots # @@ -473,9 +502,11 @@ COPY --from=mesa-build /artifacts/mesa / COPY --from=nestri-build /artifacts/nestri / COPY --from=nvgpu-layer-build /artifacts/nvgpu / COPY --from=umu-build /artifacts/umu / -# The kernel this image is booted with, for the host to take out. See the -# kernel-build stage. +# The kernel this image is booted with, and the host's half of GPU +# forwarding, for the host to take out. See the kernel-build and +# nvgpu-host-build stages. COPY --from=kernel-build /artifacts/kernel / +COPY --from=nvgpu-host-build /artifacts/nvgpu-host / # **No Proton here.** It is attached to a box at run time, mounted at # /nestri/compat, so this image has no Proton path for a launch to fall back to From 2c150b761d47eff31be4ce90883435434c6d248b Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 12:08:07 +0300 Subject: [PATCH 02/21] feat(box): a listed box names its machine's host and whether it is the fleet The desktop app gives a new cloud user a fleet box and then opens that machine's Steam sign-in on its hostname; GET /box now says which hostname, and which box is the fleet one. --- apps/api/test/fleet.test.ts | 12 ++++++++++++ packages/core/src/box/index.ts | 31 +++++++++++++++++++++++++++---- packages/core/src/examples.ts | 4 +++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts index 628280a1..488bc619 100644 --- a/apps/api/test/fleet.test.ts +++ b/apps/api/test/fleet.test.ts @@ -123,6 +123,18 @@ describe('POST /box on the fleet', () => { expect((await Box.listByUser(p.owner.userId)).length).toBe(1); }); + test('a listed fleet box names the machine whose Steam sign-in it opens', async () => { + const f = await fleet(1); + useFleet(f.organisationId); + const p = await person('fleet-host'); + expect((await createBox(p.headers, { on: 'fleet' })).status).toBe(201); + const listed = ((await (await app.request('/box', { headers: p.headers })).json()) as any).data; + const [slug] = await sql`select slug from machine where id = ${f.machines[0]!.id}`; + expect(listed).toHaveLength(1); + expect(listed[0].fleet).toBe(true); + expect(listed[0].host).toBe(slug!.slug); + }); + test('`on` defaults to your own host', async () => { const f = await fleet(1); useFleet(f.organisationId); diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index e0b14148..6d894572 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -56,6 +56,15 @@ export namespace Box { description: 'The card it runs on, as its host reports it: what a person recognises the box by. Null until the host has reported one', example: Examples.Box.gpu + }), + host: z.string().nullable().optional().meta({ + description: + "The DNS label of the machine it is placed on: that machine's Steam sign-in is at `https://./_nestri/auth`. Listed boxes only", + example: Examples.Box.host + }), + fleet: z.boolean().optional().meta({ + description: 'Whether it is on Nestri GPUs rather than your own host. Listed boxes only', + example: Examples.Box.fleet }) }) .meta({ @@ -124,12 +133,21 @@ export namespace Box { export const listByUser = fn(Info.shape.userId, async (userId) => { return Database.use(async (tx) => { return tx - .select({ box: BoxTable, gpus: MachineTable.gpus }) + .select({ + box: BoxTable, + gpus: MachineTable.gpus, + host: MachineTable.slug, + organisationId: MachineTable.organisationId + }) .from(BoxTable) .leftJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId)) .where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted))) .orderBy(BoxTable.timeCreated) - .then((rows) => rows.map((r) => serialize(r.box, Machine.gpuName(r.gpus)))); + .then((rows) => + rows.map((r) => + serialize(r.box, Machine.gpuName(r.gpus), { host: r.host, fleet: r.organisationId !== null }) + ) + ); }); }); @@ -385,7 +403,11 @@ export namespace Box { } ); - export function serialize(input: typeof BoxTable.$inferSelect, gpu: string | null = null): z.infer { + export function serialize( + input: typeof BoxTable.$inferSelect, + gpu: string | null = null, + machine?: { host: string | null; fleet: boolean } + ): z.infer { return { id: input.id, userId: input.userId, @@ -395,7 +417,8 @@ export namespace Box { state: input.state as Info['state'], stopReason: input.stopReason, stopClean: input.stopClean, - gpu + gpu, + ...machine }; } } diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 70ba3e16..8cf82830 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -162,7 +162,9 @@ export namespace Examples { state: 'created' as const, stopReason: null, stopClean: null, - gpu: 'NVIDIA GeForce RTX 3060' + gpu: 'NVIDIA GeForce RTX 3060', + host: 'm7k2q9', + fleet: false }; export const Session = { From b52479975c7d97ba6d19e057bf48995f274169cb Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 13:22:22 +0300 Subject: [PATCH 03/21] feat(trial): the free days are Saturday, Sunday and Monday Monday in place of Friday: the window is Saturday 00:00 to Tuesday 00:00, Helsinki time, and the refusal outside it says Saturday to Monday. --- apps/api/test/trial.test.ts | 29 +++++++++++++++++------------ packages/core/src/trial/index.ts | 19 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/api/test/trial.test.ts b/apps/api/test/trial.test.ts index fce3d388..4f25fc1d 100644 --- a/apps/api/test/trial.test.ts +++ b/apps/api/test/trial.test.ts @@ -126,12 +126,12 @@ async function playedBefore( } describe('The weekend window', () => { - test('Friday 00:00 to Monday 00:00 on the trial’s clock', () => { + test('Saturday 00:00 to Tuesday 00:00 on the trial’s clock', () => { const closed = Trial.window(WEDNESDAY); expect(closed.open).toBe(false); // Summer time: midnight there is 21:00 UTC the day before. - expect(closed.start.toISOString()).toBe('2026-10-08T21:00:00.000Z'); - expect(closed.end.toISOString()).toBe('2026-10-11T21:00:00.000Z'); + expect(closed.start.toISOString()).toBe('2026-10-09T21:00:00.000Z'); + expect(closed.end.toISOString()).toBe('2026-10-12T21:00:00.000Z'); const open = Trial.window(SATURDAY); expect(open.open).toBe(true); @@ -141,13 +141,18 @@ describe('The weekend window', () => { test('a weekend with a clock change in it is still three calendar days', () => { // Summer time ends in the early hours of Sunday 25 October 2026. const w = Trial.window(new Date('2026-10-24T12:00:00Z')); - expect(w.start.toISOString()).toBe('2026-10-22T21:00:00.000Z'); - expect(w.end.toISOString()).toBe('2026-10-25T22:00:00.000Z'); + expect(w.start.toISOString()).toBe('2026-10-23T21:00:00.000Z'); + expect(w.end.toISOString()).toBe('2026-10-26T22:00:00.000Z'); }); - test('the last minute of Sunday is in; Monday 00:00 is out', () => { - expect(Trial.window(new Date('2026-10-11T20:59:00Z')).open).toBe(true); - expect(Trial.window(new Date('2026-10-11T21:00:00Z')).open).toBe(false); + test('Monday is in; Friday is not', () => { + expect(Trial.window(new Date('2026-10-12T12:00:00Z')).open).toBe(true); + expect(Trial.window(new Date('2026-10-09T12:00:00Z')).open).toBe(false); + }); + + test('the last minute of Monday is in; Tuesday 00:00 is out', () => { + expect(Trial.window(new Date('2026-10-12T20:59:00Z')).open).toBe(true); + expect(Trial.window(new Date('2026-10-12T21:00:00Z')).open).toBe(false); }); }); @@ -169,7 +174,7 @@ describe('POST /session on rented GPUs without a plan', () => { const s = await scene('trial-closed'); const { res, body } = await play(s); expect(res.status).toBe(429); - expect(body.message).toContain('Friday to Sunday'); + expect(body.message).toContain('Saturday to Monday'); }); test('two hours used this weekend: 429', async () => { @@ -179,7 +184,7 @@ describe('POST /session on rented GPUs without a plan', () => { s.box.id, s.gameId, s.owner.linkedAccountId, - new Date('2026-10-09T10:00:00Z'), + new Date('2026-10-10T08:00:00Z'), 120 ); const { res, body } = await play(s); @@ -286,9 +291,9 @@ describe('Stopping a trial run', () => { }); test('when the window closes: stopped, for the window', async () => { - at(new Date('2026-10-11T21:05:00Z')); + at(new Date('2026-10-12T21:05:00Z')); const s = await scene('stop-window'); - const id = await liveTrialRun(s, new Date('2026-10-11T20:30:00Z')); + const id = await liveTrialRun(s, new Date('2026-10-12T20:30:00Z')); const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop'); expect(stops).toEqual([ { kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'window' } diff --git a/packages/core/src/trial/index.ts b/packages/core/src/trial/index.ts index d6cf4d84..644b4eec 100644 --- a/packages/core/src/trial/index.ts +++ b/packages/core/src/trial/index.ts @@ -18,8 +18,9 @@ import { TrialClaimTable } from './trial.sql.js'; * * Three rules and nothing else: * - * - **When.** Friday 00:00 to Monday 00:00, on the clock of the place the - * hardware is run from. Outside that, a trial run is refused, and one still + * - **When.** Saturday 00:00 to Tuesday 00:00 — Saturday, Sunday and + * Monday, Monday in place of Friday since 2026-10-11 — on the clock of the + * place the hardware is run from. Outside that, a trial run is refused, and one still * going when it closes is stopped. * - **How much.** {@link HOURS} of live time per window, across every run. * Unlike a plan's allowance, this one stops a run that reaches it: the @@ -104,14 +105,14 @@ export namespace Trial { /** The weekend window an instant is in, or the next one if it is in none. */ export function window(at: Date = now()): Window { const today = localDay(at); - const sinceFriday = (today.weekday - 5 + 7) % 7; // Fri 0, Sat 1, Sun 2, Mon 3 … - const open = sinceFriday <= 2; - const friday = addDays(today, open ? -sinceFriday : 7 - sinceFriday); - const monday = addDays(friday, 3); + const sinceSaturday = (today.weekday - 6 + 7) % 7; // Sat 0, Sun 1, Mon 2, Tue 3 … + const open = sinceSaturday <= 2; + const saturday = addDays(today, open ? -sinceSaturday : 7 - sinceSaturday); + const tuesday = addDays(saturday, 3); return { open, - start: midnight(friday.year, friday.month, friday.day), - end: midnight(monday.year, monday.month, monday.day) + start: midnight(saturday.year, saturday.month, saturday.day), + end: midnight(tuesday.year, tuesday.month, tuesday.day) }; } @@ -190,7 +191,7 @@ export namespace Trial { throw new VisibleError( 'rate_limit', ErrorCodes.RateLimit.QUOTA_EXCEEDED, - `Free play on Nestri GPUs is Friday to Sunday. The next window opens ${when(w.start)}.` + `Free play on Nestri GPUs is Saturday to Monday. The next window opens ${when(w.start)}.` ); } if ((await usedSeconds(input.userId, w, at)) >= SECONDS) { From 01542cf14a5004f9c9f9657e66f5e4a2868a2c11 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 13:44:18 +0300 Subject: [PATCH 04/21] feat(session): runs wait in line for a free slot on their host A host holds only so many runs at once, and until now every waiting run was handed to it regardless; the ones it could not hold failed there. The host now says on its heartbeat how many runs it holds, and is handed only as many waiting runs as it has free. The rest wait in line: paid runs before free-weekend runs, then first come first served. Reading a waiting run returns its place in line, and says when a paid run that asked later has gone ahead, so a free player is never reordered quietly. A waiting run nobody has asked after for a minute was abandoned, and leaves the line rather than holding up everyone behind it. A host that does not report its slots is handed every run, as before. --- apps/api/app/routes/machine.ts | 8 +- apps/api/app/routes/session.ts | 2 +- apps/api/test/queue.test.ts | 158 + apps/api/test/session.test.ts | 1 + packages/core/migrations/0025_run_queue.sql | 2 + .../core/migrations/meta/0025_snapshot.json | 3865 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/machine/index.ts | 8 +- packages/core/src/machine/machine.sql.ts | 8 +- packages/core/src/session/index.ts | 143 +- packages/core/src/session/session.sql.ts | 8 +- 11 files changed, 4201 insertions(+), 9 deletions(-) create mode 100644 apps/api/test/queue.test.ts create mode 100644 packages/core/migrations/0025_run_queue.sql create mode 100644 packages/core/migrations/meta/0025_snapshot.json diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index a3728798..290268ba 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -455,6 +455,11 @@ export namespace MachineApi { description: 'Where this host can be reached, as its own endpoint id. Omit it and the stored value is left alone — a host that does not mention where it is has not moved, and an absent field must never read as "nowhere"', example: Examples.Machine.endpointId + }), + slots: z.number().int().min(0).max(64).optional().meta({ + description: + 'How many runs this host will hold at once. Omit it and the stored value is left alone. Runs beyond it wait their turn rather than being handed to a host that would refuse them', + example: 2 }) }) // A host that has nothing to add sends no body at all, which @@ -465,7 +470,8 @@ export namespace MachineApi { const body = c.req.valid('json'); const lastSeen = await Machine.touchLastSeen({ id: Actor.machineID, - endpointId: body?.endpointId + endpointId: body?.endpointId, + slots: body?.slots }); if (!lastSeen) { // The credentials authenticated but the row is gone — a host diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index c39e1567..78b73641 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -333,7 +333,7 @@ export namespace SessionApi { 'No such session, or it is not yours' ); } - return c.json({ data: session }); + return c.json({ data: { ...session, queue: await Session.queueFor(session.id) } }); } ) .post( diff --git a/apps/api/test/queue.test.ts b/apps/api/test/queue.test.ts new file mode 100644 index 00000000..3de453d3 --- /dev/null +++ b/apps/api/test/queue.test.ts @@ -0,0 +1,158 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { AccessToken } from '@nestri/core/access-token/index'; +import { Box } from '@nestri/core/box/index'; +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Game } from '@nestri/core/game/index'; +import { Identifier } from '@nestri/core/id'; +import { Machine } from '@nestri/core/machine/index'; +import { Session } from '@nestri/core/session/index'; + +import { app } from '../app/index'; + +const sql = testDb(); +const createdUserIds: string[] = []; +const createdGameIds: string[] = []; + +afterAll(async () => { + await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`; + await sql`delete from box where user_id = any(${createdUserIds})`; + await sql`delete from machine where owner_user_id = any(${createdUserIds})`; + await sql`delete from game where id = any(${createdGameIds})`; + await sql`delete from "user" where id = any(${createdUserIds})`; +}); + +/** One host, and a way to put a player in line on it. */ +async function host(label: string, steamAppId: number) { + const operator = await Fixtures.owner(`${label}-host`); + createdUserIds.push(operator.userId); + const machine = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: operator.userId, + teamId: operator.teamId, + label + }); + const [game] = await Game.upsert({ + id: Identifier.ascending('game'), + steamAppId, + slug: `queue-${steamAppId}`, + name: `Queue ${steamAppId}` + }); + createdGameIds.push(game!.id); + const headers = { + 'x-nestri-machine-id': machine.id, + 'x-nestri-machine-secret': machine.secret, + 'content-type': 'application/json' + }; + + async function player(name: string, paid: boolean) { + const owner = await Fixtures.owner(`${label}-${name}`); + createdUserIds.push(owner.userId); + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId: machine.id, + label: name, + tier: 'sm' + }); + const run = await Session.request({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId: game!.id, + linkedAccountId: owner.linkedAccountId, + trial: !paid + }); + const pat = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: owner.userId, + teamId: null, + name + }); + const read = async () => { + const res = await app.request(`/session/${run.id}`, { + headers: { authorization: `Bearer ${pat.token}` } + }); + return ((await res.json()) as any).data; + }; + return { run, read }; + } + + const beat = (slots: number) => + app.request('/machine/heartbeat', { + method: 'POST', + headers, + body: JSON.stringify({ slots }) + }); + const jobs = async () => { + const res = await app.request('/machine/jobs', { headers }); + return (((await res.json()) as any).data as any[]).filter((j) => j.kind === 'session.start'); + }; + return { machine, player, beat, jobs }; +} + +describe('the line for a full host', () => { + test('a host is handed only as many runs as it has free slots', async () => { + const h = await host('queue-slots', 7701); + const a = await h.player('a', true); + const b = await h.player('b', true); + const c = await h.player('c', true); + + // An agent that never said how many it holds gets everything, as before. + expect(await h.jobs()).toHaveLength(3); + + expect((await h.beat(2)).status).toBe(200); + expect((await h.jobs()).map((j) => j.sessionId)).toEqual([a.run.id, b.run.id]); + + await Session.setState({ id: a.run.id, state: 'live' }); + expect((await h.jobs()).map((j) => j.sessionId)).toEqual([b.run.id]); + await Session.setState({ id: b.run.id, state: 'live' }); + expect(await h.jobs()).toHaveLength(0); + expect((await c.read()).queue).toEqual({ position: 1, paidFirst: false }); + + // A slot frees and the next in line is handed it. + await Session.setState({ id: a.run.id, state: 'ended' }); + expect((await h.jobs()).map((j) => j.sessionId)).toEqual([c.run.id]); + }); + + test('paid runs go first, and a free player is told so', async () => { + const h = await host('queue-paid', 7702); + await h.beat(1); + const playing = await h.player('playing', true); + await Session.setState({ id: playing.run.id, state: 'live' }); + + const free = await h.player('free', false); + const paid = await h.player('paid', true); + + expect(await free.read()).toMatchObject({ queue: { position: 2, paidFirst: true } }); + expect(await paid.read()).toMatchObject({ queue: { position: 1, paidFirst: false } }); + + await Session.setState({ id: playing.run.id, state: 'ended' }); + expect((await h.jobs()).map((j) => j.sessionId)).toEqual([paid.run.id]); + }); + + test('a run nobody asks after leaves the line', async () => { + const h = await host('queue-gone', 7703); + await h.beat(1); + const playing = await h.player('playing', true); + await Session.setState({ id: playing.run.id, state: 'live' }); + const gone = await h.player('gone', true); + const here = await h.player('here', true); + + await sql`update session set time_created = now() - interval '6 minutes' where id = ${gone.run.id}`; + await sql`update session set time_created = now() - interval '5 minutes' where id = ${here.run.id}`; + expect((await here.read()).queue.position).toBe(2); + + await Session.setState({ id: playing.run.id, state: 'ended' }); + expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]); + const left = await Session.fromID(gone.run.id); + expect(left).toMatchObject({ state: 'ended', errorMessage: 'Left the queue' }); + }); + + test('a run with a slot is not in line', async () => { + const h = await host('queue-none', 7704); + const a = await h.player('a', true); + await Session.setState({ id: a.run.id, state: 'live' }); + expect((await a.read()).queue).toBeNull(); + }); +}); diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 09c22e6a..1b747f90 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -725,6 +725,7 @@ describe('POST /session/:id/ticket', () => { 'gameId', 'id', 'linkedAccountId', + 'queue', 'state', 'ticket', 'tier', diff --git a/packages/core/migrations/0025_run_queue.sql b/packages/core/migrations/0025_run_queue.sql new file mode 100644 index 00000000..f28ab117 --- /dev/null +++ b/packages/core/migrations/0025_run_queue.sql @@ -0,0 +1,2 @@ +ALTER TABLE "machine" ADD COLUMN "slots" integer;--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "time_seen" timestamp with time zone; \ No newline at end of file diff --git a/packages/core/migrations/meta/0025_snapshot.json b/packages/core/migrations/meta/0025_snapshot.json new file mode 100644 index 00000000..f632bd3e --- /dev/null +++ b/packages/core/migrations/meta/0025_snapshot.json @@ -0,0 +1,3865 @@ +{ + "id": "4fad2fe6-89da-40b9-a125-096df5f7fe91", + "prevId": "264ccda1-c4d3-409c-903e-4defa36fac10", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index c1c55eda..63ba6fef 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1791634593049, "tag": "0024_box_sizes_move_up", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1791715304549, + "tag": "0025_run_queue", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index d04e8bc7..64daf345 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -338,7 +338,10 @@ export namespace Machine { * alone and leave the two facts disagreeing. */ export const touchLastSeen = fn( - Info.pick({ id: true }).extend({ endpointId: EndpointId.optional() }), + Info.pick({ id: true }).extend({ + endpointId: EndpointId.optional(), + slots: z.number().int().min(0).optional() + }), async (input) => { try { return await Database.use(async (tx) => { @@ -350,7 +353,8 @@ export namespace Machine { // that does not mention where it is has not moved, and // clearing the column would deregister a working host // from every route that reads it. - ...(input.endpointId ? { endpointId: input.endpointId } : {}) + ...(input.endpointId ? { endpointId: input.endpointId } : {}), + ...(input.slots !== undefined ? { slots: input.slots } : {}) }) .where(eq(MachineTable.id, input.id)) .returning({ lastSeen: MachineTable.lastSeen }) diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index 63b40c7b..304b086f 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -1,5 +1,5 @@ import { sql } from 'drizzle-orm'; -import { check, index, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; +import { check, index, integer, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps, ulid, utc } from '../db/types.js'; import { OrganisationTable } from '../organisation/organisation.sql.js'; @@ -101,7 +101,11 @@ export const MachineTable = pgTable( // calls the machine ("AMD Radeon RX 9060 XT"), and the first measured part // of the capacity a scheduler will place by. Null until a report carries // them; never filled in by guessing. - gpus: jsonb('gpus').$type<{ model: string; vramMib?: number; pciId: string }[]>() + gpus: jsonb('gpus').$type<{ model: string; vramMib?: number; pciId: string }[]>(), + // How many runs this host will hold at once, as its last heartbeat said. + // Null for an agent that does not say, which is handed every waiting + // run as before; a number is what makes the rest wait their turn. + slots: integer('slots') }, (t) => [ uniqueIndex('machine_secret_hash_unique').on(t.secretHash), diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index d5fe255d..f78938e7 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; +import { and, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; import z from 'zod'; import { Burn } from '../billing/burn.js'; @@ -10,6 +10,7 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { GameTable } from '../game/game.sql.js'; +import { MachineTable } from '../machine/machine.sql.js'; import { Trial } from '../trial/index.js'; import { SessionState, SessionTable } from './session.sql.js'; @@ -32,6 +33,29 @@ export namespace Session { */ export const BOX_BUSY = 'That box already has a run that has not stopped'; + /** + * A run waiting for a slot on its host. + * + * Runs on Nestri GPUs that are paid for go ahead of free-weekend runs, and + * within each, first come first served. That reorders a free player, so it + * is said rather than done quietly: `paidFirst` is true when a paid run that + * asked after this one is ahead of it. + */ + export const Queue = z.object({ + position: z.number().int().min(1).meta({ description: 'Place in line, 1 is next', example: 3 }), + paidFirst: z.boolean().meta({ + description: 'Whether a paid run that asked later has gone ahead of this one', + example: false + }) + }); + + /** + * A waiting run nobody has asked after for this long has been abandoned — + * the app was closed — and leaves the line. A client polls every few + * seconds while it waits, so a minute is many missed polls, not one. + */ + export const ABANDONED_AFTER_SECONDS = 60; + export const Info = z .object({ id: z.string().meta({ @@ -84,6 +108,10 @@ export namespace Session { errorMessage: z.string().nullable().optional().meta({ description: 'Why it failed, when it did', example: Examples.Session.errorMessage + }), + queue: Queue.nullable().optional().meta({ + description: + 'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting' }) }) .meta({ @@ -426,6 +454,8 @@ export namespace Session { .filter((run) => !seen.has(run.sessionId)) .map((run): Job => ({ kind: 'session.stop', ...run })) ]; + await leaveAbandoned(machineId); + const free = await freeSlots(machineId); const starts = await Database.use(async (tx) => { return tx .select({ session: SessionTable, box: BoxTable, game: GameTable }) @@ -440,7 +470,9 @@ export namespace Session { isNull(BoxTable.timeDeleted) ) ) - .orderBy(SessionTable.timeCreated) + // Paid ahead of free, then first come first served. See {@link Queue}. + .orderBy(SessionTable.trial, SessionTable.timeCreated) + .limit(free) .then((rows) => rows.map( (row): Job => ({ @@ -460,6 +492,113 @@ export namespace Session { return [...stops, ...starts]; }); + /** The runs a host is holding: claimed and not yet over. */ + const holding = (machineId: string) => + Database.use((tx) => + tx + .select({ n: count() }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(BoxTable.machineId, machineId), + inArray(SessionTable.state, ['starting', 'live']), + isNull(SessionTable.timeStopped), + isNull(SessionTable.timeDeleted) + ) + ) + .then((rows) => rows[0]?.n ?? 0) + ); + + /** + * How many waiting runs a host may be handed now. + * + * Its own count of slots, less what it already holds. A host that has not + * said how many it holds is handed every waiting run, as before there was a + * line; a run it cannot hold fails there, which is what it did then. + */ + export const freeSlots = fn(z.string(), async (machineId) => { + const slots = await Database.use((tx) => + tx + .select({ slots: MachineTable.slots }) + .from(MachineTable) + .where(eq(MachineTable.id, machineId)) + .then((rows) => rows[0]?.slots ?? null) + ); + if (slots === null) return 1000; + return Math.max(0, slots - (await holding(machineId))); + }); + + /** + * End the waiting runs on a host that nobody has asked after. See + * {@link ABANDONED_AFTER_SECONDS}. Only waiting runs: a run that has a slot + * is ended by its host, never from here. + */ + export const leaveAbandoned = fn(z.string(), async (machineId) => { + await Database.use((tx) => + tx + .update(SessionTable) + .set({ + state: 'ended', + timeStopped: sql`now()`, + errorMessage: 'Left the queue' + }) + .where( + and( + eq(SessionTable.state, 'requested'), + isNull(SessionTable.timeDeleted), + inArray( + SessionTable.boxId, + tx.select({ id: BoxTable.id }).from(BoxTable).where(eq(BoxTable.machineId, machineId)) + ), + sql`coalesce(${SessionTable.timeSeen}, ${SessionTable.timeCreated}) < now() - make_interval(secs => ${ABANDONED_AFTER_SECONDS})` + ) + ) + ); + }); + + /** + * Where a waiting run is in line, or null when it is not waiting. + * + * Ahead of it: every waiting run on the same host that the host would be + * handed first — paid before free, then older first. Reading it also marks + * the run as asked after, which is what keeps it in line. + */ + export const queueFor = fn(z.string(), async (id): Promise | null> => { + return Database.use(async (tx) => { + const mine = await tx + .update(SessionTable) + .set({ timeSeen: sql`now()` }) + .where(and(eq(SessionTable.id, id), eq(SessionTable.state, 'requested'))) + .returning() + .then((rows) => rows.at(0)); + if (!mine) return null; + const machine = await tx + .select({ machineId: BoxTable.machineId }) + .from(BoxTable) + .where(eq(BoxTable.id, mine.boxId)) + .then((rows) => rows[0]!.machineId); + const waiting = await tx + .select({ trial: SessionTable.trial, timeCreated: SessionTable.timeCreated }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(BoxTable.machineId, machine), + eq(SessionTable.state, 'requested'), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) + ); + const before = (r: { trial: boolean; timeCreated: Date }) => + r.trial !== mine.trial ? !r.trial : r.timeCreated < mine.timeCreated; + return { + position: waiting.filter(before).length + 1, + paidFirst: mine.trial && waiting.some((r) => !r.trial && r.timeCreated > mine.timeCreated) + }; + }); + }); + /** * The same run as {@link forMachine}, as the row rather than as the shape * that goes out. diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts index f990632c..6be4d446 100644 --- a/packages/core/src/session/session.sql.ts +++ b/packages/core/src/session/session.sql.ts @@ -88,7 +88,13 @@ export const SessionTable = pgTable( * and the database agree; without it the next generated migration adds a * column that already exists and fails wherever it has run once. */ - claimToken: text('claim_token') + claimToken: text('claim_token'), + /** + * When the person waiting for this run last asked after it. A run that + * is still waiting for a free slot and nobody has asked about in a while + * has been abandoned — the app was closed — and leaves the line. + */ + timeSeen: utc('time_seen') }, (t) => [ index('session_box_idx').on(t.boxId), From 3d381673f8b4058e1e7030e39dba57b5757a87bc Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 14:07:28 +0300 Subject: [PATCH 05/21] chore(install): host agent 0.2.13 --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 4b883bc1..ce9ca2d1 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.12" +DEFAULT_VERSION="0.2.13" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From eb20ab83094f094ce14e8ca7d8b793743ab8079b Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 14:13:44 +0300 Subject: [PATCH 06/21] feat(install): the desktop app downloads from a link that never changes /download/windows and /download/linux redirect to a one-minute signed URL for the current desktop app, from the same private bucket as the host agent, so a download page can link to them once and every download still passes through the API. Which version they hand out is a constant bumped by a commit after a release is published, as the host installer's is. /desktop.sh installs it on Linux for the calling user: one binary in ~/.local/bin and a launcher entry, no root. --- apps/api/app/routes/install.ts | 88 ++++++++++++++++++++++------- apps/api/install/desktop-version.ts | 6 ++ apps/api/install/desktop.sh | 36 ++++++++++++ apps/api/test/download.test.ts | 22 ++++++++ 4 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 apps/api/install/desktop-version.ts create mode 100644 apps/api/install/desktop.sh create mode 100644 apps/api/test/download.test.ts diff --git a/apps/api/app/routes/install.ts b/apps/api/app/routes/install.ts index 935bc3d0..1d2bdb03 100644 --- a/apps/api/app/routes/install.ts +++ b/apps/api/app/routes/install.ts @@ -5,6 +5,8 @@ import { describeRoute } from 'hono-openapi'; // The installer, embedded at build time so the script and the API that redeems // its token always ship as one version. import script from '../../install/install.sh' with { type: 'text' }; +import desktopScript from '../../install/desktop.sh' with { type: 'text' }; +import { DESKTOP_VERSION } from '../../install/desktop-version'; import { presignGet } from '../utils/presign'; /** @@ -16,12 +18,37 @@ import { presignGet } from '../utils/presign'; */ export namespace InstallApi { /** What may be downloaded: one component, versions and asset names by shape. */ - const COMPONENTS = new Set(['host']); + const COMPONENTS = new Set(['host', 'desktop']); const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/; const ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SIGNED_SECONDS = 60; + /** The desktop app's asset for each platform a person can download it for. */ + const DESKTOP_ASSETS: Record = { + windows: 'Nestri.exe', + linux: 'nestri-linux-x86_64' + }; + + function releases() { + const env = Env.get(); + if ( + !env.RELEASES_BUCKET || + !env.RELEASES_ENDPOINT || + !env.RELEASES_ACCESS_KEY_ID || + !env.RELEASES_SECRET_ACCESS_KEY + ) { + return null; + } + return { + endpoint: env.RELEASES_ENDPOINT, + bucket: env.RELEASES_BUCKET, + region: env.RELEASES_REGION, + accessKeyId: env.RELEASES_ACCESS_KEY_ID, + secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY + }; + } + export const route = new Hono() .get( '/install.sh', @@ -55,26 +82,49 @@ export namespace InstallApi { if (!COMPONENTS.has(component) || !VERSION.test(version) || !ASSET.test(asset)) { return c.notFound(); } - const env = Env.get(); - if ( - !env.RELEASES_BUCKET || - !env.RELEASES_ENDPOINT || - !env.RELEASES_ACCESS_KEY_ID || - !env.RELEASES_SECRET_ACCESS_KEY - ) { + const bucket = releases(); + if (!bucket) { return c.json({ message: 'Downloads are not configured on this deployment.' }, 503); } - const url = await presignGet( - { - endpoint: env.RELEASES_ENDPOINT, - bucket: env.RELEASES_BUCKET, - region: env.RELEASES_REGION, - accessKeyId: env.RELEASES_ACCESS_KEY_ID, - secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY - }, - `${component}/${version}/${asset}`, - SIGNED_SECONDS - ); + const url = await presignGet(bucket, `${component}/${version}/${asset}`, SIGNED_SECONDS); + return c.redirect(url, 302); + } + ) + .get( + '/desktop.sh', + describeRoute({ + tags: ['Install'], + summary: 'The desktop app installer for Linux', + description: + 'A POSIX shell script that installs the desktop app for the calling user, in ~/.local/bin with a launcher entry. Pipe it to `sh`.', + responses: { 200: { description: 'The script' } } + }), + (c) => + c.body(desktopScript, 200, { + 'content-type': 'text/x-shellscript; charset=utf-8', + 'cache-control': 'no-cache' + }) + ) + .get( + '/download/:platform', + describeRoute({ + tags: ['Install'], + summary: 'Download the desktop app', + description: + 'Redirects to a short-lived signed URL for the current desktop app on `windows` or `linux`. This link does not change between versions.', + responses: { + 302: { description: 'Where to download it' }, + 404: { description: 'Not available for that platform' } + } + }), + async (c) => { + const asset = DESKTOP_ASSETS[c.req.param('platform')]; + if (!asset) return c.notFound(); + const bucket = releases(); + if (!bucket) { + return c.json({ message: 'Downloads are not configured on this deployment.' }, 503); + } + const url = await presignGet(bucket, `desktop/${DESKTOP_VERSION}/${asset}`, SIGNED_SECONDS); return c.redirect(url, 302); } ); diff --git a/apps/api/install/desktop-version.ts b/apps/api/install/desktop-version.ts new file mode 100644 index 00000000..ed4cfd70 --- /dev/null +++ b/apps/api/install/desktop-version.ts @@ -0,0 +1,6 @@ +/** + * The desktop app every download link hands out. Bumped by a commit once a + * release of that version is published, the same way the host installer's + * default is, so what a person downloads changes only through review. + */ +export const DESKTOP_VERSION = '0.1.0'; diff --git a/apps/api/install/desktop.sh b/apps/api/install/desktop.sh new file mode 100644 index 00000000..84125756 --- /dev/null +++ b/apps/api/install/desktop.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Installs the Nestri desktop app for the calling user: one binary in +# ~/.local/bin and a launcher entry. No root, nothing outside $HOME. +# +# curl -fsSL https://api.nestri.io/desktop.sh | sh +set -eu + +API="${NESTRI_API:-https://api.nestri.io}" +BIN="${XDG_BIN_HOME:-$HOME/.local/bin}" +APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) ;; + *) echo "Nestri runs on x86_64 Linux and Windows for now." >&2; exit 1 ;; +esac + +mkdir -p "$BIN" "$APPS" +tmp="$BIN/.nestri.$$" +trap 'rm -f "$tmp"' EXIT +echo "Downloading Nestri..." +curl -fSL --progress-bar "$API/download/linux" -o "$tmp" +chmod 755 "$tmp" +mv "$tmp" "$BIN/nestri" + +cat > "$APPS/nestri.desktop" < { + test('the Linux installer is served as a script', async () => { + const res = await app.request('/desktop.sh'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('shellscript'); + expect(await res.text()).toContain('/download/linux'); + }); + + test('a platform that is not offered is not found', async () => { + expect((await app.request('/download/macos')).status).toBe(404); + }); + + test('a desktop asset is a known component', async () => { + // No bucket in tests: the route got past its allow-list to the config check. + const res = await app.request('/install/desktop/0.1.0/Nestri.exe'); + expect([302, 503]).toContain(res.status); + }); +}); From 1d5b62a50173ff3f5648214353aab15ae6311a30 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 17:33:18 +0300 Subject: [PATCH 07/21] fix(api): a Steam sign-in on any host links the account A sign-in on a person's own host recorded the enrolment but never linked the Steam account, so their first launch asked them to link it again with nothing in the app to do it from. The QR sign-in is the same proof on their host as on ours. (cherry picked from commit 24ad9f6b788ef39568606e6b10d9ef532b127e33) --- apps/api/app/routes/enrolment.ts | 27 ++++++++------------------- apps/api/test/enrolment.test.ts | 6 +++--- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/apps/api/app/routes/enrolment.ts b/apps/api/app/routes/enrolment.ts index 1391e62d..e7eff5e2 100644 --- a/apps/api/app/routes/enrolment.ts +++ b/apps/api/app/routes/enrolment.ts @@ -1,7 +1,5 @@ import { Actor } from '@nestri/core/actor'; -import { Env } from '@nestri/core/env'; import { ErrorCodes, VisibleError } from '@nestri/core/error'; -import { Machine } from '@nestri/core/machine/index'; import { Enrolment } from '@nestri/core/steam/enrolment'; import { Identity } from '@nestri/core/user/identity'; import { Hono } from 'hono'; @@ -31,32 +29,23 @@ import { ErrorResponses, machineOnly, Result, validator } from '../utils'; */ export namespace EnrolmentApi { /** - * A sign-in on one of Nestri's own machines is also proof of the Steam - * account, so it links it: the person scanned a QR code on hardware we - * run, the Steam id is the subject of the token Valve issued there, and - * who they are came from the edge. Asking them to sign in to Steam a - * second time, in a browser, would prove nothing new. - * - * **Only the fleet.** On a host somebody brought, the report is that - * person's machine vouching for itself, and a machine can say anything. - * Linking from it would let any host operator attach any Steam account to - * any user enrolled on it. + * A Steam sign-in on a host is also proof of the Steam account, so it + * links it: the person scanned a QR code with the Steam app, the Steam id + * is the subject of the token Valve issued there, and who they are came + * from the edge. Asking them to sign in to Steam a second time, in a + * browser, would prove nothing new. * * Never a reason to refuse the report. The sign-in happened and the * enrolment is recorded either way; a Steam account already linked to * somebody else stays theirs, and is left for the person to sort out on * the website. */ - async function linkIfFleet(machineId: string, userId: string, steamId: string) { - const fleet = Env.get().FLEET_ORGANISATION_ID; - if (!fleet) return; - const machine = await Machine.fromID(machineId); - if (machine?.organisationId !== fleet) return; + async function link(machineId: string, userId: string, steamId: string) { try { await Identity.linkSteam({ userId, steamId }); } catch (err) { if (err instanceof VisibleError) { - console.warn('a fleet sign-in was not linked', { machineId, userId, reason: err.message }); + console.warn('a sign-in was not linked', { machineId, userId, reason: err.message }); return; } throw err; @@ -102,7 +91,7 @@ export namespace EnrolmentApi { userId: body.userId, steamId: body.steamId }); - await linkIfFleet(Actor.machineID, body.userId, body.steamId); + await link(Actor.machineID, body.userId, body.steamId); return c.json({ data: enrolment }); } ) diff --git a/apps/api/test/enrolment.test.ts b/apps/api/test/enrolment.test.ts index 9331c35f..9e56106d 100644 --- a/apps/api/test/enrolment.test.ts +++ b/apps/api/test/enrolment.test.ts @@ -393,7 +393,7 @@ describe('The enrolment surface refuses a token', () => { }); }); -describe('A sign-in on the fleet links the Steam account', () => { +describe('A sign-in on a host links the Steam account', () => { /** A fleet host the API knows is the fleet, and a person to enrol on it. */ async function fleetHost(label: string) { const operator = await Fixtures.owner(`${label}-operator`); @@ -436,11 +436,11 @@ describe('A sign-in on the fleet links the Steam account', () => { expect(await linked(steamId(9001))).toHaveLength(1); }); - test('a host somebody brought cannot link anything', async () => { + test('a host somebody brought links it too', async () => { const host = await registeredHost('own-host-link'); const res = await enrol(host, { userId: host.userId, steamId: steamId(9002) }); expect(res.status).toBe(200); - expect(await linked(steamId(9002))).toHaveLength(0); + expect((await linked(steamId(9002))).map((r) => r.user_id)).toEqual([host.userId]); }); test('an account already linked elsewhere stays there, and the report still lands', async () => { From 5f00d9bb9b6b37ffedba9fd6510a963bcb1ca4cd Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 17:33:47 +0300 Subject: [PATCH 08/21] chore(install): desktop app 0.1.1, host agent 0.2.14 --- apps/api/install/desktop-version.ts | 2 +- apps/api/install/install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/install/desktop-version.ts b/apps/api/install/desktop-version.ts index ed4cfd70..62522fa2 100644 --- a/apps/api/install/desktop-version.ts +++ b/apps/api/install/desktop-version.ts @@ -3,4 +3,4 @@ * release of that version is published, the same way the host installer's * default is, so what a person downloads changes only through review. */ -export const DESKTOP_VERSION = '0.1.0'; +export const DESKTOP_VERSION = '0.1.1'; diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index ce9ca2d1..47e147a2 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.13" +DEFAULT_VERSION="0.2.14" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From 00f1efdcb1f1ebff84c2eec6b31fe52a007e924f Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 18:35:49 +0300 Subject: [PATCH 09/21] chore(install): host agent 0.2.15 --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 47e147a2..a05d5ad9 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.14" +DEFAULT_VERSION="0.2.15" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From 5e48dc86f77d5148b7a8080d34196709f581975d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 11 Oct 2026 18:46:55 +0300 Subject: [PATCH 10/21] chore(install): desktop app 0.1.2 --- apps/api/install/desktop-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/desktop-version.ts b/apps/api/install/desktop-version.ts index 62522fa2..afff9346 100644 --- a/apps/api/install/desktop-version.ts +++ b/apps/api/install/desktop-version.ts @@ -3,4 +3,4 @@ * release of that version is published, the same way the host installer's * default is, so what a person downloads changes only through review. */ -export const DESKTOP_VERSION = '0.1.1'; +export const DESKTOP_VERSION = '0.1.2'; From db43068d9fc91cd36dc5f0dd352a707a0fda28a8 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Tue, 13 Oct 2026 21:37:22 +0300 Subject: [PATCH 11/21] fix(billing): a returning customer's subscription reaches their team The provider reuses a customer it already has under the same email and leaves our team id off it, so the subscription arrived with no team, was acknowledged, and changed nothing: a paid plan never applied. The checkout now carries the team in its metadata, which the subscription keeps, and the webhook reads it there when the customer has none. A paid delivery that still matches no team is logged instead of dropped in silence. (cherry picked from commit 1301ff080692989a074fe2147fc757231bb055bd) --- apps/api/app/routes/billing.ts | 5 ++++ packages/core/src/billing/polar.test.ts | 33 +++++++++++++++++++++++++ packages/core/src/billing/polar.ts | 19 ++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/api/app/routes/billing.ts b/apps/api/app/routes/billing.ts index 8a003848..3c2bfbac 100644 --- a/apps/api/app/routes/billing.ts +++ b/apps/api/app/routes/billing.ts @@ -84,6 +84,11 @@ export namespace BillingApi { // would have them retry it for days against a thing that will // never become actionable. if (!delivery.teamId || !delivery.standing) { + // Said, because a paid subscription that matches no team is + // somebody paying for nothing, and a 200 alone hides it. + if (delivery.standing?.plan === 'paid') { + console.error('a paid subscription matched no team', { type: delivery.type }); + } return c.json({ data: { applied: false, type: delivery.type } }); } diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 0e65e27b..73a28d24 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -202,6 +202,39 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' }); }); + test('a customer who existed before checkout is found by the checkout metadata', async () => { + // The provider reuses a customer it already has under the same email and + // does not put our id on it, so the subscription arrives with a customer + // that has no external id. A paid subscription was dropped this way. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('c'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const body = JSON.stringify({ + type: 'subscription.active', + data: { + customer: { external_id: null }, + metadata: { teamId: 'tem_returning' }, + product_id: PAID + } + }); + const id = 'msg_returning'; + const timestamp = new Date(); + const signature = new Webhook(secret).sign(id, timestamp, body); + + const delivery = Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': signature + } + }); + + expect(delivery.teamId).toBe('tem_returning'); + expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); + }); + test('a tampered body under a valid-looking signature is refused', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 9cb06b6c..9e71c22e 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -187,6 +187,11 @@ export namespace Polar { const created = await client().checkouts.create({ products: [productId], externalCustomerId: input.teamId, + // Also on the checkout itself, which the provider copies onto the + // subscription. A customer who already existed under this email + // keeps the customer record they had, without our id on it, and + // this is then the only place the team survives. + metadata: { teamId: input.teamId }, customerEmail: input.email, successUrl: input.successUrl }); @@ -350,11 +355,15 @@ export namespace Polar { const customer = data.customer as | { externalId?: string | null; external_id?: string | null } | undefined; - // `externalId` is the team id we put on the customer. A delivery - // without one is about a customer created some other way — by hand in - // their dashboard, most likely — and there is nothing here it can - // change. - const teamId = customer?.externalId ?? customer?.external_id ?? null; + // `externalId` is the team id we put on the customer. A customer who + // existed before checkout keeps the record they had, without it, so + // the team then comes from the checkout's metadata, which the + // subscription carries. A delivery with neither was not started here. + const metadata = data.metadata as { teamId?: unknown } | undefined; + const teamId = + customer?.externalId ?? + customer?.external_id ?? + (typeof metadata?.teamId === 'string' ? metadata.teamId : null); const product = data.product as { id?: string } | undefined; const productId = From a175cdadb7f2599add0a3edaf9358f37e3deedee Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Wed, 14 Oct 2026 11:21:52 +0300 Subject: [PATCH 12/21] chore(install): host agent 0.2.16 --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index a05d5ad9..eb588f00 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.15" +DEFAULT_VERSION="0.2.16" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From bd249422df4e88bfe3f0750c316d5a62fa36be7d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 08:21:39 +0000 Subject: [PATCH 13/21] Games that draw with OpenGL or Chromium no longer stream a black screen (#402) Guest image fixes from #401, plus the dev changes that had not reached prod yet. - **OpenGL games render** (Dome Keeper, confirmed on an AMD host). The guest's Mesa gains its EGL/GLX frontends, and `vm.overcommit_memory=1` stops a large reservation being refused before it is used. - **Chromium / NW.js games get past their first frame** (Sheepy). nescope answers every commit, and paces Xwayland windows too. - **Proton patch step.** `build/patches/proton/` and the `-nN` tag. Nothing uses it until hosts are pointed at the new tag. - Also included: the pixelforge update already on dev. It is in the image tested today. **Versions kept, not downgraded.** Dev still had host agent 0.2.13 and desktop 0.1.0. The last commit restores prod's 0.2.16 and 0.1.2, so this merge changes no install versions. **After merge.** `make push` from this prod commit publishes the guest image. The host agent release that raises LAYOUT to 6 makes hosts pull it. --------- Co-authored-by: DatCaptainHorse --- Cargo.lock | 173 +++++++++--------- apps/nescapture/Cargo.toml | 2 +- apps/nescope/src/handlers.rs | 31 ++-- apps/nescope/src/state.rs | 5 +- apps/nesinit/src/system.rs | 42 ++++- build/Containerfile | 18 +- build/Containerfile.proton | 12 ++ build/Makefile | 7 +- ...-ntdll-read-an-ASCII-locale-as-UTF-8.patch | 14 ++ 9 files changed, 190 insertions(+), 114 deletions(-) create mode 100644 build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch diff --git a/Cargo.lock b/Cargo.lock index ecb4533a..6cd030f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,7 +335,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -346,23 +346,22 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "nix", "polling", "rustix 1.1.5", - "slab", "tracing", ] [[package]] name = "cc" -version = "1.5.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -385,9 +384,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" dependencies = [ "smallvec", "target-lexicon", @@ -490,7 +489,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -680,9 +679,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -759,7 +758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -879,7 +878,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -968,9 +967,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "embedded-io" @@ -1006,7 +1005,7 @@ checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -1180,7 +1179,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -1316,9 +1315,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1422,9 +1421,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1889,9 +1888,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -1914,9 +1913,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -2011,15 +2010,15 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -2192,9 +2191,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2731,9 +2730,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2976,7 +2975,7 @@ dependencies = [ [[package]] name = "pixelforge" version = "0.9.1" -source = "git+https://github.com/DatCaptainHorse/pixelforge.git?rev=b4b7b36af6f9cbd0fedae220915edd75de933743#b4b7b36af6f9cbd0fedae220915edd75de933743" +source = "git+https://github.com/DatCaptainHorse/pixelforge.git?rev=21bb5cdb2ae128eb762e4750378916aac8dd3c82#21bb5cdb2ae128eb762e4750378916aac8dd3c82" dependencies = [ "ash", "futures-channel", @@ -3128,9 +3127,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3147,7 +3146,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -3597,14 +3596,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3624,9 +3623,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3748,9 +3747,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smithay" @@ -3825,9 +3824,9 @@ checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der", @@ -3885,9 +3884,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" dependencies = [ "proc-macro2", "quote", @@ -3911,7 +3910,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -3944,7 +3943,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.6+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "version-compare", ] @@ -4004,7 +4003,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -4065,9 +4064,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4088,7 +4087,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] @@ -4115,9 +4114,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4178,14 +4177,14 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.4", @@ -4202,9 +4201,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -4225,21 +4224,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] @@ -4252,9 +4251,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4389,9 +4388,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -4489,9 +4488,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4538,9 +4537,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -4603,7 +4602,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", "wasm-bindgen-shared", ] @@ -5271,30 +5270,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -5318,15 +5317,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -5372,7 +5371,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.8", ] [[package]] diff --git a/apps/nescapture/Cargo.toml b/apps/nescapture/Cargo.toml index 58675961..d860ec4f 100644 --- a/apps/nescapture/Cargo.toml +++ b/apps/nescapture/Cargo.toml @@ -32,7 +32,7 @@ toml = "0.8" serde = { version = "1", features = ["derive"] } # Vulkan Video hardware encoding. -pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "b4b7b36af6f9cbd0fedae220915edd75de933743" } +pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "21bb5cdb2ae128eb762e4750378916aac8dd3c82" } # libc for socket options libc = "0.2" diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 2a0d341e..075ec57f 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -100,22 +100,27 @@ impl CompositorHandler for NescopeState { s }; - let Some(window) = self + // Matched on the window's surface, not its xdg toplevel: an Xwayland + // window has no toplevel. + let window = self .space .elements() - .find(|w| { - w.toplevel() - .map(|t| t.wl_surface() == &root) - .unwrap_or(false) - }) - .cloned() - else { - return; - }; + .find(|w| w.wl_surface().is_some_and(|s| *s == root)) + .cloned(); + if let Some(window) = &window { + window.on_commit(); + self.game_frame_count += 1; + } - window.on_commit(); - self.game_frame_count += 1; - self.present_now(&root, &window); + // Every commit is answered, mapped window or not. A Vulkan client + // presenting FIFO waits in the driver for this frame callback, and + // not every surface a game draws to is a window here: Chromium's GPU + // process draws for a window another process owns, which Wine's + // Wayland driver gives a surface of its own with no role. Unanswered, + // that game presents one frame and waits forever. There is no screen + // here to pace against -- capture paces itself -- so answering every + // commit costs nothing a mapped window does not already get. + self.present_now(&root); } fn destroyed(&mut self, surface: &WlSurface) { diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index b3d13fb6..b682ae66 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -735,13 +735,14 @@ impl NescopeState { } } - pub(crate) fn present_now(&mut self, root: &WlSurface, window: &Window) { + pub(crate) fn present_now(&mut self, root: &WlSurface) { let output = self.output.clone(); let now = self.clock.now(); self.presentation_seq += 1; let mut fb = OutputPresentationFeedback::new(&output); - window.take_presentation_feedback( + take_presentation_feedback_surface_tree( + root, &mut fb, |_, _| Some(output.clone()), |_, _| wp_presentation_feedback::Kind::Vsync, diff --git a/apps/nesinit/src/system.rs b/apps/nesinit/src/system.rs index 5a952c9a..8246b58a 100644 --- a/apps/nesinit/src/system.rs +++ b/apps/nesinit/src/system.rs @@ -219,11 +219,26 @@ fn devices() { /// or Wine, and the process dies — here as a `wineserver crashed` with the game /// already gone. SteamOS and every distribution that packages Proton raise it /// to this value for that reason. -const SYSCTLS: &[(&str, &str, &str)] = &[( - "vm/max_map_count", - "2147483642", - "a game that maps more than 65530 regions (Unreal Engine 5 does) dies on a failed allocation", -)]; +/// - `vm.overcommit_memory`: **the kernel's default refuses any one allocation +/// larger than RAM plus swap**, and a box has no swap, so the ceiling is the +/// box's size: 8 GiB at 1080p. Engines and drivers reserve address space far +/// past what they touch, and a reservation is refused before a byte of it is +/// used — Dome Keeper asked for 9 GiB on an 8 GiB box and never started +/// (2026-10-14). `1` grants every request and leaves the OOM killer to act on +/// what is actually used; in a box that is the game, since this process has +/// already refused it. +const SYSCTLS: &[(&str, &str, &str)] = &[ + ( + "vm/max_map_count", + "2147483642", + "a game that maps more than 65530 regions (Unreal Engine 5 does) dies on a failed allocation", + ), + ( + "vm/overcommit_memory", + "1", + "a game that reserves more address space than the box has RAM is refused it and does not start", + ), +]; /// How many files a process may hold open, which every process in the box /// inherits from here. @@ -678,6 +693,23 @@ mod tests { assert!("2147483642".parse::().unwrap() > 65530 * 1000); } + #[test] + fn a_reservation_bigger_than_the_box_is_granted() { + let root = scratch("overcommit"); + std::fs::create_dir_all(root.join("vm")).unwrap(); + std::fs::write(root.join("vm/overcommit_memory"), "0\n").unwrap(); + + apply_sysctls(&root); + + assert_eq!( + std::fs::read_to_string(root.join("vm/overcommit_memory")) + .unwrap() + .trim(), + "1" + ); + let _ = std::fs::remove_dir_all(&root); + } + /// A box on a kernel without the knob still boots; the cost is said, not /// raised. #[test] diff --git a/build/Containerfile b/build/Containerfile index 0a068d7e..4ff69045 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -80,6 +80,13 @@ ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git ARG MESA_COMMIT=a4a47003a194effad8cabe727f74985e9417476f ARG JOBS= +# OpenGL is zink: GL on top of whichever Vulkan driver the box has, radv over +# native context or the forwarded NVIDIA one, so one build serves both. Its +# frontends have to be on for any of it to be reachable. With EGL, GLX, GBM and +# GLES off, a GL game found no GL at all and ran with sound and a black screen, +# which is what three alpha testers saw. GLX needs the x11 platform for +# Xwayland's clients, and Xwayland needs GBM to give them DRI3. +# # Our patches to the amdgpu native-context path, applied on top of the pinned # commit. They are not cosmetic: upstream's winsys re-queries device-static # facts on a path that costs a synchronous round trip to the host under @@ -93,17 +100,18 @@ RUN test -n "$JOBS" || JOBS=$(nproc) && \ meson setup builddir \ -Dprefix=/usr \ -Dbuildtype=release \ - -Dplatforms=wayland \ + -Dplatforms=wayland,x11 \ -Dgallium-drivers=zink \ -Dvulkan-drivers=amd,intel \ -Damdgpu-virtio=true \ -Dintel-virtio-experimental=true \ -Dvideo-codecs=all \ - -Degl=disabled \ - -Dglx=disabled \ + -Degl=enabled \ + -Dglx=dri \ + -Dglvnd=enabled \ -Dgles1=disabled \ - -Dgles2=disabled \ - -Dgbm=disabled \ + -Dgles2=enabled \ + -Dgbm=enabled \ -Dgallium-va=disabled \ -Db_ndebug=true && \ mold -run ninja -C builddir -j${JOBS:-$(nproc)} && \ diff --git a/build/Containerfile.proton b/build/Containerfile.proton index a8721017..172eea6f 100644 --- a/build/Containerfile.proton +++ b/build/Containerfile.proton @@ -110,6 +110,18 @@ RUN pacman -Syu --noconfirm --needed \ libsm libtheora xz \ && pacman -Scc --noconfirm +# Our own patches, one directory per submodule they apply to: patches/proton/ +# wine/ applies in wine/. Its own layer, after the fetch and the dependencies, +# so a patch change costs the build and nothing before it. A patch that no +# longer applies to a new tag stops here, before the hours of building. +COPY patches/proton /build/patches/proton +RUN set -e; cd /build/patches/proton; \ + for dir in */; do \ + for p in "${dir}"*.patch; do \ + echo "applying ${p}"; \ + patch -d "/build/proton-cachyos/${dir%/}" -p1 --forward < "${p}"; \ + done; \ + done COPY scripts/proton-build.sh /build/proton-build.sh RUN GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" JOBS="${JOBS}" \ bash /build/proton-build.sh diff --git a/build/Makefile b/build/Makefile index d167742b..be252cfb 100644 --- a/build/Makefile +++ b/build/Makefile @@ -38,8 +38,13 @@ IMAGE_NAME := ghcr.io/nestrilabs/nestri/base # number in two spellings and an image whose name does not say which Proton is # inside it is worse than no image at all. Changing the tag by hand and # forgetting the version is exactly the mistake this removes. +# +# `PROTON_PATCHES` counts our own changes on top of that tag (patches/proton/). +# Bump it whenever they change, so a patched build never takes the name of the +# one before it; reset it to 0 when the tag moves. PROTON_TAG ?= cachyos-11.0-20261005-native -PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%) +PROTON_PATCHES ?= 1 +PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%)-n$(PROTON_PATCHES) PROTON_IMAGE ?= ghcr.io/nestrilabs/proton-cachyos-native-wow64 PROTON_REF := $(PROTON_IMAGE):$(PROTON_VERSION) OUTPUT_DIR := output diff --git a/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch b/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch new file mode 100644 index 00000000..50060c15 --- /dev/null +++ b/build/patches/proton/wine/0001-ntdll-read-an-ASCII-locale-as-UTF-8.patch @@ -0,0 +1,14 @@ +--- a/dlls/ntdll/unix/env.c ++++ b/dlls/ntdll/unix/env.c +@@ -286,6 +286,11 @@ + + if (!(ctype = setlocale( LC_CTYPE, "" ))) name = "UTF-8"; + else if (!(name = nl_langinfo( CODESET ))) return; ++ /* The C and POSIX locales name plain ASCII, in which a non-ASCII file ++ * name cannot be created at all. Steam starts games with LC_ALL=C, so ++ * under it every such name failed; ASCII is a subset of UTF-8, so ++ * reading it as UTF-8 changes nothing that worked before. */ ++ if (!strcmp( name, "ANSI_X3.4-1968" )) name = "UTF-8"; + TRACE_(nls)( "Unix LC_CTYPE %s, using %s codeset\n", debugstr_a(ctype), debugstr_a(name) ); + + /* remove punctuation characters from charset name */ From 351a2d890aba82b70cd45b2a6b5e7ce1fae8d73a Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 08:57:42 +0000 Subject: [PATCH 14/21] chore(install): host agent 0.2.17 (#404) New installs get host agent 0.2.17. It is published at /install/host/0.2.17/; the binary is checksum-verified and reports `neslet 0.2.17`. 0.2.17 raises LAYOUT to 6, so hosts rebuild their guest from the current image (OpenGL frontends, nescope answering every frame). --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index eb588f00..510591fa 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.16" +DEFAULT_VERSION="0.2.17" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From bf65df7e1a4df3fd3236839e329cd6225165f4ab Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 16:33:56 +0000 Subject: [PATCH 15/21] Proton's media plugins load in boxes; billing webhooks say what they did (#408) - **Guest image (#405):** carries `libvpx`, `speex`, `libtheora`, `libvdpau` and `jsoncpp`, which Proton's GStreamer plugins link. Without them, `libgstlibav` and `libgstspeex` failed to load in every box on every Proton shipped so far. Verified: every library both published Protons need is now provided. - **Billing (#406):** every Polar webhook logs its type, the team it resolved to, the plan and status, whether it changed anything, and the subscription and customer ids. Logging only. Cut from prod rather than opened from dev: after squash merges the two histories conflict, though the files differ only in these four. This branch's tree is identical to dev's. After merge, the guest image is published from this prod commit. Host agent 0.2.18 then makes hosts pull it. --- apps/api/app/routes/billing.ts | 40 ++++++++++++++++++++-- build/Containerfile | 11 +++++++ packages/core/src/billing/polar.test.ts | 44 +++++++++++++++++++++++++ packages/core/src/billing/polar.ts | 27 ++++++++++++++- 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/api/app/routes/billing.ts b/apps/api/app/routes/billing.ts index 3c2bfbac..bd1aa1fe 100644 --- a/apps/api/app/routes/billing.ts +++ b/apps/api/app/routes/billing.ts @@ -78,6 +78,10 @@ export namespace BillingApi { }); const delivery = Polar.receive({ body, headers }); + const ids = { + subscription: delivery.subscriptionId, + customer: delivery.customerId + }; // Acknowledged rather than refused. A delivery we cannot act on is // still a delivery that arrived intact, and answering an error @@ -85,10 +89,28 @@ export namespace BillingApi { // never become actionable. if (!delivery.teamId || !delivery.standing) { // Said, because a paid subscription that matches no team is - // somebody paying for nothing, and a 200 alone hides it. + // somebody paying for nothing, and a 200 alone hides it. With + // the provider's ids, so whoever reads it can find the customer + // in the provider's dashboard and set the team's plan by hand: + // a subscription made before checkouts carried the team id + // arrives like this for as long as it lives. if (delivery.standing?.plan === 'paid') { - console.error('a paid subscription matched no team', { type: delivery.type }); + console.error( + 'a paid subscription matched no team; find the customer in the provider dashboard and set the team plan by hand', + { type: delivery.type, team: delivery.teamId, ...ids } + ); } + // Every delivery is said, applied or not. A webhook that + // changes nothing looks exactly like one that worked from the + // outside, and a cancellation on 2026-10-15 was only found to + // have missed by reading the team's row. + console.log('billing webhook', { + type: delivery.type, + team: delivery.teamId, + standing: delivery.standing, + applied: false, + ...ids + }); return c.json({ data: { applied: false, type: delivery.type } }); } @@ -97,6 +119,20 @@ export namespace BillingApi { plan: delivery.standing.plan, subscriptionStatus: delivery.standing.status }); + if (!updated) { + console.error('a subscription names a team that does not exist', { + type: delivery.type, + team: delivery.teamId, + ...ids + }); + } + console.log('billing webhook', { + type: delivery.type, + team: delivery.teamId, + standing: delivery.standing, + applied: Boolean(updated), + ...ids + }); return c.json({ data: { applied: Boolean(updated), type: delivery.type } diff --git a/build/Containerfile b/build/Containerfile index 4ff69045..e8757397 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -396,11 +396,22 @@ RUN pacman -Syu --noconfirm --needed \ pipewire pipewire-audio wireplumber opus \ python libunwind sdl2-compat \ python-xlib python-urllib3 python-pyzstd \ + libvpx speex libtheora libvdpau jsoncpp \ strace lsof less \ && rm -f /usr/share/libalpm/hooks/dbus-reload.hook \ && pacman -Rdd --noconfirm systemd systemd-sysvcompat \ && pacman -Scc --noconfirm +# `libvpx`, `speex`, `libtheora`, `libvdpau` and `jsoncpp` are not ours at all: +# they are what Proton's own GStreamer plugins link against, and Proton does +# not carry them. Without them `libgstlibav.so` and `libgstspeex.so` fail to +# load (`libvpx.so.12: cannot open shared object file`), so a game whose videos +# go through Wine's media stack plays them as nothing. Measured 2026-10-15 by +# listing every NEEDED library of the published Proton against this image; +# these five were the whole gap, for the stock build and the patched one +# alike. They track the sonames Proton was built against, so a Proton built +# on a newer Arch can need this list rebuilt with it. +# # `strace`, `lsof` and `less` are for the operator's shell (`neslet box exec`): # a root shell in a box that cannot trace a process, list what it has open or # page through a log is the situation that feature exists to get out of. They diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 73a28d24..624fb40b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -235,6 +235,50 @@ describe('Both signing schemes', () => { expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' }); }); + test('the provider\u2019s ids travel with a delivery, in either spelling', async () => { + // They are what a person looks the customer up by when a delivery + // matched no team, as a subscription made before checkouts carried the + // team id does for as long as it lives. + const { Webhook } = await import('standardwebhooks'); + const secret = `whsec_${Buffer.from('d'.repeat(32)).toString('base64')}`; + configure({ POLAR_WEBHOOK_SECRET: secret }); + + const send = (payload: object, id: string) => { + const body = JSON.stringify(payload); + const timestamp = new Date(); + return Polar.receive({ + body, + headers: { + 'webhook-id': id, + 'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'webhook-signature': new Webhook(secret).sign(id, timestamp, body) + } + }); + }; + + const unmatched = send( + { + type: 'subscription.canceled', + data: { id: 'sub_1', customer_id: 'cus_1', customer: { external_id: null }, product_id: PAID } + }, + 'msg_ids_snake' + ); + expect(unmatched.teamId).toBeNull(); + expect(unmatched.standing).toEqual({ plan: 'paid', status: 'canceled' }); + expect(unmatched.subscriptionId).toBe('sub_1'); + expect(unmatched.customerId).toBe('cus_1'); + + // Not a subscription event: its own id is the order's, and the + // subscription is a field on it. + const order = send( + { type: 'order.refunded', data: { id: 'ord_1', subscriptionId: 'sub_2', customer: { id: 'cus_2' } } }, + 'msg_ids_order' + ); + expect(order.subscriptionId).toBe('sub_2'); + expect(order.customerId).toBe('cus_2'); + expect(order.standing).toBeNull(); + }); + test('a tampered body under a valid-looking signature is refused', () => { const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`; configure({ POLAR_WEBHOOK_SECRET: secret }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 9e71c22e..705c9300 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -286,6 +286,14 @@ export namespace Polar { /** The team this is about, from the customer's external id. */ teamId: string | null; standing: Standing | null; + /** + * The provider's own ids for the subscription and the customer. Nothing + * here acts on them; they are what a person searches the provider's + * dashboard for when a delivery matched no team and has to be applied by + * hand. + */ + subscriptionId: string | null; + customerId: string | null; } /** @@ -372,7 +380,24 @@ export namespace Polar { product?.id ?? null; - return { type: event.type, teamId, standing: standingFor(event.type, productId) }; + // A subscription event's own id is the subscription's; anything else + // about one carries it as a field. + const str = (v: unknown) => (typeof v === 'string' ? v : null); + const subscriptionId = event.type.startsWith('subscription.') + ? str(data.id) + : (str(data.subscriptionId) ?? str(data.subscription_id)); + const customerId = + str(data.customerId) ?? + str(data.customer_id) ?? + str((data.customer as { id?: unknown } | undefined)?.id); + + return { + type: event.type, + teamId, + standing: standingFor(event.type, productId), + subscriptionId, + customerId + }; } ); } From 21c80ecab53c2989a402b885e30e9b772b4cf634 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 16:49:38 +0000 Subject: [PATCH 16/21] chore(install): host agent 0.2.18 (#409) New installs get host agent 0.2.18. It is published at /install/host/0.2.18/; the binary is checksum-verified and reports `neslet 0.2.18`. --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 510591fa..23a52527 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -24,7 +24,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.17" +DEFAULT_VERSION="0.2.18" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From ed465dae51d5c15bc21bfd7755c8b287c7f178d9 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 21:35:26 +0000 Subject: [PATCH 17/21] =?UTF-8?q?A=20finished=20session=20says=20why=20it?= =?UTF-8?q?=20ended;=20bash=20<(curl=20=E2=80=A6)=20-u=20updates=20a=20hos?= =?UTF-8?q?t=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Sessions (#412):** `end_reason` and `time_claimed`, with migration `0026_session_end_reason` (two nullable columns, readable by the release it replaces). `POST /session/:id/state` takes an optional `endReason`, and a run stopped before it went live can now go from `starting` to `ended`. - **Installer (#411):** - `bash <(curl -fsSL https://api.nestri.io/install.sh) -u` updates a host; `-h` prints help; a plain token installs. - When the script is not root, it asks sudo itself. - The installed host agent version is unchanged (0.2.18). **Ships before host agent 0.2.19**, which sends `endReason` and the new transition. Cut from prod with dev's files, so squash histories cannot conflict; the tree is identical to dev. --- apps/api/app/routes/session.ts | 24 +- apps/api/install/install.sh | 117 +- apps/api/test/queue.test.ts | 6 +- apps/api/test/session.test.ts | 115 +- .../migrations/0026_session_end_reason.sql | 2 + .../core/migrations/meta/0026_snapshot.json | 3877 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/examples.ts | 4 +- packages/core/src/session/index.ts | 89 +- packages/core/src/session/session.sql.ts | 12 + packages/core/src/session/session.test.ts | 57 + 11 files changed, 4278 insertions(+), 32 deletions(-) create mode 100644 packages/core/migrations/0026_session_end_reason.sql create mode 100644 packages/core/migrations/meta/0026_snapshot.json diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index 78b73641..f2cef935 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -88,10 +88,18 @@ export namespace SessionApi { errorMessage: z.string().max(1024).nullable().optional().meta({ description: 'Why it failed. Kept only for a run that did', example: Examples.Session.errorMessage + }), + endReason: Session.EndReason.nullable().optional().meta({ + description: + 'Why the run is over. Only with `ended` or `failed`; leave it out and the run records no reason', + example: 'game_exited' }) }) .strict(); + /** The states a run is over in, and so the only ones a reason goes with. */ + const TERMINAL: readonly Session.ReportableState[] = ['ended', 'failed']; + export const route = new Hono() .get( '/', @@ -385,7 +393,7 @@ export namespace SessionApi { tags: ['Session'], summary: 'Report where a run has got to', description: - 'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.', + 'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move. Say why a run ended with `endReason` on the report that ends it; sending one with any other state is 400.', responses: { 200: { content: { 'application/json': { schema: Result(Session.Info) } }, @@ -400,12 +408,24 @@ export namespace SessionApi { validator('json', StateReport), async (c) => { const body = c.req.valid('json'); + // Refused rather than dropped. A reason on a run that is still + // going is a host agent that has its states and its reasons out + // of step, and storing nothing would hide that from it. + if (body.endReason && !TERMINAL.includes(body.state)) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_PARAMETER, + `A run that is ${body.state} is not over, so it has no reason to have ended`, + 'endReason' + ); + } const result = await Session.transition({ id: c.req.valid('param').id, machineId: Actor.machineID, state: body.state, claimToken: body.claimToken, - errorMessage: body.errorMessage ?? null + errorMessage: body.errorMessage ?? null, + endReason: body.endReason ?? null }); switch (result.outcome) { diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 23a52527..1955cded 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -4,7 +4,10 @@ # This file is the source of what that URL serves, kept in the public # repository so anyone about to pipe it into a shell can read it first. # -# curl -fsSL https://api.nestri.io/install.sh | sudo sh -s -- +# bash <(curl -fsSL https://api.nestri.io/install.sh) +# bash <(curl -fsSL https://api.nestri.io/install.sh) -u +# +# Piping it works the same way: `curl -fsSL … | sh -s -- -u`. # # What it does, in order: check this is 64-bit Linux, ask where box images # should live, download the host agent for this platform, verify it against the @@ -14,10 +17,16 @@ # # It runs as root because the agent does: each box's VMM is jailed under a uid # of its own, which only root can give it, and a box's network needs -# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. +# CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. Started as +# anyone else, it asks sudo for root once, before it changes anything. # # The token comes from the dashboard's Installation page. It registers one # machine, works once and lapses after an hour. +# +# `-u` (`--update`) needs no token: on a machine that is already a host, it installs +# the current agent and restarts it, and the agent picks up whatever a new +# release changed about its images on its own. It touches nothing else -- the +# machine keeps its registration, its box store and its Steam sign-ins. set -eu @@ -32,8 +41,43 @@ STATE_DIR=/var/lib/nestri say() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } -TOKEN="${1:-${NESTRI_INSTALL_TOKEN:-}}" -[ -n "$TOKEN" ] || die "no install token. Copy the full command from the dashboard's Installation page." +usage() { + cat >&2 < make this machine a host + bash <(curl -fsSL $API/install.sh) -u update the host agent here + + -u, --update install the current host agent on a machine that is already a host + -h, --help this +USAGE +} + +MODE=install +TOKEN="${NESTRI_INSTALL_TOKEN:-}" +while [ $# -gt 0 ]; do + case "$1" in + -u|--update) MODE=update ;; + -h|--help) usage; exit 0 ;; + --) ;; + -*) usage; die "unknown option $1" ;; + *) TOKEN="$1" ;; + esac + shift +done +if [ "$MODE" = install ] && [ -z "$TOKEN" ]; then + usage + die "no install token. Copy the full command from the dashboard's Installation page." +fi + +# --- fetch ------------------------------------------------------------------ +if command -v curl >/dev/null 2>&1; then + get() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + get() { wget -qO "$2" "$1"; } +else + die "need curl or wget" +fi # --- platform --------------------------------------------------------------- [ "$(uname -s)" = Linux ] || die "a host has to run Linux (with KVM); this is $(uname -s)." @@ -41,7 +85,46 @@ case "$(uname -m)" in x86_64|amd64) target=x86_64-unknown-linux-gnu ;; *) die "no host build for $(uname -m) yet." ;; esac -[ "$(id -u)" -eq 0 ] || die "run this as root: pipe it into \`sudo sh -s -- \`, as the dashboard shows." +# --- root ------------------------------------------------------------------ +# Asked for once, here, before anything changes. A script read from a pipe or +# from `bash <(curl …)` has no file sudo could run again -- the descriptor it +# came on is this shell's, and sudo starts a fresh one -- so this fetches the +# same script to a private file and runs that as root, with the same mode and +# token. Only the settings somebody actually set are carried across. +if [ "$(id -u)" -ne 0 ]; then + command -v sudo >/dev/null 2>&1 || die "this needs root and there is no sudo here. Run it as root." + self="$(mktemp)" + get "$API/install.sh" "$self" || die "could not fetch $API/install.sh to run it as root" + if [ "$MODE" = update ]; then set -- -u; else set -- "$TOKEN"; fi + say "The host agent runs as root, so this needs sudo." + exec sudo env NESTRI_SELF="$self" NESTRI_API="$API" \ + ${NESTRI_HOST_VERSION:+NESTRI_HOST_VERSION="$NESTRI_HOST_VERSION"} \ + ${NESTRI_BIN_DIR:+NESTRI_BIN_DIR="$NESTRI_BIN_DIR"} \ + ${NESTRI_BOX_STORE:+NESTRI_BOX_STORE="$NESTRI_BOX_STORE"} \ + sh "$self" "$@" +fi +# The copy fetched above, now open as this shell's script: removing the name +# leaves what is being read intact and leaves nothing behind. +if [ -n "${NESTRI_SELF:-}" ]; then rm -f "$NESTRI_SELF"; fi + +# --- update: only where there is something to update ------------------------- +# Checked before anything is downloaded, so a mistaken run costs nothing. +if [ "$MODE" = update ]; then + # Registered means the agent has a machine identity. Without one there is + # nothing to update, and installing would need the token this skips. + [ -f "$STATE_DIR/machine.json" ] || die "this machine is not a Nestri host yet, so there is nothing to update. Install it with the command from the dashboard's Installation page." + # Updating restarts the agent, and the agent stopping stops every box. A + # session somebody is playing would end in the middle, so wait for it. + if pgrep -x nesbox >/dev/null 2>&1; then + die "a box is running on this machine right now, and updating would stop it. Run this again when nobody is playing." + fi + current="$("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}')" || current="" + if [ "$current" = "$VERSION" ]; then + say "The host agent is already $VERSION. Nothing to do." + exit 0 + fi + say "Updating the host agent from ${current:-an unknown version} to $VERSION…" +fi # --- an install from before the agent ran as root --------------------------- # Hosts used to run the agent as the user who installed it, from a state @@ -67,22 +150,14 @@ if [ -n "$OLD_STATE" ] && [ -f "$OLD_STATE"/onboarding.json ] && [ ! -e "$STATE_ say "Copied $OLD_STATE to $STATE_DIR." fi -# --- fetch ------------------------------------------------------------------ -if command -v curl >/dev/null 2>&1; then - get() { curl -fsSL "$1" -o "$2"; } -elif command -v wget >/dev/null 2>&1; then - get() { wget -qO "$2" "$1"; } -else - die "need curl or wget" -fi - # --- where box images go ---------------------------------------------------- # Their own device, xfs or ext4, and never `/`: box images are large, and a # filled root filesystem takes the whole machine down with it. The agent's # preflight checks this again; asking here is so the default is a good guess. +# An update keeps the store the machine already has, so it is not asked. tty_ok() { [ -e /dev/tty ] && (exec 3/dev/null; } BOX_STORE="${NESTRI_BOX_STORE:-}" -if [ -z "$BOX_STORE" ]; then +if [ "$MODE" = install ] && [ -z "$BOX_STORE" ]; then guess="$(df -P -T -x tmpfs -x devtmpfs -x overlay 2>/dev/null \ | awk 'NR>1 && ($2=="xfs"||$2=="ext4") && $7!="/" && $7!~/^\/(boot|efi)/ {print $5, $7}' \ | sort -rn | awk 'NR==1 {print $2}')" @@ -128,6 +203,18 @@ mv "$TMP/$ASSET" "$BIN_DIR/nestri-host" say "Installed $BIN_DIR/nestri-host" say "" +# --- update: restart on the new binary, and stop ----------------------------- +# No onboarding: the machine is registered and configured already. The agent +# reads what its new release needs when it starts -- a raised image layout +# makes it pull the guest and its jail again -- so restarting is the whole of +# it. +if [ "$MODE" = update ]; then + systemctl restart nestri-host.service || die "the new agent is installed, but restarting nestri-host failed. See: journalctl -u nestri-host" + say "Updated to $("$BIN_DIR/nestri-host" --version 2>/dev/null | awk '{print $NF}'). If this release changed the box images, the agent is downloading them now:" + say " journalctl -fu nestri-host" + exit 0 +fi + # --- upgrade ---------------------------------------------------------------- # On a host that already runs the agent, this is an upgrade: the agent holds # the host lock, and onboarding refuses to race it. Stopped here, and started diff --git a/apps/api/test/queue.test.ts b/apps/api/test/queue.test.ts index 3de453d3..81c48d50 100644 --- a/apps/api/test/queue.test.ts +++ b/apps/api/test/queue.test.ts @@ -146,7 +146,11 @@ describe('the line for a full host', () => { await Session.setState({ id: playing.run.id, state: 'ended' }); expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]); const left = await Session.fromID(gone.run.id); - expect(left).toMatchObject({ state: 'ended', errorMessage: 'Left the queue' }); + expect(left).toMatchObject({ + state: 'ended', + errorMessage: 'Left the queue', + endReason: 'left_queue' + }); }); test('a run with a slot is not in line', async () => { diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 1b747f90..8dea3a1f 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -160,11 +160,13 @@ describe('POST /session', () => { state: 'requested', tier: null, ticket: null, + timeClaimed: null, timeStarted: null, timeStopped: null, timeStopRequested: null, trial: false, - errorMessage: null + errorMessage: null, + endReason: null }); expect(body.data.id.startsWith('ses_')).toBe(true); }); @@ -478,7 +480,11 @@ describe('POST /session/:id/state', () => { body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); expect(res.status).toBe(200); - expect(((await res.json()) as any).data.state).toBe('starting'); + const claimed = ((await res.json()) as any).data; + expect(claimed.state).toBe('starting'); + // The time it was taken is what bring-up is measured from. + expect(body.data.timeClaimed).toBeNull(); + expect(claimed.timeClaimed).not.toBeNull(); const jobs = await app.request('/machine/jobs', { headers: s.host }); expect(((await jobs.json()) as any).data).toEqual([]); @@ -638,6 +644,106 @@ describe('POST /session/:id/state', () => { expect(res.status).toBe(400); }); + test('the report that ends a run says why, and the owner reads it', async () => { + const s = await scene('route-reason-ended', 5570); + const { body } = await requestSession(s); + const report = (state: string, endReason?: string) => + app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state, claimToken: HOLDER, endReason }) + }); + + await report('starting'); + await report('live'); + const ended = await report('ended', 'game_exited'); + expect(ended.status).toBe(200); + expect(((await ended.json()) as any).data.endReason).toBe('game_exited'); + + const read = await app.request(`/session/${body.data.id}`, { headers: s.user }); + expect(((await read.json()) as any).data.endReason).toBe('game_exited'); + }); + + test('a run that failed says why it is over, beside what went wrong', async () => { + const s = await scene('route-reason-failed', 5571); + const { body } = await requestSession(s); + const report = (payload: Record) => + app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ claimToken: HOLDER, ...payload }) + }); + + await report({ state: 'starting' }); + const failed = await report({ + state: 'failed', + errorMessage: 'the download could not finish', + endReason: 'launch_failed' + }); + expect(failed.status).toBe(200); + expect(((await failed.json()) as any).data).toMatchObject({ + state: 'failed', + errorMessage: 'the download could not finish', + endReason: 'launch_failed' + }); + }); + + test('a reason with a run that is not over is refused, and the run stays put', async () => { + const s = await scene('route-reason-early', 5572); + const { body } = await requestSession(s); + await app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) + }); + + const res = await app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state: 'live', claimToken: HOLDER, endReason: 'idle' }) + }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).param).toBe('endReason'); + const run = await Session.fromID(body.data.id); + expect(run?.state).toBe('starting'); + expect(run?.endReason).toBeNull(); + }); + + test('a reason nobody defined is a validation error', async () => { + const s = await scene('route-reason-bogus', 5573); + const { body } = await requestSession(s); + await app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) + }); + const res = await app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state: 'failed', claimToken: HOLDER, endReason: 'bored' }) + }); + expect(res.status).toBe(400); + expect((await Session.fromID(body.data.id))?.state).toBe('starting'); + }); + + test('a host agent that sends no reason still ends the run, with none recorded', async () => { + const s = await scene('route-reason-absent', 5574); + const { body } = await requestSession(s); + const report = (state: string) => + app.request(`/session/${body.data.id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state, claimToken: HOLDER }) + }); + + await report('starting'); + await report('live'); + // The report every host agent already in the field sends. + const ended = await report('ended'); + expect(ended.status).toBe(200); + expect(((await ended.json()) as any).data).toMatchObject({ state: 'ended', endReason: null }); + }); + test('a person cannot report a state on their own session', async () => { const s = await scene('route-claim-person', 5538); const { body } = await requestSession(s); @@ -721,6 +827,7 @@ describe('POST /session/:id/ticket', () => { const data = ((await read.json()) as any).data; expect(Object.keys(data).sort()).toEqual([ 'boxId', + 'endReason', 'errorMessage', 'gameId', 'id', @@ -729,6 +836,7 @@ describe('POST /session/:id/ticket', () => { 'state', 'ticket', 'tier', + 'timeClaimed', 'timeStarted', 'timeStopRequested', 'timeStopped', @@ -901,6 +1009,9 @@ describe('POST /session/:id/stop', () => { const out = (await res.json()) as any; expect(out.data.state).toBe('ended'); expect(out.data.timeStopped).not.toBeNull(); + // Ended here rather than by a host, so the reason is written here too. + expect(out.data.endReason).toBe('user'); + expect(out.data.timeClaimed).toBeNull(); const res2 = await app.request('/machine/jobs', { headers: s.host }); expect(((await res2.json()) as any).data).toEqual([]); }); diff --git a/packages/core/migrations/0026_session_end_reason.sql b/packages/core/migrations/0026_session_end_reason.sql new file mode 100644 index 00000000..de226d91 --- /dev/null +++ b/packages/core/migrations/0026_session_end_reason.sql @@ -0,0 +1,2 @@ +ALTER TABLE "session" ADD COLUMN "end_reason" text;--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "time_claimed" timestamp with time zone; \ No newline at end of file diff --git a/packages/core/migrations/meta/0026_snapshot.json b/packages/core/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..05fc0a5b --- /dev/null +++ b/packages/core/migrations/meta/0026_snapshot.json @@ -0,0 +1,3877 @@ +{ + "id": "c72160ef-959c-4d52-891a-f4faeda89c1b", + "prevId": "4fad2fe6-89da-40b9-a125-096df5f7fe91", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_claimed": { + "name": "time_claimed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 63ba6fef..bc97099f 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1791715304549, "tag": "0025_run_queue", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1792095717042, + "tag": "0026_session_end_reason", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 8cf82830..4671e3ab 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -177,9 +177,11 @@ export namespace Examples { // Shaped like the real thing so the docs do not teach a shorter one, // and obviously not one. claimToken: '00000000000000000000000000000000', + timeClaimed: '2026-07-28T11:59:30.000Z', timeStarted: '2026-07-28T12:00:00.000Z', timeStopped: null, - errorMessage: null + errorMessage: null, + endReason: null }; export const GameDownload = { diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index f78938e7..7b660544 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -56,6 +56,36 @@ export namespace Session { */ export const ABANDONED_AFTER_SECONDS = 60; + /** + * Why a run is over. + * + * `ended` and `failed` say how a run finished, not why, and a stop its + * owner asked for, a free weekend running out and a game quitting on its + * own all read `ended` without this. Whoever ends the run writes it: the + * control plane for the two endings it decides itself, `user` on a run + * nobody had claimed and `left_queue`, and the host agent for everything + * else, in its terminal report. ref(d-0083) + */ + export const EndReason = z + .enum([ + 'user', + 'window', + 'hours', + 'idle', + 'left_queue', + 'game_exited', + 'launch_failed', + 'host_stopped', + 'host_restart' + ]) + .meta({ + description: + 'Why a run is over. `user`: its owner asked. `window`: the free weekend closed. `hours`: this weekend’s free hours are used. `idle`: nobody was connected to it. `left_queue`: it waited for a slot and nobody asked after it. `game_exited`: the game quit on its own. `launch_failed`: it never got as far as playing. `host_stopped`: its host ended it for a reason of its own. `host_restart`: its host restarted and the run did not survive it', + example: 'user' + }); + + export type EndReason = z.infer; + export const Info = z .object({ id: z.string().meta({ @@ -87,6 +117,11 @@ export namespace Session { description: 'The size this run was asked for. Null runs at the size of its box', example: 'md' }), + timeClaimed: z.string().nullable().optional().meta({ + description: + 'When its host took the run on. Null while it waits for one, and for a run that never had one', + example: Examples.Session.timeClaimed + }), timeStarted: z.string().nullable().optional().meta({ description: 'When the box actually started, not when the row appeared', example: Examples.Session.timeStarted @@ -109,6 +144,11 @@ export namespace Session { description: 'Why it failed, when it did', example: Examples.Session.errorMessage }), + endReason: EndReason.nullable().optional().meta({ + description: + 'Why this run is over, once it is. Null while it runs, and for a run whose host did not say', + example: Examples.Session.endReason + }), queue: Queue.nullable().optional().meta({ description: 'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting' @@ -261,10 +301,11 @@ export namespace Session { * * `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both * only if unset — so a duplicate report does not extend a session someone is - * billed for, and metering can trust the pair. + * billed for, and metering can trust the pair. A reason is kept only for a + * run that is over, as an error message is kept only for one that failed. */ export const setState = fn( - Info.pick({ id: true, state: true, errorMessage: true }), + Info.pick({ id: true, state: true, errorMessage: true, endReason: true }), async (input) => { const now = sql`now()`; return Database.use(async (tx) => { @@ -273,6 +314,10 @@ export namespace Session { .set({ state: input.state, errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null, + endReason: + input.state === 'ended' || input.state === 'failed' + ? (input.endReason ?? null) + : null, ...(input.state === 'live' ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } : {}), @@ -325,10 +370,14 @@ export namespace Session { * the only mutual exclusion in this design. `ended` and `failed` are * terminal, so their entries are empty rather than absent — a state with no * exits is a fact worth writing down. + * + * `starting → ended` is a run stopped before it went live — the person + * stopping it while its game downloads, say. Nothing failed, so `failed` + * would be a lie, and it never reached `live`, so it was never billed. */ export const NEXT_STATES: Record = { requested: ['starting'], - starting: ['live', 'failed'], + starting: ['live', 'ended', 'failed'], live: ['ended', 'failed'], ended: [], failed: [] @@ -541,7 +590,8 @@ export namespace Session { .set({ state: 'ended', timeStopped: sql`now()`, - errorMessage: 'Left the queue' + errorMessage: 'Left the queue', + endReason: 'left_queue' satisfies EndReason }) .where( and( @@ -712,7 +762,12 @@ export namespace Session { // the same moment wins or loses cleanly, never both. const [cancelled] = await tx .update(SessionTable) - .set({ state: 'ended', timeStopRequested: sql`now()`, timeStopped: sql`now()` }) + .set({ + state: 'ended', + timeStopRequested: sql`now()`, + timeStopped: sql`now()`, + endReason: 'user' satisfies EndReason + }) .where(and(scope, eq(SessionTable.state, 'requested'))) .returning(); if (cancelled) return serialize(cancelled); @@ -756,7 +811,8 @@ export namespace Session { from: z.enum(SessionState.enumValues), to: z.enum(SessionState.enumValues), claimToken: ClaimToken, - errorMessage: Info.shape.errorMessage + errorMessage: Info.shape.errorMessage, + endReason: Info.shape.endReason }), async (input) => { const now = sql`now()`; @@ -772,9 +828,16 @@ export namespace Session { state: input.to, // Taken on the claim and never written again: a terminal row // still records which attempt ran it, and a holder that goes - // back to null lets a settled claim be replayed. - ...(isClaim ? { claimToken: input.claimToken } : {}), + // back to null lets a settled claim be replayed. The time is + // taken with it, so a run's bring-up can be measured from + // the moment a host had it rather than from when it asked. + ...(isClaim ? { claimToken: input.claimToken, timeClaimed: now } : {}), errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null, + // Only a run that is over has a reason to be over. Null when + // the report carries none, which is every report from a + // host agent that predates the field. + endReason: + input.to === 'ended' || input.to === 'failed' ? (input.endReason ?? null) : null, ...(input.to === 'live' ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } : {}), @@ -889,7 +952,8 @@ export namespace Session { machineId: z.string(), state: z.enum(SessionState.enumValues), claimToken: ClaimToken, - errorMessage: Info.shape.errorMessage + errorMessage: Info.shape.errorMessage, + endReason: Info.shape.endReason }), async (input): Promise => { // One transaction, because "this run is live" and "the box under it @@ -933,7 +997,8 @@ export namespace Session { from: current.state, to: input.state, claimToken: input.claimToken, - errorMessage: input.errorMessage + errorMessage: input.errorMessage, + endReason: input.endReason }); // The state read above is not the state written below, and the // gap is where two agents race. Nothing moved means somebody @@ -1084,11 +1149,13 @@ export namespace Session { state: input.state as Info['state'], tier: input.tier as Info['tier'], ticket: input.ticket, + timeClaimed: input.timeClaimed?.toISOString() ?? null, timeStarted: input.timeStarted?.toISOString() ?? null, timeStopped: input.timeStopped?.toISOString() ?? null, timeStopRequested: input.timeStopRequested?.toISOString() ?? null, trial: input.trial, - errorMessage: input.errorMessage + errorMessage: input.errorMessage, + endReason: input.endReason as Info['endReason'] }; } } diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts index 6be4d446..b0ae7e07 100644 --- a/packages/core/src/session/session.sql.ts +++ b/packages/core/src/session/session.sql.ts @@ -79,6 +79,18 @@ export const SessionTable = pgTable( trial: boolean('trial').notNull().default(false), /** Why it ended badly, when it did. */ errorMessage: text('error_message'), + /** + * Why the run is over, in one word from `Session.EndReason`, written by + * whoever ended it. Null while it runs, and null for a run ended by a + * host agent too old to say. Text rather than a database enum, so a + * reason added later is a code change and not a migration. + */ + endReason: text('end_reason'), + /** + * When a host agent claimed this run, or null while it waits. The gap + * between this and `timeStarted` is the time spent bringing it up. + */ + timeClaimed: utc('time_claimed'), /** * Which attempt holds this run. Null until an agent claims it, and never * cleared afterwards — a terminal row still records who ran it, and a diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index e98a3d74..42c6324e 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -1157,3 +1157,60 @@ describe('Session tickets need a claim first', () => { expect(closed.outcome).toBe('closed'); }); }); + +describe('Session end reasons', () => { + const report = ( + run: { machineId: string; session: { id: string } }, + state: Session.ReportableState, + endReason: Session.EndReason | null = null + ) => + Session.transition({ + claimToken: HOLDER, + id: run.session.id, + machineId: run.machineId, + state, + errorMessage: null, + endReason + }); + + test('the claim stamps when a host took the run, and nothing before it does', async () => { + const run = await requestedRun('ses-claimed-at', 5470); + expect(run.session.timeClaimed).toBeNull(); + + const claimed = await report(run, 'starting'); + expect(claimed.session?.timeClaimed).not.toBeNull(); + + // Going live is not a second claim, so the time a host took the run + // stays the time it took it. + const live = await report(run, 'live'); + expect(live.session?.timeClaimed).toBe(claimed.session!.timeClaimed!); + }); + + test('a reason is kept only for a run that is over', async () => { + const run = await requestedRun('ses-reason-terminal', 5471); + await report(run, 'starting'); + + // The write itself refuses to give a running run a reason, whoever + // calls it, so no row can say it ended while it is still going. + const live = await report(run, 'live', 'idle'); + expect(live.outcome).toBe('moved'); + expect(live.session?.endReason).toBeNull(); + + const ended = await report(run, 'ended', 'idle'); + expect(ended.session?.endReason).toBe('idle'); + expect((await Session.fromID(run.session.id))?.endReason).toBe('idle'); + }); + + test('a run stopped before it went live ends, with its reason, and was never billed', async () => { + const run = await requestedRun('ses-stopped-starting', 5472); + await report(run, 'starting'); + + // Stopped while its game downloaded: nothing failed, and it never + // reached `live`, which is where billing starts. + const ended = await report(run, 'ended', 'user'); + expect(ended.outcome).toBe('moved'); + expect(ended.session?.state).toBe('ended'); + expect(ended.session?.endReason).toBe('user'); + expect(ended.session?.timeStarted).toBeNull(); + }); +}); From fdd78b81bf275921f6217b6d68e0ff4cbe81770c Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Thu, 15 Oct 2026 22:04:32 +0000 Subject: [PATCH 18/21] chore(install): host agent 0.2.19 (#414) New hosts and `-u` updates install host agent 0.2.19: a finished run records why it ended, and a stop during a game's download stops the run. --- apps/api/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index 1955cded..ff3e3c71 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -33,7 +33,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.18" +DEFAULT_VERSION="0.2.19" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri From fd9a0c4508d7acea5b2645d856781865070dd666 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 16 Oct 2026 18:00:00 +0000 Subject: [PATCH 19/21] feat: add a host by approving it in a browser, without pasting a token (#417) Releases #416 without install.sh, which waits for a host agent that can sign in. --- apps/api/app/middleware/auth.ts | 21 ++ apps/api/app/routes/machine.ts | 11 +- apps/api/test/install-token.test.ts | 220 ++++++++++++++++++ apps/auth/script/preview.ts | 12 + apps/auth/src/index.ts | 43 +++- apps/auth/test/device.test.ts | 148 ++++++++++++ packages/auth/src/issuer.ts | 121 +++++++--- packages/auth/src/ui/issuer.ts | 18 +- packages/auth/test/device.test.ts | 121 +++++++++- packages/core/src/machine/index.ts | 126 ++++++---- .../core/src/machine/install-token.test.ts | 82 +++++++ packages/core/src/machine/install-token.ts | 56 ++++- packages/core/src/machine/machine.test.ts | 52 +++++ 13 files changed, 950 insertions(+), 81 deletions(-) create mode 100644 apps/api/test/install-token.test.ts create mode 100644 apps/auth/test/device.test.ts diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts index 21e0244a..98246f75 100644 --- a/apps/api/app/middleware/auth.ts +++ b/apps/api/app/middleware/auth.ts @@ -84,6 +84,16 @@ function getClient(env: Record) { }); } +/** + * A token minted for the host installer is good for one call: trading it for + * a single-use install token. The installer signs a person in on a machine + * that may not be theirs, so the session it holds must not be able to do + * anything else with the account — and checking it here, once, means a route + * added later cannot forget to. + */ +const INSTALLER_AUDIENCE = 'installer'; +const INSTALLER_ROUTE = { method: 'POST', path: '/machine/install-token' }; + export const auth: MiddlewareHandler = async (c, next) => { // A registered nessh host proves it is itself, rather than asserting an id // nobody checks. Wrong credentials fall through to public rather than @@ -188,6 +198,17 @@ export const auth: MiddlewareHandler = async (c, next) => { return Actor.with({ type: 'public', properties: {} }, next); } + if ( + verified.aud === INSTALLER_AUDIENCE && + !(c.req.method === INSTALLER_ROUTE.method && c.req.path === INSTALLER_ROUTE.path) + ) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'This sign-in can only add a host' + ); + } + const { subject } = verified; if (subject.type === 'user') { const teamID = c.req.header('x-nestri-team'); diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 290268ba..07c047a6 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -55,7 +55,8 @@ export namespace MachineApi { description: 'The box is registered' }, 401: ErrorResponses[401], - 403: ErrorResponses[403] + 403: ErrorResponses[403], + 429: ErrorResponses[429] } }), validator( @@ -176,7 +177,7 @@ export namespace MachineApi { tags: ['Machine'], summary: 'Issue an install token', description: - 'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.', + 'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history. One person may make five an hour and hold three unspent at once, and a team may own at most ten hosts; fleet hosts are not counted.', responses: { 200: { content: { @@ -194,7 +195,8 @@ export namespace MachineApi { description: 'A token for one host' }, 401: ErrorResponses[401], - 403: ErrorResponses[403] + 403: ErrorResponses[403], + 429: ErrorResponses[429] } }), validator( @@ -291,7 +293,8 @@ export namespace MachineApi { }, description: 'The host is registered' }, - 401: ErrorResponses[401] + 401: ErrorResponses[401], + 429: ErrorResponses[429] } }), validator( diff --git a/apps/api/test/install-token.test.ts b/apps/api/test/install-token.test.ts new file mode 100644 index 00000000..a252b31e --- /dev/null +++ b/apps/api/test/install-token.test.ts @@ -0,0 +1,220 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Env } from '@nestri/core/env'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; + +import { app } from '../app/index'; +import { TEST_FRONTEND_URL } from './setup'; + +const sql = testDb(); +const createdUserIds: string[] = []; + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from machine where owner_user_id in ${sql(createdUserIds)}`; + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + } +}); + +/** + * An issuer, reached the way the API reaches one in production: through a + * binding that serves its discovery document and its keys. Tokens are signed + * here with the claims the real issuer writes, `aud` being the client id. + */ +const ISSUER = 'https://auth.test'; +const keys = await generateKeyPair('ES256'); +const jwk = { ...(await exportJWK(keys.publicKey)), kid: 'test', alg: 'ES256', use: 'sig' }; +const env = { + AUTH: { + fetch: async (request: Request) => { + const { pathname } = new URL(request.url); + if (pathname === '/.well-known/oauth-authorization-server') { + return Response.json({ issuer: ISSUER, jwks_uri: `${ISSUER}/.well-known/jwks.json` }); + } + if (pathname === '/.well-known/jwks.json') return Response.json({ keys: [jwk] }); + return new Response('not found', { status: 404 }); + } + } +}; + +beforeEach(() => { + Env.init({ NODE_ENV: 'test', FRONTEND_URL: TEST_FRONTEND_URL, AUTH_ISSUER_URL: ISSUER }); +}); + +/** An access token for `userId`, minted for `clientID` and so carrying it as `aud`. */ +async function tokenFor(userId: string, clientID: string): Promise { + return new SignJWT({ + mode: 'access', + type: 'user', + properties: { userID: userId, linkedAccountID: '' } + }) + .setProtectedHeader({ alg: 'ES256', kid: 'test', typ: 'JWT' }) + .setIssuer(ISSUER) + .setAudience(clientID) + .setSubject(`user:${userId}`) + .setExpirationTime('15m') + .sign(keys.privateKey); +} + +async function person(label: string) { + const owner = await Fixtures.owner(label); + createdUserIds.push(owner.userId); + return owner; +} + +function call(method: string, path: string, token: string, body?: unknown) { + return app.request( + path, + { + method, + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) + }, + env + ); +} + +describe('a sign-in made by the installer', () => { + test('can ask for an install token', async () => { + const owner = await person('aud-ok'); + const response = await call( + 'POST', + '/machine/install-token', + await tokenFor(owner.userId, 'installer'), + {} + ); + expect(response.status).toBe(200); + const body = (await response.json()) as any; + expect(body.data.token).toStartWith('nit_'); + }); + + test('can do nothing else', async () => { + const owner = await person('aud-refused'); + const token = await tokenFor(owner.userId, 'installer'); + + const elsewhere: [string, string][] = [ + ['GET', '/user'], + ['GET', '/machine'], + ['POST', '/machine/register'], + ['POST', '/access-token'] + ]; + for (const [method, path] of elsewhere) { + const response = await call(method, path, token, method === 'POST' ? {} : undefined); + expect(response.status).toBe(403); + expect((await response.json()) as any).toEqual({ + type: 'forbidden', + code: 'insufficient_permissions', + message: 'This sign-in can only add a host' + }); + } + }); + + test('every other client is unaffected', async () => { + const owner = await person('aud-desktop'); + const token = await tokenFor(owner.userId, 'desktop'); + expect((await call('GET', '/user', token)).status).toBe(200); + expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200); + }); +}); + +describe('how many install tokens one person may have', () => { + test('three unspent at once, then a 429 a terminal can print', async () => { + const owner = await person('limit-outstanding'); + const token = await tokenFor(owner.userId, 'installer'); + for (let i = 0; i < 3; i++) { + expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200); + } + const refused = await call('POST', '/machine/install-token', token, {}); + expect(refused.status).toBe(429); + expect((await refused.json()) as any).toEqual({ + type: 'rate_limit', + code: 'too_many_requests', + message: + 'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.' + }); + }); + + test('five an hour, even when every one was spent', async () => { + const owner = await person('limit-hourly'); + const token = await tokenFor(owner.userId, 'installer'); + for (let i = 0; i < 5; i++) { + const issued = await call('POST', '/machine/install-token', token, {}); + expect(issued.status).toBe(200); + const { data } = (await issued.json()) as any; + const installed = await app.request('/machine/install', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: data.token, label: `host-${i}` }) + }); + expect(installed.status).toBe(200); + } + const refused = await call('POST', '/machine/install-token', token, {}); + expect(refused.status).toBe(429); + expect((await refused.json()) as any).toEqual({ + type: 'rate_limit', + code: 'too_many_requests', + message: 'You have made 5 install tokens in the last hour. Try again later.' + }); + }); +}); + +describe('how many machines a team may own', () => { + const full = { + type: 'rate_limit', + code: 'quota_exceeded', + message: 'Your team already has 10 machines. Remove one to add another.' + }; + + function install(token: string, label: string) { + return app.request('/machine/install', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token, label }) + }); + } + + test('a full team is not handed an install token', async () => { + const owner = await person('cap-mint'); + for (let i = 0; i < 10; i++) await Fixtures.machine(owner, `box-${i}`); + const refused = await call( + 'POST', + '/machine/install-token', + await tokenFor(owner.userId, 'installer'), + {} + ); + expect(refused.status).toBe(429); + expect((await refused.json()) as any).toEqual(full); + }); + + test('a token minted with room left cannot add an eleventh', async () => { + const owner = await person('cap-redeem'); + for (let i = 0; i < 9; i++) await Fixtures.machine(owner, `box-${i}`); + const issued = await call( + 'POST', + '/machine/install-token', + await tokenFor(owner.userId, 'installer'), + {} + ); + const { data } = (await issued.json()) as any; + await Fixtures.machine(owner, 'the-last-place'); + + const refused = await install(data.token, 'eleventh'); + expect(refused.status).toBe(429); + expect((await refused.json()) as any).toEqual(full); + }); + + test('registering directly is held to the same limit', async () => { + const owner = await person('cap-register'); + for (let i = 0; i < 10; i++) await Fixtures.machine(owner, `box-${i}`); + const refused = await call( + 'POST', + '/machine/register', + await tokenFor(owner.userId, 'desktop'), + { label: 'eleventh' } + ); + expect(refused.status).toBe(429); + expect((await refused.json()) as any).toEqual(full); + }); +}); diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts index 491e9f63..fb6ffad7 100644 --- a/apps/auth/script/preview.ts +++ b/apps/auth/script/preview.ts @@ -23,6 +23,7 @@ import { MARK_CODE, MARK_GITHUB, MARK_GOOGLE } from '@nestri/auth/ui/mark'; import { HtmlRenderer } from '@nestri/auth/ui/render'; import type { Screen } from '@nestri/auth/ui/screen'; +import { deviceConfirm } from '../src/index.js'; import { THEME_NESTRI } from '../src/theme.js'; const PORT = Number(process.env.PORT ?? 1338); @@ -120,6 +121,17 @@ const STATES: State[] = [ csrf: 'x' }) }, + { + id: 'device-confirm-installer', + group: 'Device', + label: 'Add a host?', + screen: () => + IssuerScreens.deviceConfirm({ + userCode: 'BCDF3467', + csrf: 'x', + wording: deviceConfirm('installer') + }) + }, { id: 'device-approved', group: 'Device', diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 3772db92..837f530a 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -45,7 +45,44 @@ type Env = { * ours: the identifier ends up on the issued token, and without this anything * on the internet could mint a grant naming anything at all. */ -const DEVICE_CLIENTS = new Set(['desktop']); +const DEVICE_CLIENTS = new Set(['desktop', 'installer']); + +export const allowDeviceClient = async (clientID: string) => DEVICE_CLIENTS.has(clientID); + +/** + * What the person approving a device client is asked, where it is not the + * generic "is this you?". + * + * Approving the installer adds a computer to the person's account as a host, + * which is a bigger thing to agree to than signing an app in, so the page says + * that in as many words. Fixed per client id: nothing the request carries is + * shown. + */ +export const deviceConfirm = (clientID: string) => + clientID === 'installer' + ? { + heading: 'Add a computer as a Nestri host?', + body: [ + 'A computer is asking to become a host on your account. The code above should match the one in its terminal.' + ], + footer: + 'If it does not, or you did not run the installer yourself, choose Deny. Approving lets that computer add itself as one host and nothing else.' + } + : undefined; + +/** + * How long a session for each device client lasts, where it is not the + * default. + * + * The installer signs in for one step — trading the session for a single host + * token — and runs as root on a machine that may not be the person's own, so + * it gets a quarter of an hour and no refresh token: nothing it could leave + * behind outlives the install. + * + * Exported, like the list above, so both can be tested against a real grant. + */ +export const clientTTL = (clientID: string) => + clientID === 'installer' ? { access: 15 * 60, refresh: 0 } : undefined; /** * The zone every user-owned host is reached under, and the one path on it that @@ -192,7 +229,9 @@ export default { codeStore: PostgresCodeStore(), refreshStore: PostgresRefreshStore(), deviceStore: PostgresDeviceStore(), - allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID), + allowDeviceClient, + clientTTL, + deviceConfirm, // The default rule allows a redirect back to whatever hostname the // request arrived on, which is right for a site served beside this // one and refuses the proxy in front of user-owned hosts — it diff --git a/apps/auth/test/device.test.ts b/apps/auth/test/device.test.ts new file mode 100644 index 00000000..e3122bb3 --- /dev/null +++ b/apps/auth/test/device.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; + +import { issuer } from '@nestri/auth/index'; +import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { subjects } from '@nestri/core/auth/subjects'; + +import { allowDeviceClient, clientTTL, deviceConfirm } from '../src/index.js'; + +/** + * The issuer the worker builds, with the database taken out and the real rules + * about device clients left in. + * + * The provider is a stub that signs straight in, because what is under test is + * what happens around the sign-in: which programs may start a grant, how long + * the session each is handed lasts, and what the person is asked. + */ +const auth = issuer({ + subjects, + storage: MemoryStorage(), + allow: async () => true, + allowDeviceClient, + clientTTL, + deviceConfirm, + providers: { + stub: { + type: 'stub', + init(route, ctx) { + route.get('/authorize', (c) => ctx.success(c, {})); + } + } + }, + async success(context) { + return context.subject('user', { userID: 'usr_test123', linkedAccountID: '' }); + } +}); + +const ORIGIN = 'https://auth.internal'; + +/** The claims, unverified: the issuer that signed them is right here. */ +function claims(token: string) { + return JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) as { aud: string }; +} + +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} + +function form(body: Record) { + return { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body) + }; +} + +async function begin(clientID: string) { + return auth.request(`${ORIGIN}/device/authorize`, form({ client_id: clientID })); +} + +/** Start a grant and sign in, stopping at the question the person is asked. */ +async function reachConfirmation(clientID: string) { + const started = (await (await begin(clientID)).json()) as any; + const cookies = jar(); + const entered = await auth.request(`${ORIGIN}/device?user_code=${started.user_code}`); + cookies.absorb(entered); + const asked = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), { + headers: { cookie: cookies.header() } + }); + cookies.absorb(asked); + return { started, cookies, html: await asked.text() }; +} + +/** The whole grant, approved, ending in what the program is handed. */ +async function approved(clientID: string) { + const { started, cookies, html } = await reachConfirmation(clientID); + const csrf = /name="csrf" value="([^"]+)"/.exec(html)![1]!; + await auth.request(`${ORIGIN}/device/confirm`, { + ...form({ csrf, action: 'approve' }), + headers: { + cookie: cookies.header(), + 'content-type': 'application/x-www-form-urlencoded' + } + }); + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + const response = await auth.request( + `${ORIGIN}/token`, + form({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: started.device_code, + client_id: clientID + }) + ); + expect(response.status).toBe(200); + return (await response.json()) as any; +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device clients', () => { + test('only the programs this issuer knows may start a grant', async () => { + expect((await begin('desktop')).status).toBe(200); + expect((await begin('installer')).status).toBe(200); + expect((await begin('something-else')).status).toBe(400); + }); + + test('the installer gets a quarter of an hour and no refresh token', async () => { + const tokens = await approved('installer'); + expect(tokens.expires_in).toBe(15 * 60); + expect(claims(tokens.access_token).aud).toBe('installer'); + expect(tokens).not.toHaveProperty('refresh_token'); + }); + + test('approving the installer is asked as adding a host, with the code to compare', async () => { + const { started, html } = await reachConfirmation('installer'); + expect(html).toContain('Add a computer as a Nestri host?'); + expect(html).toContain('the one in its terminal'); + expect(html).toContain(started.user_code.slice(0, 4)); + expect(html).toContain(started.user_code.slice(4)); + expect(html).toContain('Approve'); + expect(html).toContain('Deny'); + }); + + test('the desktop app is asked the generic question', async () => { + const { html } = await reachConfirmation('desktop'); + expect(html).toContain('Is this you?'); + expect(html).not.toContain('Nestri host'); + }); + + test('the desktop app keeps a session it can refresh', async () => { + const tokens = await approved('desktop'); + expect(tokens.expires_in).toBe(60 * 60 * 24 * 30); + expect(claims(tokens.access_token).aud).toBe('desktop'); + expect(tokens.refresh_token).toMatch(/.+/); + }); +}); diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index d0c6d4b6..553031d1 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -231,7 +231,7 @@ import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; import { HtmlRenderer, type Renderer } from './ui/render.js'; -import { IssuerScreens } from './ui/issuer.js'; +import { type DeviceConfirmWording, IssuerScreens } from './ui/issuer.js'; import type { ChooseOption, Screen } from './ui/screen.js'; import type { Theme } from './ui/theme.js'; import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; @@ -394,6 +394,19 @@ export interface IssuerInput< */ deviceInterval?: number; }; + /** + * Token lifetimes for one client, in place of {@link ttl}. + * + * For a client whose tokens should be narrower than everybody else's — one + * that needs a session for a single step and has no business holding one + * afterwards. A `refresh` of `0` means no refresh token is issued at all, + * so the session ends when the access token does. + * + * What this returns wins over a lifetime `success` asks for: how long a + * client may hold a session is a property of the client, and a flow that + * signs somebody in has no reason to know which client is asking. + */ + clientTTL?(clientID: string): { access?: number; refresh?: number } | undefined; /** * Where device authorization grants are kept. * @@ -448,7 +461,17 @@ export interface IssuerInput< /** Length of the window, in seconds. @default 600 */ guessWindow?: number; /** - * Which caller a guess is charged to. + * Device codes one caller may ask for per window. + * + * Asking costs nothing and takes no secret, so without a limit one + * caller can fill the store with grants nobody will ever answer, and + * hold the user code space open for guessing. @default 10 + */ + requestLimit?: number; + /** Length of that window, in seconds. @default 600 */ + requestWindow?: number; + /** + * Which caller a guess, or a request for a code, is charged to. * * Defaults to the usual forwarded-address headers. Returning undefined * puts the request in one shared bucket, which is the right answer for @@ -469,6 +492,15 @@ export interface IssuerInput< * issuer that has not thought about it, and is worth thinking about. */ allowDeviceClient?(clientID: string, req: Request): Promise; + /** + * What the confirmation page says to a person approving `clientID`, where + * the generic wording would undersell what they are agreeing to. + * + * Looked up by the client id and never built from it, so a request cannot + * put words of its own in front of the person deciding on it. Undefined + * keeps the generic page. + */ + deviceConfirm?(clientID: string): DeviceConfirmWording | undefined; /** * Which providers appear on the screen offering a choice of them, and in * what order. @@ -590,6 +622,8 @@ export function issuer< const deviceStore = input.deviceStore ?? MemoryDeviceStore(); const deviceGuessLimit = input.deviceVerification?.guessLimit ?? 10; const deviceGuessWindow = input.deviceVerification?.guessWindow ?? 600; + const deviceRequestLimit = input.deviceVerification?.requestLimit ?? 10; + const deviceRequestWindow = input.deviceVerification?.requestWindow ?? 600; const deviceAddress = input.deviceVerification?.address ?? ((req: Request) => @@ -599,6 +633,15 @@ export function issuer< undefined); const renderer = input.renderer ?? HtmlRenderer({ theme: input.theme }); + /** What a token for `clientID` lives for. See {@link IssuerInput.clientTTL}. */ + function lifetime(clientID: string, requested?: { access?: number; refresh?: number }) { + const rule = input.clientTTL?.(clientID); + return { + access: rule?.access ?? requested?.access ?? ttlAccess, + refresh: rule?.refresh ?? requested?.refresh ?? ttlRefresh + }; + } + /** * The screen offering a choice of providers. * @@ -716,10 +759,7 @@ export function issuer< subject, type: type as string, properties, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(grant.clientID, subjectOpts?.ttl) } }; await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); @@ -733,14 +773,11 @@ export function issuer< type: type as string, properties, clientID: authorization.client_id, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(authorization.client_id, subjectOpts?.ttl) }); location.hash = new URLSearchParams({ access_token: tokens.access, - refresh_token: tokens.refresh, + ...(tokens.refresh ? { refresh_token: tokens.refresh } : {}), state: authorization.state || '' }).toString(); await auth.unset(ctx, 'authorization'); @@ -757,10 +794,7 @@ export function issuer< redirectURI: authorization.redirect_uri, clientID: authorization.client_id, pkce: authorization.pkce, - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(authorization.client_id, subjectOpts?.ttl) }, 60 ); @@ -781,10 +815,7 @@ export function issuer< type: type as string, properties, clientID: 'ssh', - ttl: { - access: subjectOpts?.ttl?.access ?? ttlAccess, - refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime('ssh', subjectOpts?.ttl) }); return ctx.json({ accessToken: tokens.access, @@ -879,16 +910,25 @@ export function issuer< * makes the limit worth having rather than a way to lock one person out. */ async function chargeGuess(req: Request): Promise { + const next = await charge('oauth:device:guess', req, deviceGuessWindow); + return next.count <= deviceGuessLimit; + } + + /** + * One more event against this caller's counter under `name`, in a window + * of `window` seconds that starts with the first of them. + */ + async function charge(name: string, req: Request, window: number) { const who = deviceAddress(req) ?? 'unknown'; - const key = ['oauth:device:guess', who]; + const key = [name, who]; const now = Date.now(); const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, key); const next = bucket && bucket.resetAt > now ? { count: bucket.count + 1, resetAt: bucket.resetAt } - : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; + : { count: 1, resetAt: now + window * 1000 }; await Storage.set(storage!, key, next, Math.max(1, Math.ceil((next.resetAt - now) / 1000))); - return next.count <= deviceGuessLimit; + return next; } async function guessesLeft(req: Request): Promise { @@ -934,7 +974,10 @@ export function issuer< } function deviceConfirmScreen(confirmation: DeviceConfirmation): Screen { - return IssuerScreens.deviceConfirm(confirmation); + return IssuerScreens.deviceConfirm({ + ...confirmation, + wording: input.deviceConfirm?.(confirmation.clientID) + }); } async function getAuthorization(ctx: Context) { @@ -977,8 +1020,12 @@ export function issuer< generateRefreshToken?: boolean; } ) { + // A client allowed no refresh token gets none, rather than one that + // expires at once: a credential that is never written down cannot be + // replayed, and its absence tells the client what to expect. + const refreshable = value.ttl.refresh > 0; const refreshToken = value.nextToken ?? crypto.randomUUID(); - if (opts?.generateRefreshToken ?? true) { + if (refreshable && (opts?.generateRefreshToken ?? true)) { /** * Generate and store the next refresh token after the one we are currently returning. * Reserving these in advance avoids concurrency issues with multiple refreshes. @@ -1017,7 +1064,7 @@ export function issuer< ) .sign(await signingKey().then((item) => item.private)), expiresIn: Math.floor(accessTimeUsed + value.ttl.access - Date.now() / 1000), - refresh: [value.subject, refreshToken].join(':') + refresh: refreshable ? [value.subject, refreshToken].join(':') : undefined }; } @@ -1398,10 +1445,7 @@ export function issuer< subject: opts?.subject || (await resolveSubject(type, properties)), properties, clientID: clientID.toString(), - ttl: { - access: opts?.ttl?.access ?? ttlAccess, - refresh: opts?.ttl?.refresh ?? ttlRefresh - } + ttl: lifetime(clientID.toString(), opts?.ttl) }); return c.json({ access_token: tokens.access, @@ -1440,6 +1484,25 @@ export function issuer< if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw))) return c.json({ error: 'invalid_client', error_description: 'Unknown client_id' }, 400); + // Charged per address, like a wrong user code. The answer is + // `slow_down` because it is the one code in this grant's vocabulary + // that means "too often, wait", and a device client already knows + // what to do with it; 429 and `Retry-After` say the same thing to + // anything that only reads HTTP. + const requests = await charge('oauth:device:request', c.req.raw, deviceRequestWindow); + if (requests.count > deviceRequestLimit) { + const wait = Math.max(1, Math.ceil((requests.resetAt - Date.now()) / 1000)); + const minutes = Math.ceil(wait / 60); + c.header('Retry-After', String(wait)); + return c.json( + { + error: 'slow_down', + error_description: `Too many sign-in requests from this network. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.` + }, + 429 + ); + } + // Not `randomUUID`: a device code is the credential the tokens are // handed to, so it gets the same treatment as one — full-width // randomness, and only its hash is written down. diff --git a/packages/auth/src/ui/issuer.ts b/packages/auth/src/ui/issuer.ts index 5cbe7e09..3d384df3 100644 --- a/packages/auth/src/ui/issuer.ts +++ b/packages/auth/src/ui/issuer.ts @@ -14,10 +14,23 @@ import type { Screen } from './screen.js'; +/** + * What the confirmation says, for a client that warrants its own words. + * + * Chosen by the issuer from the client id, never taken from the request: the + * id is a claim, and the words a person decides on must not be the claimant's. + */ +export interface DeviceConfirmWording { + heading: string; + body: string[]; + footer?: string; +} + /** The confirmation a device grant asks for, before it is approved. */ export interface DeviceConfirmCopy { userCode: string; csrf: string; + wording?: DeviceConfirmWording; } export const IssuerScreens = { @@ -93,12 +106,12 @@ export const IssuerScreens = { deviceConfirm(confirmation: DeviceConfirmCopy): Screen { return { kind: 'confirm', - heading: 'Is this you?', + heading: confirmation.wording?.heading ?? 'Is this you?', verify: { code: confirmation.userCode, group: 4 }, // Not the client id. It is chosen by whoever started the grant, so // naming it would let a stranger's request introduce itself as // anything it likes — and to the person reading, it is a device. - body: [ + body: confirmation.wording?.body ?? [ 'A device is asking to sign in to your account. The code above should match the one it is showing you.' ], action: '/device/confirm', @@ -106,6 +119,7 @@ export const IssuerScreens = { approve: { label: 'Approve', name: 'action', value: 'approve' }, deny: { label: 'Deny', name: 'action', value: 'deny' }, footer: + confirmation.wording?.footer ?? 'If it does not, or you did not start this on a device of your own, choose Deny. Nobody can sign in as you unless you approve here.' }; }, diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts index 2fc1f542..3f2598b7 100644 --- a/packages/auth/test/device.test.ts +++ b/packages/auth/test/device.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; +import { decodeJwt } from 'jose'; import { object, string } from 'valibot'; import { hashDeviceCode, MemoryDeviceStore } from '../src/device.js'; @@ -21,7 +22,15 @@ const auth = issuer({ subjects, allow: async () => true, allowDeviceClient: async (clientID) => clientID !== 'banned', - deviceVerification: { guessLimit: 3, guessWindow: 60 }, + clientTTL: (clientID) => (clientID === 'one-step' ? { access: 15 * 60, refresh: 0 } : undefined), + deviceConfirm: (clientID) => + clientID === 'one-step' + ? { heading: 'Let this one step through?', body: ['Words chosen for this client.'] } + : undefined, + // Every test here asks for codes from one address at one frozen instant, + // so the request limit is widened out of the way; it has its own issuer + // below, on the defaults. + deviceVerification: { guessLimit: 3, guessWindow: 60, requestLimit: 10_000 }, providers: { dummy: { type: 'dummy', @@ -285,6 +294,53 @@ describe('approval', () => { }); }); +/** + * A client that is allowed a session for one step, and nothing after it. + * + * Its lifetime is set per client rather than by the sign-in, so the person + * approving it cannot be talked into a longer one and the flow that signs them + * in does not need to know who is asking. + */ +describe('a client with a lifetime of its own', () => { + async function approved(clientID: string) { + const grant = await started(clientID); + await answer(grant.user_code, 'approve'); + setSystemTime(new Date(Date.now() + (grant.interval + 1) * 1000)); + const response = await poll(grant.device_code, clientID); + expect(response.status).toBe(200); + return response.body; + } + + test('gets the access lifetime it was given and no refresh token', async () => { + const body = await approved('one-step'); + expect(body.expires_in).toBe(15 * 60); + expect(decodeJwt(body.access_token).exp).toBe(Math.floor(Date.now() / 1000) + 15 * 60); + expect(decodeJwt(body.access_token).aud).toBe('one-step'); + expect(body).not.toHaveProperty('refresh_token'); + }); + + test('cannot refresh what it was never given', async () => { + const body = await approved('one-step'); + const subject = decodeJwt(body.access_token).sub!; + const response = await auth.request(`${ORIGIN}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: `${subject}:${crypto.randomUUID()}` + }) + }); + expect(response.status).toBe(400); + expect(((await response.json()) as any).error).toBe('invalid_grant'); + }); + + test('every other client keeps the defaults', async () => { + const body = await approved('desktop'); + expect(body.expires_in).toBe(60 * 60 * 24 * 30); + expect(body.refresh_token).toMatch(/.+/); + }); +}); + /** * The attack this flow exists to stop, and the properties that stop it. * @@ -323,6 +379,16 @@ describe('a code somebody else started', () => { expect(reached.html).toContain('A device is asking'); }); + test('a client the issuer has words for is asked about in those words', async () => { + const grant = await started('one-step'); + const reached = await signInAndReachConfirmation(grant.user_code); + + expect(reached.html).toContain('Let this one step through?'); + expect(reached.html).toContain('Words chosen for this client.'); + expect(reached.html).not.toContain('Is this you?'); + expect(reached.html).toContain(grant.user_code.slice(0, 4)); + }); + test('a confirmation posted without the value from the cookie is refused', async () => { const grant = await started(); const { cookies } = await signInAndReachConfirmation(grant.user_code); @@ -493,3 +559,56 @@ describe('guessing at user codes', () => { expect((await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZD`)).status).toBe(429); }); }); + +/** + * Asking for a code costs the caller nothing, so it is charged per address + * like a wrong guess is. On the defaults, because those are what a deployment + * that never thought about it gets. + */ +describe('asking for codes', () => { + const limited = issuer({ + storage: MemoryStorage(), + subjects, + allow: async () => true, + providers: {}, + success: async (ctx) => ctx.subject('user', { userID: '123' }) + }); + + function ask(address?: string) { + return limited.request(`${ORIGIN}/device/authorize`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(address ? { 'cf-connecting-ip': address } : {}) + }, + body: new URLSearchParams({ client_id: 'desktop' }) + }); + } + + test('a caller gets ten in ten minutes, then a 429 that says when to come back', async () => { + for (let i = 0; i < 10; i++) expect((await ask('198.51.100.20')).status).toBe(200); + + const refused = await ask('198.51.100.20'); + expect(refused.status).toBe(429); + expect(refused.headers.get('retry-after')).toBe('600'); + const body = (await refused.json()) as any; + expect(body.error).toBe('slow_down'); + expect(body.error_description).toBe( + 'Too many sign-in requests from this network. Try again in 10 minutes.' + ); + }); + + test('the window ends, and another caller was never affected', async () => { + for (let i = 0; i < 11; i++) await ask('198.51.100.21'); + expect((await ask('198.51.100.21')).status).toBe(429); + expect((await ask('198.51.100.22')).status).toBe(200); + + setSystemTime(new Date(Date.now() + 601 * 1000)); + expect((await ask('198.51.100.21')).status).toBe(200); + }); + + test('a caller with no address shares one budget', async () => { + for (let i = 0; i < 10; i++) expect((await ask()).status).toBe(200); + expect((await ask()).status).toBe(429); + }); +}); diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 64daf345..5917189d 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -10,6 +10,7 @@ import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { SessionTable } from '../session/session.sql.js'; import { Member } from '../team/member.js'; +import { TeamTable } from '../team/team.sql.js'; import { MachineTable } from './machine.sql.js'; import { Slug } from './slug.js'; @@ -149,10 +150,45 @@ export namespace Machine { return diff === 0; } + /** + * Most machines one team may own. + * + * A host somebody brings is added by a command anyone signed in can run, + * so without a ceiling one account is as many hosts as it can script. + * Hardware an organisation owns outright is not counted: its own people + * add it, deliberately, and it is never reached through a team. + */ + export const TEAM_MACHINE_LIMIT = 10; + + /** + * Refuses when `teamId` already owns {@link TEAM_MACHINE_LIMIT} machines, + * with a message the person can act on. + */ + export async function assertTeamHasRoom(teamId: string) { + const owned = await Database.use((tx) => + tx + .select({ count: sql`count(*)::int` }) + .from(MachineTable) + .where(and(eq(MachineTable.teamId, teamId), isNull(MachineTable.timeDeleted))) + .then((rows) => rows[0]?.count ?? 0) + ); + if (owned >= TEAM_MACHINE_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.QUOTA_EXCEEDED, + `Your team already has ${TEAM_MACHINE_LIMIT} machines. Remove one to add another.` + ); + } + } + /** * Register a box. The secret is returned here and nowhere else — it is * stored only as a digest, so a lost secret means re-registering rather * than looking it up. + * + * A team at {@link TEAM_MACHINE_LIMIT} is refused here, which is what holds + * the limit for every way of registering, an install token minted while + * there was still room included. */ export const register = fn( Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }) @@ -160,50 +196,62 @@ export namespace Machine { .refine((v) => (v.teamId === null) !== ((v.organisationId ?? null) === null), { message: 'A machine belongs to a team or to an organisation, and not to both' }), - async (input) => { - const secret = generateSecret(); - const secretHash = await hashSecret(secret); + async (input) => + Database.transaction(async (tx) => { + if (input.teamId) { + // Held until the machine is written, so two registrations + // at the same instant cannot both see room for one. + await tx + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(eq(TeamTable.id, input.teamId)) + .for('update'); + await assertTeamHasRoom(input.teamId); + } - // The name space holds tens of millions, so a collision is rare - // enough that retrying is cheaper than checking first -- and a - // check-then-insert would be wrong as well as slower, because two - // registrations in the same instant both read "free" and both write. - // The unique index is the thing that actually decides. - for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) { - const slug = Slug.generate(); - try { - await Database.use(async (tx) => { - await tx.insert(MachineTable).values({ - id: input.id, - ownerUserId: input.ownerUserId, - teamId: input.teamId, - organisationId: input.organisationId ?? null, - label: input.label, - slug, - secretHash, - lastSeen: null + const secret = generateSecret(); + const secretHash = await hashSecret(secret); + + // The name space holds tens of millions, so a collision is rare + // enough that retrying is cheaper than checking first -- and a + // check-then-insert would be wrong as well as slower, because two + // registrations in the same instant both read "free" and both write. + // The unique index is the thing that actually decides. + for (let attempt = 0; attempt < SLUG_ATTEMPTS; attempt++) { + const slug = Slug.generate(); + try { + await Database.use(async (tx) => { + await tx.insert(MachineTable).values({ + id: input.id, + ownerUserId: input.ownerUserId, + teamId: input.teamId, + organisationId: input.organisationId ?? null, + label: input.label, + slug, + secretHash, + lastSeen: null + }); }); - }); - return { id: input.id, secret, slug }; - } catch (err) { - if (!isUniqueViolation(err)) { - throw err; - } - // Only a name collision is worth another go. A duplicate id - // or secret means something is wrong that a new name cannot - // fix, and retrying would bury it. - if (!isSlugViolation(err)) { - throw err; + return { id: input.id, secret, slug }; + } catch (err) { + if (!isUniqueViolation(err)) { + throw err; + } + // Only a name collision is worth another go. A duplicate id + // or secret means something is wrong that a new name cannot + // fix, and retrying would bury it. + if (!isSlugViolation(err)) { + throw err; + } } } - } - throw new VisibleError( - 'internal', - ErrorCodes.Server.INTERNAL_ERROR, - 'Could not mint an unused hostname for this machine' - ); - } + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + 'Could not mint an unused hostname for this machine' + ); + }) ); /** How many names to try before giving up. See `register`. */ diff --git a/packages/core/src/machine/install-token.test.ts b/packages/core/src/machine/install-token.test.ts index d7effeee..74df5270 100644 --- a/packages/core/src/machine/install-token.test.ts +++ b/packages/core/src/machine/install-token.test.ts @@ -2,6 +2,8 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; +import { VisibleError } from '../error.js'; +import { Machine } from './index.js'; import { InstallToken } from './install-token.js'; const sql = testDb(); @@ -67,4 +69,84 @@ describe('Install tokens', () => { expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull(); expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull(); }); + + test('one person may hold three unspent at once', async () => { + const owner = await newOwner('nit-outstanding'); + const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) await mint(); + + const refused = await mint().catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.type).toBe('rate_limit'); + expect(refused.code).toBe('too_many_requests'); + expect(refused.message).toBe( + 'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.' + ); + + // One expiring frees its place. + await sql`update install_token set expires_at = now() - interval '1 minute' + where id = (select id from install_token where created_by_user_id = ${owner.userId} limit 1)`; + expect((await mint()).token).toStartWith('nit_'); + }); + + test('one person may make five an hour, spent or not', async () => { + const owner = await newOwner('nit-hourly'); + const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + for (let i = 0; i < 3; i++) { + const { token } = await mint(); + await InstallToken.redeem({ token, label: `h${i}` }); + } + await mint(); + await mint(); + + const refused = await mint().catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('too_many_requests'); + expect(refused.message).toBe( + 'You have made 5 install tokens in the last hour. Try again later.' + ); + + // The hour is rolling: once the first ones age out, there is room again. + await sql`update install_token set time_created = now() - interval '61 minutes' + where created_by_user_id = ${owner.userId}`; + expect((await mint()).token).toStartWith('nit_'); + }); + + test('the limits are per person', async () => { + const busy = await newOwner('nit-busy'); + const quiet = await newOwner('nit-quiet'); + for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) { + await InstallToken.create({ teamId: busy.teamId, userId: busy.userId }); + } + const issued = await InstallToken.create({ teamId: quiet.teamId, userId: quiet.userId }); + expect(issued.token).toStartWith('nit_'); + }); + + test('a team with no room for a host is not handed a token for one', async () => { + const owner = await newOwner('nit-cap-mint'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await Fixtures.machine(owner, `box-${i}`); + const refused = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }).catch( + (e) => e + ); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('quota_exceeded'); + expect(refused.message).toBe('Your team already has 10 machines. Remove one to add another.'); + }); + + test('a token minted while there was room is refused once there is none, and stays unspent', async () => { + const owner = await newOwner('nit-cap-redeem'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT - 1; i++) { + await Fixtures.machine(owner, `box-${i}`); + } + const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId }); + await Fixtures.machine(owner, 'the-last-place'); + + const refused = await InstallToken.redeem({ token, label: 'late' }).catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.code).toBe('quota_exceeded'); + + const rows = + await sql`select redeemed_at from install_token where created_by_user_id = ${owner.userId}`; + expect(rows[0]!.redeemed_at).toBeNull(); + }); }); diff --git a/packages/core/src/machine/install-token.ts b/packages/core/src/machine/install-token.ts index c2935f3b..6d4000e6 100644 --- a/packages/core/src/machine/install-token.ts +++ b/packages/core/src/machine/install-token.ts @@ -4,8 +4,10 @@ import { and, eq, gt, isNull, sql } from 'drizzle-orm'; import { z } from 'zod'; import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; +import { UserTable } from '../user/user.sql.js'; import { Machine } from './index.js'; import { InstallTokenTable } from './install-token.sql.js'; @@ -20,6 +22,15 @@ export namespace InstallToken { */ export const TTL_MINUTES = 60; + /** + * How many one person may ask for. A token is minted by a signed-in call + * that a script can repeat, and each one is a host waiting to happen, so + * minting is bounded twice: by rate, and by how many can be lying unspent + * at once. Both are far above what installing by hand ever needs. + */ + export const HOURLY_LIMIT = 5; + export const OUTSTANDING_LIMIT = 3; + function generate(): string { return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`; } @@ -34,6 +45,12 @@ export namespace InstallToken { /** * Issue a token for a team, or for an organisation's fleet. The caller has * already checked membership of whichever it names. + * + * Refused once the person has hit {@link HOURLY_LIMIT} or + * {@link OUTSTANDING_LIMIT}, or the team already owns as many machines as + * it may, with a message a terminal can print as it is. The team limit is + * held again when the token is spent; checking here as well means nobody + * is handed a command that cannot work. */ export const create = fn( z @@ -48,8 +65,39 @@ export namespace InstallToken { async (input) => { const token = generate(); const tokenHash = await digest(token); - const expiresAt = await Database.use(async (tx) => - tx + const expiresAt = await Database.transaction(async (tx) => { + // The limits are counts, so they hold only if nothing else can + // mint for this person while they are taken. Their own row is + // the one thing every such call contends on. + await tx + .select({ id: UserTable.id }) + .from(UserTable) + .where(eq(UserTable.id, input.userId)) + .for('update'); + if (input.teamId) await Machine.assertTeamHasRoom(input.teamId); + const [usage] = await tx + .select({ + lastHour: sql`(count(*) filter (where ${InstallTokenTable.timeCreated} > now() - interval '1 hour'))::int`, + outstanding: sql`(count(*) filter (where ${InstallTokenTable.redeemedAt} is null and ${InstallTokenTable.timeDeleted} is null and ${InstallTokenTable.expiresAt} > now()))::int` + }) + .from(InstallTokenTable) + .where(eq(InstallTokenTable.createdByUserId, input.userId)); + if ((usage?.lastHour ?? 0) >= HOURLY_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.TOO_MANY_REQUESTS, + `You have made ${HOURLY_LIMIT} install tokens in the last hour. Try again later.` + ); + } + if ((usage?.outstanding ?? 0) >= OUTSTANDING_LIMIT) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.RateLimit.TOO_MANY_REQUESTS, + `You have ${OUTSTANDING_LIMIT} install tokens that have not been used yet. Use one, or wait for them to expire.` + ); + } + + return tx .insert(InstallTokenTable) .values({ id: Identifier.ascending('installToken'), @@ -60,8 +108,8 @@ export namespace InstallToken { expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'` }) .returning({ expiresAt: InstallTokenTable.expiresAt }) - .then((rows) => rows[0]!.expiresAt) - ); + .then((rows) => rows[0]!.expiresAt); + }); return { token, expiresAt }; } ); diff --git a/packages/core/src/machine/machine.test.ts b/packages/core/src/machine/machine.test.ts index b7a45501..9e061d17 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; +import { VisibleError } from '../error.js'; import { Identifier } from '../id.js'; import { Machine } from './index.js'; @@ -167,3 +168,54 @@ describe('Machine heartbeat', () => { expect(Machine.isOnline(wellPast)).toBe(false); }); }); + +describe('How many machines a team may own', () => { + function register(owner: { userId: string; teamId: string }, label: string) { + return Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: owner.userId, + teamId: owner.teamId, + label + }); + } + + test('ten, and the eleventh is refused with a message to act on', async () => { + const owner = await newOwner('mch-cap'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await register(owner, `box-${i}`); + + const refused = await register(owner, 'one-too-many').catch((e) => e); + expect(refused).toBeInstanceOf(VisibleError); + expect(refused.type).toBe('rate_limit'); + expect(refused.code).toBe('quota_exceeded'); + expect(refused.message).toBe('Your team already has 10 machines. Remove one to add another.'); + + const rows = await sql`select id from machine where team_id = ${owner.teamId}`; + expect(rows).toHaveLength(Machine.TEAM_MACHINE_LIMIT); + }); + + test('a removed machine frees its place', async () => { + const owner = await newOwner('mch-cap-removed'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT; i++) await register(owner, `box-${i}`); + await sql`update machine set time_deleted = now() + where id = (select id from machine where team_id = ${owner.teamId} limit 1)`; + expect((await register(owner, 'replacement')).secret).toStartWith('msk_'); + }); + + test('registrations racing for the last place do not both get it', async () => { + const owner = await newOwner('mch-cap-race'); + for (let i = 0; i < Machine.TEAM_MACHINE_LIMIT - 1; i++) await register(owner, `box-${i}`); + const results = await Promise.allSettled( + Array.from({ length: 4 }, (_, i) => register(owner, `racer-${i}`)) + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + const rows = await sql`select id from machine where team_id = ${owner.teamId}`; + expect(rows).toHaveLength(Machine.TEAM_MACHINE_LIMIT); + }); + + test('hardware an organisation owns is not counted', async () => { + const operator = await newOwner('mch-cap-fleet'); + const fleet = await Fixtures.fleet(operator, Machine.TEAM_MACHINE_LIMIT + 1); + expect(fleet.machines).toHaveLength(Machine.TEAM_MACHINE_LIMIT + 1); + expect((await register(operator, 'own-box')).secret).toStartWith('msk_'); + }); +}); From 07186f3d22de2f2ee1c08f37d62aad2676096b6e Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 16 Oct 2026 18:14:04 +0000 Subject: [PATCH 20/21] feat(install): the install token is optional; host agent 0.2.20 (#418) Releases the install.sh half of #416 now that host agent 0.2.20 can sign in. --- apps/api/install/install.sh | 39 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh index ff3e3c71..8aa4d2e0 100755 --- a/apps/api/install/install.sh +++ b/apps/api/install/install.sh @@ -4,6 +4,7 @@ # This file is the source of what that URL serves, kept in the public # repository so anyone about to pipe it into a shell can read it first. # +# bash <(curl -fsSL https://api.nestri.io/install.sh) # bash <(curl -fsSL https://api.nestri.io/install.sh) # bash <(curl -fsSL https://api.nestri.io/install.sh) -u # @@ -12,18 +13,21 @@ # What it does, in order: check this is 64-bit Linux, ask where box images # should live, download the host agent for this platform, verify it against the # published SHA256SUMS, install it to /usr/local/bin, and hand over to the -# agent's own onboarding, which checks the machine, registers it with the token -# and starts the agent as a systemd service. +# agent's own onboarding, which checks the machine, registers it and starts the +# agent as a systemd service. # # It runs as root because the agent does: each box's VMM is jailed under a uid # of its own, which only root can give it, and a box's network needs # CAP_NET_ADMIN. The agent keeps its state in /var/lib/nestri. Started as # anyone else, it asks sudo for root once, before it changes anything. # -# The token comes from the dashboard's Installation page. It registers one -# machine, works once and lapses after an hour. +# The token is optional. Without one, the agent signs in instead: it shows a +# code and a link, and the machine is registered once somebody approves that +# code in a browser, on any device. A token from the dashboard's Installation +# page skips the browser; it registers one machine, works once and lapses after +# an hour. # -# `-u` (`--update`) needs no token: on a machine that is already a host, it installs +# `-u` (`--update`) registers nothing: on a machine that is already a host, it installs # the current agent and restarts it, and the agent picks up whatever a new # release changed about its images on its own. It touches nothing else -- the # machine keeps its registration, its box store and its Steam sign-ins. @@ -33,7 +37,7 @@ set -eu API="${NESTRI_API:-https://api.nestri.io}" # Pinned, not "latest", so the script and the binary it installs are a pair # somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it. -DEFAULT_VERSION="0.2.19" +DEFAULT_VERSION="0.2.20" VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}" BIN_DIR="${NESTRI_BIN_DIR:-/usr/local/bin}" STATE_DIR=/var/lib/nestri @@ -45,7 +49,8 @@ usage() { cat >&2 < make this machine a host + bash <(curl -fsSL $API/install.sh) make this machine a host, approving it in a browser + bash <(curl -fsSL $API/install.sh) make this machine a host with a token from the dashboard bash <(curl -fsSL $API/install.sh) -u update the host agent here -u, --update install the current host agent on a machine that is already a host @@ -65,11 +70,6 @@ while [ $# -gt 0 ]; do esac shift done -if [ "$MODE" = install ] && [ -z "$TOKEN" ]; then - usage - die "no install token. Copy the full command from the dashboard's Installation page." -fi - # --- fetch ------------------------------------------------------------------ if command -v curl >/dev/null 2>&1; then get() { curl -fsSL "$1" -o "$2"; } @@ -95,7 +95,9 @@ if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null 2>&1 || die "this needs root and there is no sudo here. Run it as root." self="$(mktemp)" get "$API/install.sh" "$self" || die "could not fetch $API/install.sh to run it as root" - if [ "$MODE" = update ]; then set -- -u; else set -- "$TOKEN"; fi + if [ "$MODE" = update ]; then set -- -u + elif [ -n "$TOKEN" ]; then set -- "$TOKEN" + else set --; fi say "The host agent runs as root, so this needs sudo." exec sudo env NESTRI_SELF="$self" NESTRI_API="$API" \ ${NESTRI_HOST_VERSION:+NESTRI_HOST_VERSION="$NESTRI_HOST_VERSION"} \ @@ -228,9 +230,14 @@ fi # --- onboard ---------------------------------------------------------------- # The token goes through the environment rather than argv, so it is not in -# `ps` for the length of the run. -export NESTRI_INSTALL_TOKEN="$TOKEN" NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API" \ - NESLET_STATE_DIR="$STATE_DIR" +# `ps` for the length of the run. With none, it is left out altogether, and +# onboarding signs in through the browser instead. +if [ -n "$TOKEN" ]; then + export NESTRI_INSTALL_TOKEN="$TOKEN" +else + unset NESTRI_INSTALL_TOKEN +fi +export NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API" NESLET_STATE_DIR="$STATE_DIR" if tty_ok; then exec "$BIN_DIR/nestri-host" onboard Date: Sat, 17 Oct 2026 17:23:58 +0000 Subject: [PATCH 21/21] feat: window streams, launches under a session, fleet placement by slot (#429) All of dev to prod. Prod's squash copies are merged in with dev's version kept, so the tree is identical to dev. --------- Co-authored-by: DatCaptainHorse Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/api/app/routes/machine.ts | 14 +- apps/api/app/routes/session.ts | 225 +- apps/api/test/fleet.test.ts | 16 + apps/api/test/launch.test.ts | 396 ++ apps/api/test/session.test.ts | 24 +- apps/nescapture/src/encode.rs | 122 +- apps/nescope/src/handlers.rs | 33 +- apps/nescope/src/input.rs | 4 + apps/nescope/src/main.rs | 34 +- apps/nescope/src/state.rs | 254 +- apps/nescope/src/windows.rs | 440 ++ apps/neshub/examples/pyro_bench.rs | 4 +- apps/neshub/src/capture_cmd.rs | 235 + apps/neshub/src/congestion.rs | 67 + apps/neshub/src/control.rs | 869 +--- apps/neshub/src/dgram.rs | 89 +- apps/neshub/src/e2e_tests.rs | 267 +- apps/neshub/src/ipc_listener.rs | 106 +- apps/neshub/src/keyframe.rs | 9 +- apps/neshub/src/main.rs | 148 +- apps/neshub/src/pyro.rs | 15 +- apps/neshub/src/rate.rs | 506 +++ apps/neshub/src/session.rs | 628 ++- apps/neshub/src/windows.rs | 651 +++ apps/nesinit/src/services.rs | 95 +- apps/nesinit/src/session.rs | 647 ++- apps/nesinit/src/system.rs | 43 + apps/nesinit/src/workload.rs | 463 +- apps/nesinit/tests/services_stop.rs | 2 + apps/neswire/src/encoder.rs | 30 +- apps/neswire/src/main.rs | 2 +- apps/neswire/src/sink.rs | 49 +- build/kernel/nestri.fragment | 2 + crates/nesprotocol/Cargo.toml | 4 + crates/nesprotocol/src/clock.rs | 81 + crates/nesprotocol/src/datagram.rs | 178 +- crates/nesprotocol/src/delay.rs | 454 +- crates/nesprotocol/src/input.rs | 85 + crates/nesprotocol/src/lib.rs | 293 +- crates/nesprotocol/src/lifecycle.rs | 80 + crates/nesprotocol/src/pyrowave.rs | 59 +- crates/nesprotocol/src/reliable.rs | 43 +- crates/nesprotocol/src/stats.rs | 16 +- crates/nesprotocol/src/window.rs | 814 ++++ crates/nespyro/tests/transport.rs | 4 +- docs/media-transport.md | 12 + .../migrations/0027_launch_under_session.sql | 29 + .../migrations/0028_machine_capabilities.sql | 1 + .../core/migrations/meta/0027_snapshot.json | 4041 ++++++++++++++++ .../core/migrations/meta/0028_snapshot.json | 4047 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 14 + packages/core/src/box/placement.ts | 31 +- packages/core/src/error.ts | 3 +- packages/core/src/examples.ts | 6 + packages/core/src/id.ts | 1 + packages/core/src/machine/index.ts | 11 +- packages/core/src/machine/machine.sql.ts | 6 +- packages/core/src/session/index.ts | 333 +- packages/core/src/session/launch.sql.ts | 51 + packages/core/src/session/launch.ts | 382 ++ 61 files changed, 16242 insertions(+), 1327 deletions(-) create mode 100644 apps/api/test/launch.test.ts create mode 100644 apps/nescope/src/windows.rs create mode 100644 apps/neshub/src/capture_cmd.rs create mode 100644 apps/neshub/src/congestion.rs create mode 100644 apps/neshub/src/rate.rs create mode 100644 apps/neshub/src/windows.rs create mode 100644 crates/nesprotocol/src/clock.rs create mode 100644 crates/nesprotocol/src/window.rs create mode 100644 packages/core/migrations/0027_launch_under_session.sql create mode 100644 packages/core/migrations/0028_machine_capabilities.sql create mode 100644 packages/core/migrations/meta/0027_snapshot.json create mode 100644 packages/core/migrations/meta/0028_snapshot.json create mode 100644 packages/core/src/session/launch.sql.ts create mode 100644 packages/core/src/session/launch.ts diff --git a/Cargo.lock b/Cargo.lock index 6cd030f7..8e12ff24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2389,6 +2389,7 @@ dependencies = [ name = "nesprotocol" version = "0.1.0" dependencies = [ + "libc", "serde", "serde_json", ] diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 07c047a6..f45e9797 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -463,7 +463,16 @@ export namespace MachineApi { description: 'How many runs this host will hold at once. Omit it and the stored value is left alone. Runs beyond it wait their turn rather than being handed to a host that would refuse them', example: 2 - }) + }), + capabilities: z + .array(z.string().min(1).max(64)) + .max(32) + .optional() + .meta({ + description: + 'What this host can do beyond starting and stopping runs. Replaces the stored list when sent; omit it and the stored list is left alone. `launches`: it adds and ends launches in a running box (`launch.start`, `launch.stop`)', + example: ['launches'] + }) }) // A host that has nothing to add sends no body at all, which // is what every agent shipped before this field did. @@ -474,7 +483,8 @@ export namespace MachineApi { const lastSeen = await Machine.touchLastSeen({ id: Actor.machineID, endpointId: body?.endpointId, - slots: body?.slots + slots: body?.slots, + capabilities: body?.capabilities }); if (!lastSeen) { // The credentials authenticated but the row is gone — a host diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index f2cef935..293c5e15 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -7,6 +7,7 @@ import { Examples } from '@nestri/core/examples'; import { Game } from '@nestri/core/game/index'; import { Identifier } from '@nestri/core/id'; import { Session } from '@nestri/core/session/index'; +import { Launch } from '@nestri/core/session/launch'; import { Team } from '@nestri/core/team/index'; import { Trial } from '@nestri/core/trial/index'; import { Library } from '@nestri/core/user/library'; @@ -54,6 +55,18 @@ export namespace SessionApi { throw new VisibleError('already_exists', ErrorCodes.Validation.INVALID_STATE, message); } + /** + * The run's host does not handle launches. A client falls back to one game + * per run: stop the run, or ask for a new one. ref(d-0085) + */ + function hostTooOld(): never { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.HOST_TOO_OLD, + 'The host this run is on cannot add or end launches; stop the run instead' + ); + } + /** The person a run belongs to, refusing a host acting as its owner. */ function actingPerson(): string { const actor = Actor.use(); @@ -310,7 +323,7 @@ export namespace SessionApi { tags: ['Session'], summary: 'Read a run you asked for', description: - 'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else. Once the run reaches a terminal state the ticket is null: stop polling and stop dialling it.', + 'Poll this while a run starts. `launches` lists what runs in the box. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else. Once the run reaches a terminal state the ticket is null: stop polling and stop dialling it.', responses: { 200: { content: { 'application/json': { schema: Result(Session.Info) } }, @@ -341,7 +354,13 @@ export namespace SessionApi { 'No such session, or it is not yours' ); } - return c.json({ data: { ...session, queue: await Session.queueFor(session.id) } }); + return c.json({ + data: { + ...session, + launches: await Launch.listForSession(session.id), + queue: await Session.queueFor(session.id) + } + }); } ) .post( @@ -448,6 +467,206 @@ export namespace SessionApi { } } ) + .post( + '/:id/launch', + notPublic, + describeRoute({ + tags: ['Session'], + summary: 'Start another game in a running box', + description: + 'Adds a launch to a run that its host has taken on and that is not being stopped; the host starts it in the box without stopping what already runs there. Answers with the launch: its `id` is what the launched game’s window carries, and what to stop when that window closes. A run that is waiting for a host, stopping or over is 409: ask for a new run instead. So is a run on a host that does not handle launches, with code `host_too_old`: stop the run and ask for a new one, as before launches. The run is still what is billed and what the allowance counts, so adding a launch asks nothing of either.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Launch.Info) } }, + description: + 'That game already has a launch in this run that is not over: it is answered instead of a second one' + }, + 201: { + content: { 'application/json': { schema: Result(Launch.Info) } }, + description: 'The launch has been requested' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404], + 409: ErrorResponses[409] + } + }), + validator( + 'param', + z.object({ id: z.string().meta({ description: 'The run', example: Examples.Session.id }) }) + ), + validator( + 'json', + z + .object({ + gameId: z.string().min(1).meta({ + description: 'The game to launch', + example: Examples.Session.gameId + }) + }) + .strict() + ), + async (c) => { + const userId = actingPerson(); + const game = await Game.fromID(c.req.valid('json').gameId); + if (!game) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such game' + ); + } + // The same check, and the same gaps, as asking for a run. + const owned = await Library.findByUserAndGame({ userId, gameId: game.id }); + if (!owned) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'That game is not in your library' + ); + } + const result = await Session.addLaunch({ + sessionId: c.req.valid('param').id, + userId, + gameId: game.id + }); + switch (result.outcome) { + case 'notFound': + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such session, or it is not yours' + ); + case 'notRunning': + conflict('That run is not running, so it cannot take another launch'); + case 'hostTooOld': + hostTooOld(); + case 'existing': + return c.json({ data: result.launch }, 200); + default: + return c.json({ data: result.launch }, 201); + } + } + ) + .post( + '/:id/launch/:launchId/stop', + notPublic, + describeRoute({ + tags: ['Session'], + summary: 'End one launch in a run', + description: + 'Asks for one game in the box to end, not the box. It stays `running`, with `timeStopRequested` set, until the host reports it over. When the last launch of a run is over, the run is stopped as `POST /session/{id}/stop` would. Asking again, or of a launch already over, changes nothing. A run on a host that does not handle launches answers 409 with code `host_too_old`: stop the run instead.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Launch.Info) } }, + description: 'The launch, with the stop recorded' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + validator( + 'param', + z.object({ + id: z.string().meta({ description: 'The run', example: Examples.Session.id }), + launchId: z + .string() + .meta({ description: 'The launch to end', example: Examples.Launch.id }) + }) + ), + async (c) => { + const { id, launchId } = c.req.valid('param'); + const launch = await Session.stopLaunch({ + sessionId: id, + launchId, + userId: actingPerson() + }); + if (launch === 'hostTooOld') hostTooOld(); + if (!launch) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such launch, or it is not yours' + ); + } + return c.json({ data: launch }); + } + ) + .post( + '/:id/launch/:launchId/state', + machineOnly, + describeRoute({ + tags: ['Session'], + summary: 'Report where one launch has got to', + description: + 'For the host the run’s box is placed on, holding the run’s claim. `running` once the game is started in the box, `ended` or `failed` once it is over, with `endReason` as for a run. Re-reporting a state already reported changes nothing. A transition that does not exist is 409. When the report ends the last launch of the run, the run is stopped and the host is handed `session.stop` for it.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Launch.Info) } }, + description: 'The launch as it stands after the report' + }, + 400: ErrorResponses[400], + 403: ErrorResponses[403], + 409: ErrorResponses[409] + } + }), + validator('param', z.object({ id: z.string(), launchId: z.string() })), + validator( + 'json', + z + .object({ + state: Launch.ReportableState.meta({ + description: 'Where the launch has got to', + example: 'ended' + }), + claimToken: ClaimTokenField, + errorMessage: z.string().max(1024).nullable().optional().meta({ + description: 'Why it failed. Kept only for a launch that did', + example: Examples.Session.errorMessage + }), + endReason: Session.EndReason.nullable().optional().meta({ + description: 'Why the launch is over. Only with `ended` or `failed`', + example: 'game_exited' + }) + }) + .strict() + ), + async (c) => { + const body = c.req.valid('json'); + if (body.endReason && body.state === 'running') { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_PARAMETER, + 'A launch that is running is not over, so it has no reason to have ended', + 'endReason' + ); + } + const { id, launchId } = c.req.valid('param'); + const result = await Session.reportLaunch({ + sessionId: id, + launchId, + machineId: Actor.machineID, + claimToken: body.claimToken, + state: body.state, + endReason: body.endReason ?? null, + errorMessage: body.errorMessage ?? null + }); + switch (result.outcome) { + case 'forbidden': + notYours(); + case 'illegal': + conflict(`A launch in state ${result.launch?.state} cannot become ${body.state}`); + case 'notHolder': + conflict('Another attempt holds this run'); + case 'lost': + conflict('Another caller moved this launch first'); + default: + return c.json({ data: result.launch }); + } + } + ) .post( '/:id/ticket', machineOnly, @@ -513,7 +732,7 @@ export namespace SessionApi { tags: ['Session'], summary: 'Ask for work', description: - 'Returns the work for the calling host, and only that host — it comes from its own credentials and the scope is the query, so a box cannot see work for another. `session.start` is a run waiting to be started; `session.stop` is a run to end now, because its owner asked or the terms it started under ran out, repeated on every poll until the host reports it ended. Poll at the cadence the heartbeat hands down. An agent skips a kind it does not know.', + 'Returns the work for the calling host, and only that host — it comes from its own credentials and the scope is the query, so a box cannot see work for another. `session.start` is a run waiting to be started; `session.stop` is a run to end now, because its owner asked, its last launch is over, or the terms it started under ran out, repeated on every poll until the host reports it ended. `launch.start` is another game to start in a box that is already live; `launch.stop` is one launch to end, both repeated until the host reports that launch. Poll at the cadence the heartbeat hands down. An agent skips a kind it does not know.', responses: { 200: { content: { 'application/json': { schema: Result(z.array(Session.Job)) } }, diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts index 488bc619..cbb8ab37 100644 --- a/apps/api/test/fleet.test.ts +++ b/apps/api/test/fleet.test.ts @@ -84,6 +84,22 @@ describe('POST /box on the fleet', () => { expect(boxB.machineId).toBe(second!.id); }); + test('places by boxes per slot, so a bigger machine takes more people', async () => { + const f = await fleet(2); + useFleet(f.organisationId); + const [small, big] = f.machines; + await sql`update machine set slots = 2 where id = ${small!.id}`; + await sql`update machine set slots = 8 where id = ${big!.id}`; + + const placed: string[] = []; + for (let i = 0; i < 5; i++) { + const p = await person(`fleet-slots-${i}`); + placed.push(((await (await createBox(p.headers, { on: 'fleet' })).json()) as any).data.machineId); + } + // small first on the tie at 0, then big until it is as full per slot. + expect(placed).toEqual([small!.id, big!.id, big!.id, big!.id, big!.id]); + }); + test('skips a fleet machine that is offline', async () => { const f = await fleet(2); useFleet(f.organisationId); diff --git a/apps/api/test/launch.test.ts b/apps/api/test/launch.test.ts new file mode 100644 index 00000000..251179af --- /dev/null +++ b/apps/api/test/launch.test.ts @@ -0,0 +1,396 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { AccessToken } from '@nestri/core/access-token/index'; +import { Box } from '@nestri/core/box/index'; +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Game } from '@nestri/core/game/index'; +import { Identifier } from '@nestri/core/id'; +import { Machine } from '@nestri/core/machine/index'; +import { Session } from '@nestri/core/session/index'; +import { Library } from '@nestri/core/user/library'; +import { LinkedAccount } from '@nestri/core/user/linked-account'; + +import { app } from '../app/index'; +import './setup'; + +const sql = testDb(); + +const HOLDER = 'h'.repeat(32); + +const createdUserIds: string[] = []; +const createdGameIds: string[] = []; + +async function newGame(steamAppId: number): Promise { + const [row] = await Game.upsert({ + id: Identifier.ascending('game'), + steamAppId, + slug: `session-route-${steamAppId}`, + name: `Session Route ${steamAppId}` + }); + if (!row) throw new Error('expected a game row'); + createdGameIds.push(row.id); + return row.id; +} + +/** + * Everything one session needs, plus both sets of credentials that reach it. + * + * The person authenticates with a personal token, which is the one user + * credential a test can mint without an auth service; the host authenticates + * as itself with the secret registration hands back exactly once. + */ +async function scene(label: string, steamAppId: number) { + const owner = await Fixtures.owner(label); + createdUserIds.push(owner.userId); + + const registered = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: owner.userId, + teamId: owner.teamId, + label + }); + + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId: registered.id, + label, + tier: 'sm' + }); + + const pat = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: owner.userId, + // Null on purpose: a token scoped to the user alone makes the caller a + // plain user actor, which is the credential a person browsing has. + teamId: null, + name: label + }); + + const gameId = await newGame(steamAppId); + // A run launches as a Steam account that owns the game, so the endpoint + // refuses one outside the caller's library. Every scene here is about + // something else, so the game is stocked. + await Library.upsert({ + id: Identifier.ascending('userLibrary'), + userId: owner.userId, + gameId, + playtime2w: null, + playtimeForever: null, + lastPlayed: null + }); + + return { + owner, + box, + machineId: registered.id, + gameId, + user: { + authorization: `Bearer ${pat.token}`, + 'content-type': 'application/json' + } as Record, + host: { + 'x-nestri-machine-id': registered.id, + 'x-nestri-machine-secret': registered.secret, + 'content-type': 'application/json' + } as Record + }; +} + +async function requestSession(s: Awaited>) { + const res = await app.request('/session', { + method: 'POST', + headers: s.user, + body: JSON.stringify({ + boxId: s.box.id, + gameId: s.gameId, + linkedAccountId: s.owner.linkedAccountId + }) + }); + const body = (await res.json()) as any; + return { res, body }; +} + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from "burn_segment" where session_id in ( + select s.id from "session" s + join "box" b on b.id = s.box_id + where b.user_id in ${sql(createdUserIds)} + )`; + await sql`delete from "box" where user_id in ${sql(createdUserIds)}`; + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + } + if (createdGameIds.length > 0) { + await sql`delete from "game" where id in ${sql(createdGameIds)}`; + } +}); + +type Scene = Awaited>; + +const report = (s: Scene, id: string, state: string, extra: object = {}) => + app.request(`/session/${id}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state, claimToken: HOLDER, ...extra }) + }); + +const reportLaunch = (s: Scene, id: string, launchId: string, state: string, extra: object = {}) => + app.request(`/session/${id}/launch/${launchId}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state, claimToken: HOLDER, ...extra }) + }); + +const addLaunch = (s: Scene, id: string, gameId: string) => + app.request(`/session/${id}/launch`, { + method: 'POST', + headers: s.user, + body: JSON.stringify({ gameId }) + }); + +const stopLaunch = (s: Scene, id: string, launchId: string) => + app.request(`/session/${id}/launch/${launchId}/stop`, { method: 'POST', headers: s.user }); + +const jobs = async (s: Scene) => + ((await (await app.request('/machine/jobs', { headers: s.host })).json()) as any).data as any[]; + +const read = async (s: Scene, id: string) => + ((await (await app.request(`/session/${id}`, { headers: s.user })).json()) as any).data; + +/** A live run, with a second game stocked in the owner's library. */ +const says = (s: Scene, capabilities: string[]) => + app.request('/machine/heartbeat', { + method: 'POST', + headers: s.host, + body: JSON.stringify({ capabilities }) + }); + +async function liveRun(label: string, appId: number, opts: { capable?: boolean } = {}) { + const s = await scene(label, appId); + const { body } = await requestSession(s); + const id = body.data.id as string; + const first = body.data.launches[0].id as string; + if (opts.capable !== false) await says(s, ['launches']); + expect((await report(s, id, 'starting')).status).toBe(200); + expect((await report(s, id, 'live')).status).toBe(200); + const second = await newGame(appId + 1000); + await Library.upsert({ + id: Identifier.ascending('userLibrary'), + userId: s.owner.userId, + gameId: second, + playtime2w: null, + playtimeForever: null, + lastPlayed: null + }); + return { s, id, first, second }; +} + +describe('launches', () => { + test('a run is created with its first launch, running once the box is live', async () => { + const { s, id, first } = await liveRun('launch-first', 7100); + const run = await read(s, id); + expect(run.launches).toHaveLength(1); + expect(run.launches[0]).toMatchObject({ id: first, gameId: s.gameId, state: 'running' }); + // The first launch starts with the box; it is never a job of its own. + expect((await jobs(s)).filter((j) => j.kind === 'launch.start')).toEqual([]); + }); + + test('a second game in a live box is a launch.start job, until the host reports it', async () => { + const { s, id, second } = await liveRun('launch-add', 7101); + const res = await addLaunch(s, id, second); + expect(res.status).toBe(201); + const launch = ((await res.json()) as any).data; + expect(launch.id.startsWith('lch_')).toBe(true); + expect(launch.state).toBe('requested'); + + const handed = (await jobs(s)).filter((j) => j.kind === 'launch.start'); + expect(handed).toEqual([ + { + kind: 'launch.start', + sessionId: id, + launchId: launch.id, + boxId: s.box.id, + gameId: second, + steamAppId: 8101, + linkedAccountId: s.owner.linkedAccountId, + userId: s.owner.userId + } + ]); + + expect((await reportLaunch(s, id, launch.id, 'running')).status).toBe(200); + expect((await reportLaunch(s, id, launch.id, 'running')).status).toBe(200); + expect((await jobs(s)).filter((j) => j.kind === 'launch.start')).toEqual([]); + const run = await read(s, id); + expect(run.launches.map((l: any) => l.state)).toEqual(['running', 'running']); + }); + + test('the same game added twice is one launch', async () => { + const { s, id, second } = await liveRun('launch-dedupe', 7109); + const [a, b] = await Promise.all([addLaunch(s, id, second), addLaunch(s, id, second)]); + const statuses = [a.status, b.status].sort(); + expect(statuses).toEqual([200, 201]); + const ids = [((await a.json()) as any).data.id, ((await b.json()) as any).data.id]; + expect(ids[0]).toBe(ids[1]); + expect((await read(s, id)).launches.length).toBe(2); + }); + + test('a run that is not running takes no launch', async () => { + const s = await scene('launch-notrunning', 7102); + const { body } = await requestSession(s); + // Still waiting for a host. + expect((await addLaunch(s, body.data.id, s.gameId)).status).toBe(409); + await report(s, body.data.id, 'starting'); + await report(s, body.data.id, 'live'); + await app.request(`/session/${body.data.id}/stop`, { method: 'POST', headers: s.user }); + // Being stopped. + expect((await addLaunch(s, body.data.id, s.gameId)).status).toBe(409); + }); + + test('a launch needs a game in your library and a run that is yours', async () => { + const { s, id } = await liveRun('launch-checks', 7103); + const other = await scene('launch-checks-other', 7104); + const notOwned = await newGame(7105); + expect((await addLaunch(s, id, notOwned)).status).toBe(403); + expect((await addLaunch(s, id, Identifier.ascending('game'))).status).toBe(404); + expect((await addLaunch(other, id, other.gameId)).status).toBe(404); + }); + + test('closing one window ends that launch, not the run', async () => { + const { s, id, first, second } = await liveRun('launch-close-one', 7106); + const added = ((await (await addLaunch(s, id, second)).json()) as any).data; + await reportLaunch(s, id, added.id, 'running'); + + const res = await stopLaunch(s, id, first); + expect(res.status).toBe(200); + expect(((await res.json()) as any).data.timeStopRequested).not.toBeNull(); + const work = await jobs(s); + expect(work.filter((j) => j.kind === 'launch.stop')).toEqual([ + { kind: 'launch.stop', sessionId: id, launchId: first, boxId: s.box.id } + ]); + expect(work.filter((j) => j.kind === 'session.stop')).toEqual([]); + + expect((await reportLaunch(s, id, first, 'ended', { endReason: 'user' })).status).toBe(200); + const run = await read(s, id); + expect(run.state).toBe('live'); + expect(run.timeStopRequested).toBeNull(); + expect(run.launches.map((l: any) => l.state)).toEqual(['ended', 'running']); + }); + + test('the last launch over stops the run, as its owner stopping it would', async () => { + const { s, id, first } = await liveRun('launch-last', 7107); + expect((await reportLaunch(s, id, first, 'ended', { endReason: 'game_exited' })).status).toBe( + 200 + ); + const run = await read(s, id); + expect(run.timeStopRequested).not.toBeNull(); + expect((await jobs(s)).filter((j) => j.kind === 'session.stop')).toEqual([ + { kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'user' } + ]); + // The host stops the box as it would any run. + expect((await report(s, id, 'ended', { endReason: 'user' })).status).toBe(200); + expect((await read(s, id)).state).toBe('ended'); + }); + + test('a run ending ends every launch in it', async () => { + const { s, id, second } = await liveRun('launch-cascade', 7108); + const added = ((await (await addLaunch(s, id, second)).json()) as any).data; + expect((await report(s, id, 'ended', { endReason: 'host_stopped' })).status).toBe(200); + const run = await read(s, id); + expect(run.launches.map((l: any) => l.state)).toEqual(['ended', 'ended']); + expect(run.launches[1].id).toBe(added.id); + expect((await jobs(s)).filter((j) => j.kind.startsWith('launch.'))).toEqual([]); + }); + + test('stopping the only launch of a run nobody has picked up ends the run at once', async () => { + const s = await scene('launch-unclaimed', 7109); + const { body } = await requestSession(s); + const res = await stopLaunch(s, body.data.id, body.data.launches[0].id); + expect(((await res.json()) as any).data.state).toBe('ended'); + const run = await read(s, body.data.id); + expect(run.state).toBe('ended'); + expect(run.endReason).toBe('user'); + }); + + test('launch reports are held to the run’s claim and its host', async () => { + const { s, id, first } = await liveRun('launch-holder', 7110); + const other = await scene('launch-holder-other', 7111); + const wrong = await app.request(`/session/${id}/launch/${first}/state`, { + method: 'POST', + headers: s.host, + body: JSON.stringify({ state: 'ended', claimToken: 'x'.repeat(32) }) + }); + expect(wrong.status).toBe(409); + expect((await reportLaunch(other, id, first, 'ended')).status).toBe(403); + expect((await reportLaunch(s, id, 'lch_' + '0'.repeat(26), 'ended')).status).toBe(403); + expect((await reportLaunch(s, id, first, 'running', { endReason: 'user' })).status).toBe(400); + // A person cannot report for the host. + const asUser = await app.request(`/session/${id}/launch/${first}/state`, { + method: 'POST', + headers: s.user, + body: JSON.stringify({ state: 'ended', claimToken: HOLDER }) + }); + expect(asUser.status).toBe(403); + await reportLaunch(s, id, first, 'failed', { errorMessage: 'crashed' }); + expect((await reportLaunch(s, id, first, 'running')).status).toBe(409); + }); +}); + +describe('launches racing the last one ending', () => { + test('a launch added as the last one ends is either kept or refused, never orphaned', async () => { + for (let i = 0; i < 5; i++) { + const { s, id, first, second } = await liveRun(`launch-race-${i}`, 7200 + i); + const [ended, added] = await Promise.all([ + reportLaunch(s, id, first, 'ended', { endReason: 'game_exited' }), + addLaunch(s, id, second) + ]); + expect(ended.status).toBe(200); + const run = await read(s, id); + if (added.status === 201) { + // Added first: the run keeps going with the new launch. + expect(run.timeStopRequested).toBeNull(); + expect(run.launches.map((l: any) => l.state)).toEqual(['ended', 'requested']); + } else { + // Stopped first: the launch was refused, and none was written. + expect(added.status).toBe(409); + expect(run.timeStopRequested).not.toBeNull(); + expect(run.launches).toHaveLength(1); + } + } + }); +}); + +describe('a host that does not handle launches', () => { + test('is handed no launch work, and adding or closing one is refused', async () => { + const { s, id, first, second } = await liveRun('launch-old-host', 7300, { capable: false }); + const added = await addLaunch(s, id, second); + expect(added.status).toBe(409); + expect(((await added.json()) as any).code).toBe('host_too_old'); + const closed = await stopLaunch(s, id, first); + expect(closed.status).toBe(409); + expect(((await closed.json()) as any).code).toBe('host_too_old'); + // Stopping the run works as it always has. + const stopped = await app.request(`/session/${id}/stop`, { method: 'POST', headers: s.user }); + expect(stopped.status).toBe(200); + expect((await jobs(s)).map((j) => j.kind)).toEqual(['session.stop']); + }); + + test('a heartbeat without capabilities leaves the stored list alone', async () => { + const { s, id, second } = await liveRun('launch-cap-keep', 7301); + const beat = await app.request('/machine/heartbeat', { + method: 'POST', + headers: s.host, + body: JSON.stringify({ slots: 4 }) + }); + expect(beat.status).toBe(200); + expect((await addLaunch(s, id, second)).status).toBe(201); + }); + + test('a host that stops listing launches is handed none', async () => { + const { s, id, second } = await liveRun('launch-cap-drop', 7302); + expect((await addLaunch(s, id, second)).status).toBe(201); + await says(s, []); + expect((await jobs(s)).filter((j) => j.kind.startsWith('launch.'))).toEqual([]); + }); +}); diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 8dea3a1f..f9fdc1db 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -166,9 +166,24 @@ describe('POST /session', () => { timeStopRequested: null, trial: false, errorMessage: null, - endReason: null + endReason: null, + // Additive: the run's first launch, which a client needs to find + // its window. ref(d-0085) + launches: [ + { + id: body.data.launches[0].id, + gameId: s.gameId, + state: 'requested', + timeStarted: null, + timeStopped: null, + timeStopRequested: null, + endReason: null, + errorMessage: null + } + ] }); expect(body.data.id.startsWith('ses_')).toBe(true); + expect(body.data.launches[0].id.startsWith('lch_')).toBe(true); }); test('creating a session makes no placement decision', async () => { @@ -433,7 +448,8 @@ describe('GET /machine/jobs', () => { gameId: s.gameId, steamAppId: 5520, linkedAccountId: s.owner.linkedAccountId, - userId: s.owner.userId + userId: s.owner.userId, + launchId: body.data.launches[0].id }); }); @@ -831,6 +847,7 @@ describe('POST /session/:id/ticket', () => { 'errorMessage', 'gameId', 'id', + 'launches', 'linkedAccountId', 'queue', 'state', @@ -984,6 +1001,9 @@ describe('Session routes in the spec', () => { expect(paths).toContain('/session/{id}/state'); expect(paths).toContain('/session/{id}/ticket'); expect(paths).toContain('/machine/jobs'); + expect(paths).toContain('/session/{id}/launch'); + expect(paths).toContain('/session/{id}/launch/{launchId}/stop'); + expect(paths).toContain('/session/{id}/launch/{launchId}/state'); }); }); diff --git a/apps/nescapture/src/encode.rs b/apps/nescapture/src/encode.rs index 6a54ffbd..3b41e512 100644 --- a/apps/nescapture/src/encode.rs +++ b/apps/nescapture/src/encode.rs @@ -56,7 +56,7 @@ use std::time::Instant; use nesprotocol::{ CODEC_AV1, CODEC_H264, CODEC_H265, CODEC_KEEP, CODEC_PYROWAVE, ClientCaps, FLAG_KEYFRAME, FLAG_RECONFIG, MSG_CLIENT_CAPS, MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, STREAM_VIDEO, - decode_client_caps, decode_encode_settings_ext, encode_ipc_frame, + decode_client_caps, decode_encode_settings_ext, encode_ipc_frame_from, }; use pixelforge::{ Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeBitDepth, @@ -131,6 +131,74 @@ pub fn effective_colorspace(vk_colorspace: u32, declared: Option) -> u32 { vk_colorspace } +/// Relay commands sent to the shared command socket onto this process's own, +/// ref(d-0085). +/// +/// Whatever still sends to the shared path (a compositor's colour message, a +/// hub before it has heard from any encoder) reaches one encoder through it. +/// Taken only if nobody holds it: a live socket is another game's, and +/// unlinking it would cut that game off. One relay per process, however many +/// times the pipeline is rebuilt, so no thread is left behind. Best effort: +/// a process that cannot have it still has its own socket. +fn relay_legacy_commands(own: std::path::PathBuf) { + use std::os::unix::net::UnixDatagram; + use std::sync::atomic::{AtomicBool, Ordering}; + static RELAYING: AtomicBool = AtomicBool::new(false); + if RELAYING.swap(true, Ordering::AcqRel) { + return; + } + let shared = std::path::Path::new(nesprotocol::CAPTURE_CMD_SOCKET); + let live = UnixDatagram::unbound() + .and_then(|probe| probe.connect(shared)) + .is_ok(); + if live { + log::info!("{} belongs to another encoder; leaving it", shared.display()); + RELAYING.store(false, Ordering::Release); + return; + } + let _ = std::fs::remove_file(shared); + let sock = match UnixDatagram::bind(shared) { + Ok(s) => s, + Err(e) => { + log::warn!("shared cmd socket bind failed: {e}"); + RELAYING.store(false, Ordering::Release); + return; + } + }; + let _ = std::fs::set_permissions(shared, std::os::unix::fs::PermissionsExt::from_mode(0o666)); + let spawned = std::thread::Builder::new() + .name("nescapture-cmd-relay".into()) + .spawn(move || { + let Ok(out) = UnixDatagram::unbound() else { + return; + }; + let mut buf = [0u8; 128]; + while let Ok(n) = sock.recv(&mut buf) { + if let Err(e) = out.send_to(&buf[..n], &own) { + log::debug!("relaying a shared-socket command failed: {e}"); + } + } + }); + if let Err(e) = spawned { + log::warn!("spawn cmd relay: {e}"); + } +} + +/// `STREAM_ENCODER_HELLO` for this process: its pid and `NESTRI_LAUNCH_ID`. +fn encoder_hello() -> Vec { + let launch = std::env::var("NESTRI_LAUNCH_ID").unwrap_or_default(); + encode_ipc_frame_from( + std::process::id(), + nesprotocol::STREAM_ENCODER_HELLO, + 0, + 0, + 0, + 0, + 0, + launch.as_bytes(), + ) +} + /// A surface colour from the compositor, as a Vulkan colour space. /// /// Mapped into the swapchain's own vocabulary so there is one thing to reason @@ -1080,7 +1148,6 @@ impl PipelineHandle { encode_ms: encode_avg_ms.clone(), dropped: dropped_frames.clone(), idr_requested: idr_requested.clone(), - epoch: Instant::now(), reconfig_tx: reconfig_tx.clone(), }; let ipc_prog = ipc_progress.clone(); @@ -1133,7 +1200,13 @@ impl PipelineHandle { thread::Builder::new() .name("nescapture-idr".into()) .spawn(move || { - let cmd_path = std::path::PathBuf::from("/tmp/nescapture-cmd.sock"); + // A socket of this process's own, ref(d-0085): with two games + // in one box, one shared path meant the second encoder took it + // from the first, and every keyframe request and bitrate change + // after that reached only the newer game. + let cmd_path = std::path::PathBuf::from(nesprotocol::capture_cmd_socket( + std::process::id(), + )); let _ = std::fs::remove_file(&cmd_path); let sock = match std::os::unix::net::UnixDatagram::bind(&cmd_path) { Ok(s) => { @@ -1149,6 +1222,10 @@ impl PipelineHandle { return; } }; + // The shared path too, relayed onto this one, so a hub or a + // compositor that predates per-process sockets still reaches the + // newest encoder exactly as it did. + relay_legacy_commands(cmd_path.clone()); let mut buf = [0u8; 128]; loop { match sock.recv(&mut buf) { @@ -2709,14 +2786,14 @@ fn bgra_to_yuv420(pixels: &[u8], width: u32, height: u32, vk_format: u32) -> Vec /// has: it only has to tell one frame from the next. fn pyro_messages( frame: &nespyro::EncodedFrame, - ts_ms: u32, + captured_us: u64, width: u16, height: u16, ) -> Result>, nesprotocol::pyrowave::ChunkError> { nesprotocol::pyrowave::chunk_frame( frame.index as u32, nesprotocol::pyrowave::FrameMeta { - ts_ms, + captured_us, width, height, }, @@ -2741,8 +2818,6 @@ struct IpcConfig { dropped: Arc, /// Shared with the encoder thread, which honours it on the next frame. idr_requested: Arc, - /// Zero point for wire timestamps. - epoch: Instant, /// For the rate probe to command its own steps. /// /// The probe has to live where the encoded sizes are, which is here, and @@ -2799,9 +2874,6 @@ fn ipc_send_thread( } }; - // Fixed before the first frame arrives, so every timestamp shares an epoch - // even though frames are stamped from their own present. - let start_time = cfg.epoch; let mut frame_count: u64 = 0; // So a PyroWave encoder short of scratch space is said once per episode. let mut scratch_overflowing = false; @@ -2920,9 +2992,11 @@ fn ipc_send_thread( // however long the frame spent queued for the encoder into the // timestamp, so the receiver could not tell capture time from // backlog and had nothing honest to pace on. - let timestamp_ms = present_time - .saturating_duration_since(start_time) - .as_millis() as u32; + // + // On the clock every media stamp on this machine shares, so the hub + // can measure capture-to-send exactly and the client can measure + // the path from it. + let captured_us = nesprotocol::clock::instant_us(present_time); // Set FLAG_RECONFIG on the first frame after an encoder reconfig. // Clear it after setting so only the first frame is marked. Taken // for a PyroWave frame too, which carries no flags, so that the @@ -2944,16 +3018,28 @@ fn ipc_send_thread( if reconfig { flags |= FLAG_RECONFIG; } - let ipc_frame = encode_ipc_frame( + // Tagged with this process, so the hub can tell which + // window the frame is, ref(d-0085). + let ipc_frame = encode_ipc_frame_from( + std::process::id(), STREAM_VIDEO, protocol_codec, flags, - timestamp_ms, + captured_us, width, height, &pkt.data, ); - (vec![ipc_frame], pkt.is_key_frame, pkt.data.len()) + // The launch this encoder draws for goes ahead of every + // keyframe, ref(d-0085): the hub cannot read this + // process's environment, and a keyframe is where a late + // hub starts anyway. + let messages = if pkt.is_key_frame { + vec![encoder_hello(), ipc_frame] + } else { + vec![ipc_frame] + }; + (messages, pkt.is_key_frame, pkt.data.len()) } Encoded::Pyro(frame) => { let enc_ms = (frame.stats.gpu_ns() / 1e6) as f32; @@ -2974,7 +3060,7 @@ fn ipc_send_thread( log::info!("PyroWave encoder has scratch space again"); } scratch_overflowing = overflowed; - match pyro_messages(frame, timestamp_ms, width, height) { + match pyro_messages(frame, captured_us, width, height) { // Every PyroWave frame stands alone, so none is a key // frame in the sense the counters below mean. Ok(messages) => (messages, false, frame.data.len()), @@ -4808,7 +4894,7 @@ mod pyrowave_tests { assert_eq!(got.frame, 41); assert_eq!(got.critical, 4); assert_eq!( - (got.meta.ts_ms, got.meta.width, got.meta.height), + (got.meta.captured_us, got.meta.width, got.meta.height), (1234, 2560, 1440) ); assert_eq!(got.packets.len(), frame.packets.len()); diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 075ec57f..c75212df 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -158,6 +158,20 @@ impl XdgShellHandler for NescopeState { &mut self.xdg_shell_state } + fn title_changed(&mut self, surface: ToplevelSurface) { + // The one place a toplevel's title is read after it was opened, not + // every commit. + let root = surface.wl_surface().clone(); + let window = self + .space + .elements() + .find(|w| w.wl_surface().is_some_and(|s| *s == root)) + .cloned(); + if let Some(window) = window { + self.window_retitled(&window); + } + } + fn new_toplevel(&mut self, surface: ToplevelSurface) { // Tell the toplevel to fill the entire virtual output. surface.with_pending_state(|state| { @@ -167,11 +181,13 @@ impl XdgShellHandler for NescopeState { let window = Window::new_wayland_window(surface); self.space.map_element(window.clone(), (0, 0), false); + self.window_opened(&window); self.set_keyboard_focus_to_window(&window); tracing::debug!("New XDG toplevel"); } fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + self.window_closed(&crate::state::WinKey::Wayland(surface.wl_surface().clone())); // Remove the dead window from the space. let elem = self .space @@ -467,7 +483,9 @@ impl XwmHandler for CalloopData { return; } let win = Window::new_x11_window(window); - self.state.space.map_element(win, (0, 0), true); + self.state.space.map_element(win.clone(), (0, 0), true); + // An id on first map; a window unmapped and mapped again keeps it. + self.state.window_opened(&win); self.state.determine_and_apply_focus(); } @@ -493,6 +511,9 @@ impl XwmHandler for CalloopData { if !window.is_override_redirect() { let _ = window.set_mapped(false); + // Closed for the hub; mapped again, it opens under the same id. + self.state + .window_unmapped(&crate::state::WinKey::X11(window.window_id())); } if was_focused { @@ -501,7 +522,10 @@ impl XwmHandler for CalloopData { } } - fn destroyed_window(&mut self, _xwm: XwmId, _window: X11Surface) {} + fn destroyed_window(&mut self, _xwm: XwmId, window: X11Surface) { + self.state + .window_closed(&crate::state::WinKey::X11(window.window_id())); + } fn configure_request( &mut self, @@ -543,11 +567,14 @@ impl XwmHandler for CalloopData { } } - fn property_notify(&mut self, _xwm: XwmId, _window: X11Surface, property: WmWindowProperty) { + fn property_notify(&mut self, _xwm: XwmId, window: X11Surface, property: WmWindowProperty) { // Only recalculate focus when the window class changes (steam_app_* detection). if matches!(property, WmWindowProperty::Class) { self.state.determine_and_apply_focus(); } + if matches!(property, WmWindowProperty::Title) { + self.state.window_retitled(&Window::new_x11_window(window)); + } } fn resize_request(&mut self, _: XwmId, _: X11Surface, _: u32, _: ResizeEdge) {} diff --git a/apps/nescope/src/input.rs b/apps/nescope/src/input.rs index 825be0b6..f68e7bec 100644 --- a/apps/nescope/src/input.rs +++ b/apps/nescope/src/input.rs @@ -338,6 +338,10 @@ pub fn decode_wire_event(data: &[u8]) -> Option { DecodedInput::MouseWheel { dx: _dx, dy } => { Some(InputEvent::ScrollVertical { amount: dy as f64 }) } + // Not input: the IPC source hands it to the output before this. + DecodedInput::OutputSize { .. } => None, + // Box-internal window messages: the IPC source handles them before this. + DecodedInput::WindowOutputSize { .. } | DecodedInput::WindowFocus { .. } => None, } } diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 6256354e..425def56 100644 --- a/apps/nescope/src/main.rs +++ b/apps/nescope/src/main.rs @@ -70,6 +70,7 @@ mod libinput_backend; //mod screenshot_ipc; //mod screenshot_wire; mod state; +mod windows; mod xwm; use crate::input::{decode_wire_event, process_input}; @@ -176,8 +177,8 @@ struct Args { /// Game command — everything after '--'. /// - /// **Optional.** With one, nescope launches it and exits when it and its - /// windows are gone — a wrapper around a single game. Without one, nescope + /// **Optional.** With one, nescope launches it and exits once it has + /// exited and no window of any launch is left. Without one, nescope /// is a plain compositor: it comes up, publishes its displays and waits, /// and whatever wants to draw connects to it. /// @@ -397,6 +398,7 @@ fn main() { Ok(write_stream) => { state.ipc_write = Some(write_stream); state.cursor_image_sent = false; // re-send on reconnect + state.hub_connected(); } Err(e) => { tracing::warn!("Failed to clone IPC write stream: {e}"); @@ -404,8 +406,25 @@ fn main() { } loop_handle .insert_source(source, move |payload, _, data| { - if let Some(event) = decode_wire_event(&payload) { - process_input(event, &mut data.state); + use nesprotocol::input::DecodedInput; + match nesprotocol::input::decode_input_event(&payload) { + // The old form is window 0's, ref(d-0085). + Some(DecodedInput::OutputSize { width, height }) => { + data.state.window_output_size(0, width, height) + } + Some(DecodedInput::WindowOutputSize { + window, + width, + height, + }) => data.state.window_output_size(window, width, height), + Some(DecodedInput::WindowFocus { window }) => { + data.state.focus_window_from_hub(window) + } + _ => { + if let Some(event) = decode_wire_event(&payload) { + process_input(event, &mut data.state); + } + } } }) .expect("Failed to register input IPC source"); @@ -583,8 +602,11 @@ fn main() { } // ── Auto-exit after all windows are gone ────────────────── - // Wait 5 s after the last mapped window disappears to give - // any lingering save-game / cleanup processes time to finish. + // Only once this nescope's own game has exited AND no window of + // any launch is left: later launches draw on this compositor, so + // their windows keep it up, ref(d-0085). The 5 s wait gives + // lingering save-game / cleanup processes time to finish. A box + // with one game ends exactly as before. if data.state.game_launched && data.game_process.is_none() { let has_windows = data.state.space.elements().next().is_some(); if !has_windows { diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index b682ae66..622dcd04 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -59,6 +59,38 @@ use crate::input::InputEvent; // CalloopData — threaded through the event loop // --------------------------------------------------------------------------- +/// The smallest side a client's window can make the output. Below it, games +/// lay out menus they cannot fit, and nothing is gained. +const MIN_OUTPUT: u32 = 320; + +/// `want` scaled down, keeping its shape, until it fits inside `max`, and +/// never smaller than [`MIN_OUTPUT`] on a side. Even on both sides. +fn fit_output(want: (u32, u32), max: (u32, u32)) -> (u32, u32) { + let (w, h) = (want.0.max(1) as f64, want.1.max(1) as f64); + let scale = (max.0 as f64 / w).min(max.1 as f64 / h).min(1.0); + let side = |v: f64, cap: u32| ((v * scale).round() as u32).clamp(MIN_OUTPUT.min(cap), cap) & !1; + (side(w, max.0), side(h, max.1)) +} + +/// What a window is known by for its id: its Wayland surface, or for an +/// Xwayland window its X11 id (the surface comes and goes with mapping). +#[derive(Debug, Clone, PartialEq)] +pub enum WinKey { + Wayland(WlSurface), + X11(u32), +} + +impl WinKey { + pub fn of(window: &Window) -> Option { + if let Some(x11) = window.x11_surface() { + return Some(WinKey::X11(x11.window_id())); + } + window + .toplevel() + .map(|t| WinKey::Wayland(t.wl_surface().clone())) + } +} + pub struct CalloopData { pub state: NescopeState, pub display: Display, @@ -192,9 +224,21 @@ pub struct NescopeState { pub presentation_seq: u64, + // ── Windows, ref(d-0085) ────────────────────────────────────────────── + /// Every toplevel's id, and what the hub has been told about it. + pub windows: crate::windows::Registry, + /// Which window input is for, as the hub's focus tags say. + pub window_input: crate::windows::WindowInput, + /// The window the cursor updates last sent describe. + last_cursor_window: nesprotocol::window::WindowId, + // ── Dimensions + frame rate ─────────────────────────────────────────── pub width: u32, pub height: u32, + /// The size the box was started at, which is what its encode budget was + /// counted for, ref(d-0036): a client's window can shrink the output + /// below it, never grow it past. + pub launch_size: (u32, u32), pub fps: u32, // ── Lifecycle ───────────────────────────────────────────────────────── @@ -326,8 +370,12 @@ impl NescopeState { last_sent_cursor_status: 0xFF, last_pointer_activity: std::time::Instant::now(), presentation_seq: 0, + windows: Default::default(), + window_input: Default::default(), + last_cursor_window: 0, width, height, + launch_size: (width, height), fps, loop_handle, game_launched: false, @@ -462,8 +510,16 @@ impl NescopeState { // Resize // ----------------------------------------------------------------------- + /// The client's window is `width`x`height`: follow it, inside the launch + /// size and on even numbers, which every encoder needs for 4:2:0. + pub fn request_output_size(&mut self, width: u16, height: u16) { + let (w, h) = fit_output((width.into(), height.into()), self.launch_size); + if (w, h) != (self.width, self.height) { + self.apply_resize(w, h); + } + } + /// Apply a new output resolution (e.g. from a runtime resize request). - #[allow(unused)] pub fn apply_resize(&mut self, width: u32, height: u32) { tracing::info!("Applying resize: {width}x{height}"); self.width = width; @@ -476,6 +532,11 @@ impl NescopeState { self.output .change_current_state(Some(mode), None, None, None); + let size = (clamp16(width), clamp16(height)); + for m in self.windows.resize_all(size) { + self.send_box_message(&m); + } + for window in self.space.elements().cloned().collect::>() { if let Some(tl) = window.toplevel() { tl.with_pending_state(|s| { @@ -757,8 +818,157 @@ impl NescopeState { send_frames_surface_tree(root, &output, now, None, |_, _| Some(output.clone())); } + // ----------------------------------------------------------------------- + // Windows, ref(d-0085) + // ----------------------------------------------------------------------- + + /// One box-internal message to the hub on the input socket. + pub(crate) fn send_box_message(&mut self, message: &[u8]) { + use std::io::Write; + let Some(ipc) = self.ipc_write.as_mut() else { + return; + }; + let mut frame = Vec::with_capacity(2 + message.len()); + frame.extend_from_slice(&(message.len() as u16).to_le_bytes()); + frame.extend_from_slice(message); + let _ = ipc.write_all(&frame); + let _ = ipc.flush(); + } + + /// A toplevel was mapped: give it an id, once, and tell the hub. + pub(crate) fn window_opened(&mut self, window: &Window) { + let Some(key) = WinKey::of(window) else { + return; + }; + let (pid, title, app_id) = if let Some(x11) = window.x11_surface() { + // The Wayland client of an Xwayland window is Xwayland itself; + // the game says who it is in _NET_WM_PID. + (x11.pid(), x11.title(), x11.class()) + } else if let Some(toplevel) = window.toplevel() { + use smithay::reexports::wayland_server::Resource as _; + let pid = toplevel + .wl_surface() + .client() + .and_then(|c| c.get_credentials(&self.display_handle).ok()) + .and_then(|c| u32::try_from(c.pid).ok()); + let (title, app_id) = toplevel_title(toplevel.wl_surface()); + (pid, title, app_id) + } else { + return; + }; + let new = crate::windows::NewWindow { + pid, + title, + app_id, + size: (clamp16(self.width), clamp16(self.height)), + }; + if let Some(messages) = + self.windows + .open(key, new, crate::windows::launch_of_process) + { + for m in messages { + self.send_box_message(&m); + } + } + } + + /// A toplevel was destroyed. + pub(crate) fn window_closed(&mut self, key: &WinKey) { + let id = self.windows.id_of(key); + if let Some(m) = self.windows.close(key) { + self.send_box_message(&m); + } + if let Some(id) = id { + self.window_input.forget(id); + } + } + + /// An X11 window was unmapped: closed for the hub, its id kept. + pub(crate) fn window_unmapped(&mut self, key: &WinKey) { + if let Some(m) = self.windows.unmap(key) { + self.send_box_message(&m); + } + } + + /// A window's title may have changed. + pub(crate) fn window_retitled(&mut self, window: &Window) { + let Some(key) = WinKey::of(window) else { + return; + }; + let title = match window.x11_surface() { + Some(x11) => x11.title(), + None => match window.toplevel() { + Some(t) => toplevel_title(t.wl_surface()).0, + None => return, + }, + }; + if let Some(m) = self.windows.retitle(&key, &title) { + self.send_box_message(&m); + } + } + + /// The hub's input connection is new: input is window 0's until it says + /// otherwise, and it hears every window there is. + pub(crate) fn hub_connected(&mut self) { + self.window_input.reset(); + let size = (clamp16(self.width), clamp16(self.height)); + for m in self.windows.announce_all(size) { + self.send_box_message(&m); + } + } + + /// The hub says input from here on is `id`'s: give it keyboard focus. + /// Nothing is raised and the output keeps its size: a fullscreen game + /// must not lose focus, or the output change size, because a client + /// looked at another window. Size follows only an explicit request, + /// ref(d-0085). + pub(crate) fn focus_window_from_hub(&mut self, id: nesprotocol::window::WindowId) { + if !self.window_input.focus(id) { + return; + } + let key = self.windows.key_of(id).cloned(); + let window = key.and_then(|key| { + self.space + .elements() + .find(|w| WinKey::of(w).as_ref() == Some(&key)) + .cloned() + }); + match window { + Some(window) => self.set_keyboard_focus_to_window(&window), + None => tracing::debug!("focus for window {id}, which is not mapped"), + } + } + + /// A client's window is `width`x`height`, for window `id`. + pub(crate) fn window_output_size( + &mut self, + id: nesprotocol::window::WindowId, + width: u16, + height: u16, + ) { + if let Some((w, h)) = + self.window_input + .size(id, width, height, std::time::Instant::now()) + { + self.request_output_size(w, h); + } + } + pub(crate) fn send_cursor_update(&mut self) { use std::io::Write; + // Cursor updates describe the window input is going to; the hub sends + // them to that window's clients. Said only when it changes, so a box + // that only ever has window 0 sends what it always did. + let current = self.window_input.current(); + if current != self.last_cursor_window && self.ipc_write.is_some() { + let mut m = Vec::with_capacity(3); + nesprotocol::window::encode_cursor_window(&mut m, current); + self.send_box_message(&m); + self.last_cursor_window = current; + // Those clients may never have had the image or the state. + self.cursor_image_sent = false; + self.last_sent_cursor_status = 0xFF; + } let ipc = match self.ipc_write.as_mut() { Some(s) => s, None => return, @@ -833,6 +1043,27 @@ impl NescopeState { // Helpers // --------------------------------------------------------------------------- +fn clamp16(v: u32) -> u16 { + v.min(u32::from(u16::MAX)) as u16 +} + +/// An xdg toplevel's title and app id, empty where the client set none. +fn toplevel_title(surface: &WlSurface) -> (String, String) { + smithay::wayland::compositor::with_states(surface, |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok()) + .map(|d| { + ( + d.title.clone().unwrap_or_default(), + d.app_id.clone().unwrap_or_default(), + ) + }) + .unwrap_or_default() + }) +} + fn intern_atom( conn: &smithay::reexports::x11rb::rust_connection::RustConnection, name: &[u8], @@ -961,3 +1192,24 @@ where let global = dmabuf_state.create_global::(display, formats.iter().copied()); (dmabuf_state, global) } + +#[cfg(test)] +mod fit_output_tests { + use super::fit_output; + + #[test] + fn a_smaller_window_is_followed() { + assert_eq!(fit_output((1280, 720), (1920, 1080)), (1280, 720)); + } + + #[test] + fn a_larger_window_keeps_its_shape() { + assert_eq!(fit_output((2560, 1600), (1920, 1080)), (1728, 1080)); + } + + #[test] + fn odd_and_tiny_sizes_are_made_usable() { + assert_eq!(fit_output((1281, 721), (1920, 1080)), (1280, 720)); + assert_eq!(fit_output((10, 10), (1920, 1080)), (320, 320)); + } +} diff --git a/apps/nescope/src/windows.rs b/apps/nescope/src/windows.rs new file mode 100644 index 00000000..acf20b8b --- /dev/null +++ b/apps/nescope/src/windows.rs @@ -0,0 +1,440 @@ +//! Window ids and what the hub is told about each window, ref(d-0085). +//! +//! Every toplevel gets an id when it is first mapped: `0` for the first window +//! of this compositor, then counting up, never reused, and none past 65,535. +//! The hub hears each window's lifecycle on the input socket in nesprotocol's +//! window encoding, and which process owns it, which is how it joins an +//! encoder's frames (tagged with its process) to the window they draw. +//! +//! This module holds no compositor objects beyond an opaque key, so the rules +//! can be tested without one. + + +use nesprotocol::window::{WindowEvent, WindowId, encode_event_message, encode_window_pid}; + +/// One window as the hub knows it. +#[derive(Debug, Clone)] +pub struct Tracked { + pub key: K, + pub id: WindowId, + pub pid: Option, + /// False while an X11 window is unmapped: the hub has been told it closed, + /// and a map again opens it under the same id. + mapped: bool, + launch_id: String, + title: String, + app_id: String, +} + +/// The windows this compositor has given ids to. +#[derive(Debug)] +pub struct Registry { + /// The next id to mint; past `u16::MAX` none are left. + next: u32, + windows: Vec>, + refused: bool, +} + +impl Default for Registry { + fn default() -> Self { + Self { + next: 0, + windows: Vec::new(), + refused: false, + } + } +} + +/// What the compositor knows about a window when it maps. +pub struct NewWindow { + pub pid: Option, + pub title: String, + pub app_id: String, + pub size: (u16, u16), +} + +impl Registry { + /// Give a newly mapped window an id, and return the messages that tell the + /// hub. A window unmapped earlier is opened again under its old id. `None` + /// for a window that is already open, or when ids have run out for this + /// run. + pub fn open( + &mut self, + key: K, + new: NewWindow, + launch_of: impl Fn(u32) -> Option, + ) -> Option>> { + if let Some(w) = self.windows.iter_mut().find(|w| w.key == key) { + if w.mapped { + return None; + } + w.mapped = true; + w.pid = new.pid.or(w.pid); + w.title = new.title; + w.app_id = new.app_id; + tracing::info!(window = w.id, "window opened again"); + return Some(announce(w, new.size)); + } + let Ok(id) = WindowId::try_from(self.next) else { + if !self.refused { + tracing::error!("this compositor has minted every window id; refusing new windows"); + self.refused = true; + } + return None; + }; + self.next += 1; + let launch_id = new.pid.and_then(launch_of).unwrap_or_default(); + let tracked = Tracked { + key, + id, + pid: new.pid, + mapped: true, + launch_id, + title: new.title, + app_id: new.app_id, + }; + let messages = announce(&tracked, new.size); + tracing::info!( + window = id, + pid = ?tracked.pid, + launch = %tracked.launch_id, + app_id = %tracked.app_id, + "window opened" + ); + self.windows.push(tracked); + Some(messages) + } + + /// A window went for good. The message for the hub, if it was open. + pub fn close(&mut self, key: &K) -> Option> { + let at = self.windows.iter().position(|w| w.key == *key)?; + let gone = self.windows.remove(at); + tracing::info!(window = gone.id, "window closed"); + gone.mapped.then(|| closed(gone.id)) + } + + /// An X11 window was unmapped: closed for the hub, but its id is kept for + /// when it is mapped again. + pub fn unmap(&mut self, key: &K) -> Option> { + let w = self.windows.iter_mut().find(|w| w.key == *key)?; + if !w.mapped { + return None; + } + w.mapped = false; + tracing::info!(window = w.id, "window unmapped"); + Some(closed(w.id)) + } + + /// A window's title is `title` now. The message for the hub if it changed. + pub fn retitle(&mut self, key: &K, title: &str) -> Option> { + let w = self.windows.iter_mut().find(|w| w.key == *key)?; + if w.title == title { + return None; + } + w.title = title.to_string(); + if !w.mapped { + return None; + } + let mut m = Vec::new(); + encode_event_message( + &mut m, + &WindowEvent::Title { + window: w.id, + title: w.title.clone(), + }, + ); + Some(m) + } + + /// Every window is now `size`: one output, which every window fills. + pub fn resize_all(&self, size: (u16, u16)) -> Vec> { + self.windows + .iter() + .filter(|w| w.mapped) + .map(|w| { + let mut m = Vec::new(); + encode_event_message( + &mut m, + &WindowEvent::Size { + window: w.id, + width: size.0, + height: size.1, + }, + ); + m + }) + .collect() + } + + /// Everything the hub needs to know of every window, for a hub that has + /// just connected. + pub fn announce_all(&self, size: (u16, u16)) -> Vec> { + self.windows + .iter() + .filter(|w| w.mapped) + .flat_map(|w| announce(w, size)) + .collect() + } + + pub fn id_of(&self, key: &K) -> Option { + self.windows.iter().find(|w| w.key == *key).map(|w| w.id) + } + + pub fn key_of(&self, id: WindowId) -> Option<&K> { + self.windows + .iter() + .find(|w| w.id == id && w.mapped) + .map(|w| &w.key) + } +} + +fn closed(window: WindowId) -> Vec { + let mut m = Vec::new(); + encode_event_message(&mut m, &WindowEvent::Closed { window }); + m +} + +fn announce(w: &Tracked, size: (u16, u16)) -> Vec> { + let mut out = Vec::with_capacity(2); + let mut m = Vec::new(); + encode_event_message( + &mut m, + &WindowEvent::Opened { + window: w.id, + width: size.0, + height: size.1, + launch_id: w.launch_id.clone(), + title: w.title.clone(), + app_id: w.app_id.clone(), + }, + ); + out.push(m); + if let Some(pid) = w.pid { + let mut m = Vec::new(); + encode_window_pid(&mut m, w.id, pid); + out.push(m); + } + out +} + +/// Read a process's `NESTRI_LAUNCH_ID`, which the launch put in its +/// environment and its children inherit. +pub fn launch_of_process(pid: u32) -> Option { + let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + environ + .split(|b| *b == 0) + .find_map(|kv| kv.strip_prefix(b"NESTRI_LAUNCH_ID=")) + .and_then(|v| String::from_utf8(v.to_vec()).ok()) +} + +/// How long the output keeps the size one window gave it before another +/// window may change it. +pub const OUTPUT_HYSTERESIS: std::time::Duration = std::time::Duration::from_millis(500); + +/// Which window input is for, and who may size the output. +/// +/// The hub tags input with focus messages; until it sends one, and again +/// after every reconnect, input is window `0`'s, which is what a hub that +/// predates tags means. Focus moves keyboard and pointer input and nothing +/// else. With one output for every window, only an explicit size request from +/// the window that has focus changes it; when several windows ask, the last +/// writer wins, but not within [`OUTPUT_HYSTERESIS`] of another window's +/// change, so two clients of different sizes do not fight over it, +/// ref(d-0085). +#[derive(Debug, Default)] +pub struct WindowInput { + current: WindowId, + last_resize: Option<(WindowId, std::time::Instant)>, +} + +impl WindowInput { + /// The input connection is new: back to window `0`. + pub fn reset(&mut self) { + self.current = 0; + } + + pub fn current(&self) -> WindowId { + self.current + } + + /// `window`'s client is `width`x`height`. The size to apply now, if any: + /// only for the window that owns the output, and not within + /// [`OUTPUT_HYSTERESIS`] of a change another window made. + pub fn size( + &mut self, + window: WindowId, + width: u16, + height: u16, + now: std::time::Instant, + ) -> Option<(u16, u16)> { + if window != self.current { + return None; + } + if let Some((who, at)) = self.last_resize + && who != window + && now.duration_since(at) < OUTPUT_HYSTERESIS + { + return None; + } + self.last_resize = Some((window, now)); + Some((width, height)) + } + + /// Input from here on is `window`'s. Whether that is a change. + pub fn focus(&mut self, window: WindowId) -> bool { + if window == self.current { + return false; + } + self.current = window; + true + } + + pub fn forget(&mut self, window: WindowId) { + if self.last_resize.is_some_and(|(w, _)| w == window) { + self.last_resize = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nesprotocol::window::{MSG_WINDOW_PID, decode_window_pid}; + + fn new(pid: Option, title: &str) -> NewWindow { + NewWindow { + pid, + title: title.into(), + app_id: "app".into(), + size: (1920, 1080), + } + } + + fn decode(m: &[u8]) -> WindowEvent { + WindowEvent::decode(m[0], &m[1..]).expect("an event") + } + + #[test] + fn ids_start_at_zero_and_are_never_reused() { + let mut r = Registry::::default(); + let launch = |pid: u32| Some(format!("launch_{pid}")); + let a = r.open(10, new(Some(1), "a"), launch).unwrap(); + assert_eq!( + decode(&a[0]), + WindowEvent::Opened { + window: 0, + width: 1920, + height: 1080, + launch_id: "launch_1".into(), + title: "a".into(), + app_id: "app".into(), + } + ); + assert_eq!(a[1][0], MSG_WINDOW_PID); + assert_eq!(decode_window_pid(&a[1][1..]), Some((0, 1))); + assert!( + r.open(10, new(Some(1), "a"), launch).is_none(), + "once per window" + ); + let b = r.open(11, new(None, "b"), launch).unwrap(); + assert_eq!(b.len(), 1, "no owner to name"); + assert_eq!(decode(&b[0]).window(), 1); + assert_eq!( + decode(&r.close(&10).unwrap()), + WindowEvent::Closed { window: 0 } + ); + assert!(r.close(&10).is_none()); + let c = r.open(12, new(None, "c"), launch).unwrap(); + assert_eq!(decode(&c[0]).window(), 2, "0 is not given out again"); + assert_eq!(r.key_of(2), Some(&12)); + assert_eq!(r.id_of(&11), Some(1)); + } + + #[test] + fn ids_run_out_rather_than_wrap() { + let mut r = Registry:: { + next: u32::from(u16::MAX), + ..Default::default() + }; + assert!(r.open(1, new(None, ""), |_| None).is_some()); + assert!(r.open(2, new(None, ""), |_| None).is_none()); + assert!(r.open(3, new(None, ""), |_| None).is_none()); + } + + #[test] + fn titles_and_sizes() { + let mut r = Registry::::default(); + r.open(1, new(None, "a"), |_| None); + assert!(r.retitle(&1, "a").is_none(), "unchanged says nothing"); + assert_eq!( + decode(&r.retitle(&1, "b").unwrap()), + WindowEvent::Title { + window: 0, + title: "b".into() + } + ); + r.open(2, new(None, "c"), |_| None); + let sizes: Vec<_> = r + .resize_all((1280, 720)) + .iter() + .map(|m| decode(m)) + .collect(); + assert_eq!(sizes.len(), 2); + assert_eq!( + sizes[1], + WindowEvent::Size { + window: 1, + width: 1280, + height: 720 + } + ); + assert_eq!(r.announce_all((1, 1)).len(), 2); + } + + #[test] + fn an_unmapped_window_is_closed_for_the_hub_and_keeps_its_id() { + let mut r = Registry::::default(); + let launch = |_: u32| None; + r.open(7, new(Some(1), "a"), launch).unwrap(); + assert_eq!(decode(&r.unmap(&7).unwrap()), WindowEvent::Closed { window: 0 }); + assert!(r.unmap(&7).is_none()); + assert!(r.retitle(&7, "b").is_none(), "nothing to tell while closed"); + assert!(r.announce_all((1, 1)).is_empty()); + let again = r.open(7, new(Some(1), "c"), launch).unwrap(); + assert_eq!(decode(&again[0]).window(), 0); + assert!(matches!(decode(&again[0]), WindowEvent::Opened { .. })); + // Destroyed while unmapped: nothing more to say. + r.unmap(&7); + assert!(r.close(&7).is_none()); + assert_eq!(decode(&r.open(8, new(None, "d"), launch).unwrap()[0]).window(), 1); + } + + #[test] + fn focus_moves_input_only_and_sizing_is_the_focused_windows() { + use std::time::{Duration, Instant}; + let t = Instant::now(); + let mut i = WindowInput::default(); + // A hub that predates tags: every resize is window 0's, applied. + assert_eq!(i.size(0, 1280, 720, t), Some((1280, 720))); + // Another window's resize is not applied, and not remembered. + assert_eq!(i.size(3, 800, 600, t), None); + assert!(i.focus(3)); + assert!(!i.focus(3)); + // Focus alone changed no size; the window must ask. + // Within the hysteresis of window 0's change: held off. + assert_eq!(i.size(3, 1024, 768, t + Duration::from_millis(100)), None); + assert_eq!( + i.size(3, 1024, 768, t + Duration::from_millis(500)), + Some((1024, 768)) + ); + // The same window may resize at once. + assert_eq!( + i.size(3, 1100, 800, t + Duration::from_millis(510)), + Some((1100, 800)) + ); + assert!(i.focus(0)); + assert_eq!(i.size(0, 640, 480, t + Duration::from_millis(600)), None); + i.reset(); + assert_eq!(i.current(), 0); + } +} diff --git a/apps/neshub/examples/pyro_bench.rs b/apps/neshub/examples/pyro_bench.rs index cd5a5f9f..b12ebc87 100644 --- a/apps/neshub/examples/pyro_bench.rs +++ b/apps/neshub/examples/pyro_bench.rs @@ -102,11 +102,11 @@ async fn send(conn: Connection, o: &Opts) -> Result<()> { while Instant::now() < end { tick.tick().await; let t0 = Instant::now(); - f.meta.ts_ms = seq as u32; + f.meta.captured_us = u64::from(seq); // Read per frame, as the hub does: with several paths up, the limit // is the smallest of their MTUs and moves as paths come and go. let now_max = conn.max_datagram_size().context("datagrams went away")?; - let d = pack_datagrams(seq, &f, now_max)?; + let d = pack_datagrams(seq, &f, now_max, nesprotocol::clock::monotonic_us())?; let backlog = DGRAM_BUFFER_BYTES.saturating_sub(conn.datagram_send_buffer_space()); if should_skip(backlog, d.buf.len(), DGRAM_BUFFER_BYTES).is_some() { skipped += 1; diff --git a/apps/neshub/src/capture_cmd.rs b/apps/neshub/src/capture_cmd.rs new file mode 100644 index 00000000..32cf9d55 --- /dev/null +++ b/apps/neshub/src/capture_cmd.rs @@ -0,0 +1,235 @@ +//! Where commands for the encoders go, ref(d-0085). +//! +//! Every game in the box runs an encoder of its own, listening on a socket +//! named for its process. The hub learns an encoder's process id from the +//! first frame it tags, and from then on sends every command to every encoder +//! it knows. Until it knows one, commands go to the shared socket, exactly as +//! they did when a box had one encoder: that is the path for an encoder that +//! predates tagging, and for every command sent before the first frame. +//! +//! An encoder that turns up later has missed what the others were told (the +//! codec every client decodes, the bitrate), so it is told again: the latest +//! command of each type, then a keyframe request. A keyframe request for one +//! client goes only to the encoder of the window that client is shown. The first encoder is not: every +//! command before it went to the shared socket, which reached it, and +//! repeating a codec change to an encoder already on that codec would restart +//! its stream for nothing. A box with one game therefore gets exactly the +//! commands it always did. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use nesprotocol::MSG_IDR_REQUEST; + +/// Marks a hub-internal command meant for one encoder: `[TO_ENCODER][4B pid] +/// [command]`. Stripped before anything is sent; never leaves the hub. +const TO_ENCODER: u8 = 0xF0; + +/// `cmd` for the encoder in `pid` alone, or for every encoder when `None`. +pub fn to_encoder(pid: Option, cmd: &[u8]) -> Vec { + match pid { + Some(pid) => { + let mut out = Vec::with_capacity(5 + cmd.len()); + out.push(TO_ENCODER); + out.extend_from_slice(&pid.to_le_bytes()); + out.extend_from_slice(cmd); + out + } + None => cmd.to_vec(), + } +} + +/// A keyframe request for the encoder in `pid`, or every encoder. +pub fn keyframe_request(pid: Option) -> Vec { + to_encoder(pid, &[MSG_IDR_REQUEST]) +} + +/// Split a queued command into its target and the command itself. +pub fn target_of(cmd: Vec) -> (Option, Vec) { + if cmd.len() >= 5 && cmd[0] == TO_ENCODER { + let pid = u32::from_le_bytes([cmd[1], cmd[2], cmd[3], cmd[4]]); + (Some(pid), cmd[5..].to_vec()) + } else { + (None, cmd) + } +} + +#[derive(Debug, Default)] +pub struct Fanout { + encoders: BTreeSet, + /// The latest command of each type, in the order they were last sent. + history: Vec>, + /// Whether any encoder has been known: only the very first is spared the + /// replay, since only it was reached by the shared socket. + ever_known: bool, +} + +impl Fanout { + /// Where a command goes now: every known encoder's own socket, or the + /// shared one while none is known. + pub fn destinations(&self) -> Vec { + if self.encoders.is_empty() { + return vec![PathBuf::from(nesprotocol::CAPTURE_CMD_SOCKET)]; + } + self.encoders + .iter() + .map(|pid| PathBuf::from(nesprotocol::capture_cmd_socket(*pid))) + .collect() + } + + /// Where a command for `target` goes: that encoder alone when it is + /// known, otherwise wherever an untargeted one would. + pub fn destinations_for(&self, target: Option) -> Vec { + match target { + Some(pid) if self.encoders.contains(&pid) => { + vec![PathBuf::from(nesprotocol::capture_cmd_socket(pid))] + } + _ => self.destinations(), + } + } + + /// Record a command as sent to every encoder, so a later one can be told + /// it. Only the latest of each type is kept. + pub fn sent(&mut self, cmd: &[u8]) { + let Some(&kind) = cmd.first() else { + return; + }; + if kind == MSG_IDR_REQUEST { + return; + } + self.history.retain(|c| c[0] != kind); + self.history.push(cmd.to_vec()); + } + + /// A frame came from `pid`. Returns what to send it if it is new to the + /// hub (or was forgotten), `None` if it was known. + pub fn encoder(&mut self, pid: u32) -> Option>> { + if !self.encoders.insert(pid) { + return None; + } + if !std::mem::replace(&mut self.ever_known, true) { + return Some(Vec::new()); + } + let mut replay = self.history.clone(); + replay.push(vec![MSG_IDR_REQUEST]); + Some(replay) + } + + /// The encoder in `pid` has gone: its socket refuses. + pub fn forget(&mut self, pid: u32) { + self.encoders.remove(&pid); + } +} + +/// Whether a send error means the encoder's process is gone, rather than a +/// passing condition (a full buffer) that the next command may not meet. +pub fn encoder_is_gone(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) +} + +/// The process id in an encoder's own socket path. +pub fn pid_of(path: &std::path::Path) -> Option { + path.file_name()? + .to_str()? + .strip_prefix("nescapture-cmd-")? + .strip_suffix(".sock")? + .parse() + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SETTINGS: &[u8] = &[nesprotocol::MSG_ENCODE_SETTINGS, 1, 2, 3]; + + #[test] + fn one_untagged_encoder_is_the_shared_socket_as_before() { + let mut f = Fanout::default(); + assert_eq!( + f.destinations(), + vec![PathBuf::from("/tmp/nescapture-cmd.sock")] + ); + f.sent(SETTINGS); + assert_eq!( + f.destinations(), + vec![PathBuf::from("/tmp/nescapture-cmd.sock")] + ); + } + + #[test] + fn the_first_encoder_is_not_told_twice() { + let mut f = Fanout::default(); + f.sent(SETTINGS); + assert_eq!(f.encoder(10), Some(vec![])); + assert_eq!(f.encoder(10), None); + assert_eq!( + f.destinations(), + vec![PathBuf::from("/tmp/nescapture-cmd-10.sock")] + ); + } + + #[test] + fn a_later_encoder_hears_the_latest_of_each_type_then_a_keyframe_request() { + let mut f = Fanout::default(); + f.sent(SETTINGS); + f.encoder(10); + f.sent(&[MSG_IDR_REQUEST]); + let caps = [nesprotocol::MSG_CLIENT_CAPS, 1]; + let bitrate = [nesprotocol::MSG_ENCODE_SETTINGS, 9]; + f.sent(&caps); + f.sent(&bitrate); + assert_eq!( + f.encoder(20), + Some(vec![caps.to_vec(), bitrate.to_vec(), vec![MSG_IDR_REQUEST]]) + ); + assert_eq!(f.destinations().len(), 2); + f.forget(10); + assert_eq!( + f.destinations(), + vec![PathBuf::from("/tmp/nescapture-cmd-20.sock")] + ); + // Forgotten, then heard from again: learnt again, and told again. + assert_eq!(f.encoder(10).map(|r| r.len()), Some(3)); + } + + #[test] + fn a_targeted_command_goes_to_its_encoder_alone() { + let mut f = Fanout::default(); + f.encoder(10); + f.encoder(20); + let (target, cmd) = target_of(keyframe_request(Some(20))); + assert_eq!(cmd, vec![MSG_IDR_REQUEST]); + assert_eq!( + f.destinations_for(target), + vec![PathBuf::from("/tmp/nescapture-cmd-20.sock")] + ); + // Unknown target, or none: everyone. + assert_eq!(f.destinations_for(Some(30)).len(), 2); + assert_eq!(target_of(keyframe_request(None)), (None, vec![MSG_IDR_REQUEST])); + assert!(encoder_is_gone(&std::io::Error::from(std::io::ErrorKind::ConnectionRefused))); + assert!(!encoder_is_gone(&std::io::Error::from(std::io::ErrorKind::WouldBlock))); + } + + #[test] + fn a_second_encoder_before_any_command_gets_a_keyframe_request() { + let mut f = Fanout::default(); + assert_eq!(f.encoder(10), Some(vec![])); + assert_eq!(f.encoder(11), Some(vec![vec![MSG_IDR_REQUEST]])); + } + + #[test] + fn pid_from_path() { + assert_eq!( + pid_of(std::path::Path::new("/tmp/nescapture-cmd-42.sock")), + Some(42) + ); + assert_eq!( + pid_of(std::path::Path::new("/tmp/nescapture-cmd.sock")), + None + ); + } +} diff --git a/apps/neshub/src/congestion.rs b/apps/neshub/src/congestion.rs new file mode 100644 index 00000000..14a5eaa4 --- /dev/null +++ b/apps/neshub/src/congestion.rs @@ -0,0 +1,67 @@ +//! The transport's congestion control, handed to the media's own. +//! +//! The video rate is decided per frame from what the path measurably did +//! (`rate.rs`), and that decision needs each frame to leave as one train: +//! its fragments back to back, so the bottleneck spaces them by its own rate +//! and the client can measure it. A transport controller in between would +//! window and pace them by its own estimate, smearing the very spacing being +//! measured, and second-guess a rate already chosen from better evidence. +//! +//! So this controller holds nothing back. Its window is larger than noq paces +//! at all (it stops pacing above `u32::MAX`), and it ignores loss, which the +//! media's own controller sees sooner, as queueing. +//! +//! Endpoint-wide, because transport settings are per endpoint, not per +//! connection: audio, input and control get it too. They are small flows the +//! video rate leaves room for; nothing they send can build a queue. + +use std::any::Any; +use std::sync::Arc; + +use iroh::endpoint::{Controller, ControllerFactory}; + +/// Past this noq does not pace: see `noq_proto::connection::pacing`. +const UNPACED_WINDOW: u64 = u32::MAX as u64 * 2; + +#[derive(Debug, Clone, Copy, Default)] +pub struct MediaRate; + +impl Controller for MediaRate { + fn on_congestion_event( + &mut self, + _now: std::time::Instant, + _sent: std::time::Instant, + _is_persistent_congestion: bool, + _is_ecn: bool, + _lost_bytes: u64, + _largest_lost_pn: u64, + ) { + } + + fn on_mtu_update(&mut self, _new_mtu: u16) {} + + fn window(&self) -> u64 { + UNPACED_WINDOW + } + + fn clone_box(&self) -> Box { + Box::new(*self) + } + + fn initial_window(&self) -> u64 { + UNPACED_WINDOW + } + + fn into_any(self: Box) -> Box { + self + } +} + +#[derive(Debug, Default)] +pub struct MediaRateFactory; + +impl ControllerFactory for MediaRateFactory { + fn build(self: Arc, _now: std::time::Instant, _current_mtu: u16) -> Box { + Box::new(MediaRate) + } +} diff --git a/apps/neshub/src/control.rs b/apps/neshub/src/control.rs index ad9bd58b..29436589 100644 --- a/apps/neshub/src/control.rs +++ b/apps/neshub/src/control.rs @@ -5,87 +5,46 @@ //! Nothing used to decide. The encoder took a bitrate from an environment //! variable nobody set, so every session offered the same 10 Mbps whatever the //! path between the two ends could carry. On a path sustaining under 3 Mbps that -//! does not degrade the picture, it removes it: most fragments are dropped, -//! every frame made of several fragments arrives incomplete, and the keyframe -- -//! the largest frame there is -- never completes either, so nothing ever -//! recovers. +//! does not degrade the picture, it removes it. //! -//! # Why the receiver decides, and not this end +//! # From the path, frame by frame //! -//! The obvious inputs are here: the hub holds the QUIC connection and can read -//! its round-trip time, its congestion window and its remaining datagram buffer. -//! They were measured, over an entire multi-session run, saying the path was -//! healthy while the client was receiving almost nothing -- 182 ms round trip, -//! *zero* QUIC packet loss, and arrivals falling from 1776 to 239 datagrams a -//! second. +//! Each client's path is modelled from its per-frame feedback (`rate.rs`): the +//! capacity its frames measure, whether the bottleneck is still busy with one +//! frame when the next arrives, and the share it delivers when it is. Down at +//! once when the queue grows, to that measured share; up only to a rate the path +//! has proven, held on the path's own recurrence. See nescore's +//! `docs/superpowers/specs/2026-10-16-video-rate-from-the-path-design.md`. //! -//! Part of that is structural rather than bad luck. `send_datagram` evicts the -//! oldest queued datagrams and returns `Ok`, so a sender that is overrunning the -//! path is told nothing at all; the "frames dropped" counter it feeds cannot be -//! non-zero however badly things are going. So the primary evidence is the -//! receiver's own report of what arrived, and this end's view is the fallback -//! for when no report has come in. +//! This replaced a controller built from thresholds: loss above 10% to back +//! off, below 2% to climb, back off to 0.85 of goodput, climb a twentieth of the +//! ceiling a second, a 10% deadband, queue limits of 250 and 80 ms. It could +//! only find the path's limit by hitting it, so it sawtoothed, and it heard about +//! the queue it built once a second, after the queue was tens of milliseconds +//! deep. //! -//! # Shape +//! # One encoder, the slowest client //! -//! Slow and dull on purpose. This is not a congestion controller in the QUIC -//! sense and does not try to be: it finds the right order of magnitude for a -//! video bitrate and stays there, at one decision a second. The transport -//! underneath is still doing real congestion control on its own packets. +//! One encoder serves every client, so it can answer only one question: the +//! rate the slowest path allows. Averaging would leave that client never +//! recovering while the numbers look acceptable. -use nesprotocol::{ControlMode, ReceiverReport}; +use std::collections::HashMap; -/// Loss above this means the path is being overrun and the target comes down. -const LOSS_DECREASE: f32 = 0.10; -/// Loss below this means there is room to climb. -const LOSS_INCREASE: f32 = 0.02; +use iroh::EndpointId; +use nesprotocol::ControlMode; +use nesprotocol::datagram::FrameFeedback; -/// What to keep of the measured goodput when backing off. -/// -/// Backing off to *below* what actually got through, rather than to a fraction -/// of what we were asking for, is what makes recovery quick. At 10 Mbps into a -/// 3 Mbps path the next target is about 2.5 Mbps rather than 8.5, so one step -/// does what twenty multiplicative decreases would. -const BACKOFF: f32 = 0.85; - -/// How much of the ceiling to add per calm second. Twenty steps from floor to -/// ceiling: slow enough that a brief quiet spell does not undo a back-off. -const CLIMB_FRACTION: u32 = 20; - -/// Don't actuate for less than this fraction of the current target. -/// -/// Retuning is cheap now -- it no longer rebuilds the encoder or forces a -/// keyframe -- but a line in the log every second saying the bitrate moved by -/// 1% is noise that hides the changes that matter. -const DEADBAND: f32 = 0.10; +use crate::rate::{self, PathModel}; /// Never go below this, whatever the ceiling is. /// -/// Under this a 1080p stream is not worth sending and the right answer is fewer -/// pixels rather than fewer bits, which is a decision about the tier and not one -/// this can make. It also has to leave room for audio, which shares the -/// connection and is not counted against the video ceiling. +/// A policy bound like the ceiling, not a guess about the network: under this a +/// 1080p stream is not worth sending, and the right answer is fewer pixels +/// rather than fewer bits, which is a decision about the tier and not one this +/// can make. const ABSOLUTE_FLOOR_KBPS: u32 = 800; -/// Seconds without a receiver report before this end's own view is used instead. -const SILENT_TICKS_BEFORE_FALLBACK: u32 = 3; - -/// Backlog, in milliseconds of video, that means the path is being overrun. -/// -/// Not a tuned number: it is a latency budget. At sixty frames a second a frame -/// is under 17 ms, so a quarter second of backlog is fifteen frames already -/// handed over and not yet gone -- a player is looking at a picture from before -/// their last four keypresses. There is no bitrate worth that, so past this the -/// target comes down whatever the loss says. -const QUEUE_DECREASE_MS: u32 = 250; - -/// Backlog, in milliseconds, under which the path is considered clear. -/// -/// Climbing needs a stronger warrant than holding does, because climbing is -/// what digs the queue. Five frames or so of backlog is the most that can be -/// outstanding and still be called live. -const QUEUE_CLIMB_MS: u32 = 80; - /// The band a target must stay inside. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Limits { @@ -112,11 +71,8 @@ impl Limits { } } -/// What this end can see about the path, for when the far end is not talking. -/// -/// Every field is optional because every one of them can be unavailable: a -/// connection with no established path has no round-trip time, and a congestion -/// window is only meaningful once something has been sent. +/// What this end can see about a client's connection: for the stats, not for +/// the decision. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PathView { /// Congestion window in bytes, as the transport currently believes it. @@ -124,70 +80,54 @@ pub struct PathView { /// Round-trip time in milliseconds. pub rtt_ms: Option, /// Datagram bytes handed to the transport that have not yet left. - /// - /// The backlog is the one thing this end can see that says the path is - /// being overrun *while it is still only late*. Loss says so too, but only - /// afterwards, and on a path that queues rather than drops, "afterwards" - /// can be several seconds. pub backlog_bytes: Option, } impl PathView { /// How long the backlog takes to drain at `drain_kbps`, in milliseconds. - /// - /// Bits divided by kilobits-per-second is milliseconds. The rate to divide - /// by is the one the queue actually drains at -- what is getting through -- - /// and not what we are asking for, which is the number that is too high - /// whenever this matters. pub fn backlog_ms(&self, drain_kbps: u32) -> Option { let backlog = self.backlog_bytes?; let drain = u64::from(drain_kbps.max(1)); Some((backlog.saturating_mul(8) / drain).min(u64::from(u32::MAX)) as u32) } - - /// Delivery rate the window and round trip imply, in kbps. - /// - /// A window is a quantity of bytes in flight for one round trip, so the two - /// together are a rate. Treated as an estimate of last resort: it describes - /// the transport's own opinion of the path, and that opinion is exactly what - /// was observed to be wrong when this mattered most. - pub fn estimate_kbps(&self) -> Option { - let (cwnd, rtt) = (self.cwnd_bytes?, self.rtt_ms?.max(1)); - let bits_per_second = cwnd.saturating_mul(8) * 1000 / u64::from(rtt); - Some((bits_per_second / 1000).min(u64::from(u32::MAX)) as u32) - } } /// Why the target is what it is, for the overlay and the log. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum Reason { - /// Loss was high; backed off to under what was actually getting through. - Congested, - /// Loss was in the middle band; left alone. - Holding, - /// Loss was low; climbing towards the ceiling. - Climbing, - /// No receiver report recently; using this end's view of the path. - Fallback, - /// No report and no usable view either; decaying towards the floor. - Blind, + /// Where the session started; no path has said otherwise yet. + Start, + /// A path's queue was growing; sending below its share to drain it. + Draining, + /// Settled at the share the slowest path delivered while queueing. + Measured, + /// Raised to what the slowest path has proven it carries. + Stepped, + /// No frame came back from the slowest path; held at its last share. + Silent, /// Somebody set the bitrate by hand. Manual, /// The encoder is not under a bitrate at all, so there is nothing to decide. ConstantQuality, /// Nobody is connected, so there is no path to have an opinion about. NoClients, - /// This second's numbers describe frames the hub chose not to send, so they - /// say nothing about the path. - SelfInflicted, - /// Nothing was lost, but the send queue is standing deep enough that the - /// picture is arriving late. Backed off on the queue rather than on loss. - Backlogged, +} + +impl From for Reason { + fn from(r: rate::Reason) -> Self { + match r { + rate::Reason::Start => Self::Start, + rate::Reason::Draining => Self::Draining, + rate::Reason::Measured => Self::Measured, + rate::Reason::Stepped => Self::Stepped, + rate::Reason::Silent => Self::Silent, + } + } } /// The controller's whole state. -#[derive(Debug, Clone, Copy)] +#[derive(Debug)] pub struct Controller { /// The ceiling the box was given, which a client may lower but never raise. /// @@ -196,13 +136,6 @@ pub struct Controller { /// it again, because the only number left to compare against is the one it /// just lowered. box_ceiling_kbps: u32, - /// What the controller currently believes the bitrate should be. - /// - /// Kept apart from `sent_kbps` deliberately. Folding the two together looks - /// simpler and stalls the climb: the step up is a fraction of the *ceiling*, - /// so once the target passes half of it each step is under the deadband, and - /// a target that only moved when it actuated could never accumulate past - /// that point. Belief moves every second; only saying so is rationed. target_kbps: u32, /// What the encoder was last told, or `None` before anything was sent. sent_kbps: Option, @@ -210,19 +143,18 @@ pub struct Controller { mode: ControlMode, /// Set when the encoder is in constant-QP; there is no bitrate to control. constant_quality: bool, - silent_ticks: u32, reason: Reason, - /// Last measured send-queue depth, kept for reporting rather than control. + /// Last measured send-queue depth, for reporting. backlog_ms: u32, + /// Each streaming client's path. + paths: HashMap, } impl Controller { /// Start at the ceiling. /// /// Optimistic on purpose: the common case is a path that can carry the tier - /// that was sold, and a session that opened at its floor and climbed would - /// take twenty seconds to look like what was paid for. The first bad report - /// undoes it in one step. + /// that was sold, and a path that cannot says so within frames. pub fn new(limits: Limits) -> Self { Self { box_ceiling_kbps: limits.ceiling_kbps, @@ -231,9 +163,9 @@ impl Controller { limits, mode: ControlMode::Auto, constant_quality: false, - silent_ticks: 0, - reason: Reason::Holding, + reason: Reason::NoClients, backlog_ms: 0, + paths: HashMap::new(), } } @@ -250,21 +182,31 @@ impl Controller { self.reason } - /// How far behind the send queue was at the last decision, in milliseconds. + /// How far behind the send queue was at the last tick, in milliseconds. pub fn backlog_ms(&self) -> u32 { self.backlog_ms } + /// The slowest path's measured capacity, in kbps; 0 before one is measured. + pub fn capacity_kbps(&self) -> u32 { + self.slowest() + .and_then(PathModel::capacity_kbps) + .unwrap_or(0) + } + + /// The slowest path's last delivered share, in kbps; 0 before it queued. + pub fn delivery_kbps(&self) -> u32 { + self.slowest() + .and_then(PathModel::delivery_kbps) + .unwrap_or(0) + } + /// The ceiling the box was given, whatever a client has since asked for. pub fn box_ceiling_kbps(&self) -> u32 { self.box_ceiling_kbps } /// Adopt a ceiling a client asked for, never above the one the box was given. - /// - /// A client may lower its own ceiling -- to test a path, or because it knows - /// something about its link that this end does not -- but it may not raise - /// the one the tier bought. pub fn set_ceiling(&mut self, ceiling_kbps: u32) { self.limits = Limits::new(ceiling_kbps.min(self.box_ceiling_kbps).max(1)); self.target_kbps = self.limits.clamp(self.target_kbps); @@ -293,39 +235,45 @@ impl Controller { self.constant_quality = constant_quality; } - /// One second's decision. - /// - /// `report` is the worst report across the clients attached, or `None` when - /// none of them said anything. Returns the new target when it is worth - /// sending, and `None` when nothing should be sent -- which is most seconds. - /// One second's decision. - /// - /// `self_inflicted` says this second contained frames the hub deliberately - /// withheld -- a client resynchronising, whose deltas were undecodable and - /// were dropped rather than sent. Those seconds cannot be read as evidence - /// about the path: fewer frames were sent, so fewer arrived, so the measured - /// goodput is low and the loss is high, and a controller anchoring a backoff - /// on that would cut the bitrate in response to its own decision. Every - /// stutter would then also cost bandwidth, which is the opposite of what a - /// resynchronisation needs. - pub fn tick( + /// One frame's feedback from one client. Returns a new target when the + /// encoder should hear one. + pub fn on_feedback( &mut self, - clients: usize, - report: Option, - path: PathView, - self_inflicted: bool, + client: EndpointId, + frame: &FrameFeedback, + now_us: u64, ) -> Option { - if clients == 0 { + let start = self.target_kbps; + self.paths + .entry(client) + .or_insert_with(|| PathModel::new(start, now_us)) + .observe(frame, now_us); + self.decide() + } + + /// Once a second, with the clients that are streaming and this end's view + /// of their connections. Returns a new target when the encoder should hear + /// one. + pub fn tick(&mut self, streaming: &[EndpointId], path: PathView) -> Option { + self.paths.retain(|id, _| streaming.contains(id)); + for model in self.paths.values_mut() { + model.tick(); + } + self.backlog_ms = path.backlog_ms(self.target_kbps).unwrap_or(0); + if streaming.is_empty() { // Nothing is connected, so nothing is being carried and there is no - // path to form an opinion about. Deciding here means deciding on the - // absence of evidence: the controller used to read it as silence and - // decay to the floor, so a box waiting for its first client spent - // that time winding itself down and then jumped back up the moment - // somebody arrived. + // path to form an opinion about. self.reason = Reason::NoClients; - self.silent_ticks = 0; return None; } + self.decide() + } + + fn slowest(&self) -> Option<&PathModel> { + self.paths.values().min_by_key(|m| m.allowed_kbps()) + } + + fn decide(&mut self) -> Option { if self.constant_quality { self.reason = Reason::ConstantQuality; return None; @@ -334,124 +282,44 @@ impl Controller { self.reason = Reason::Manual; return None; } - if self_inflicted { - // Held, not decayed. The path may be fine; this second simply - // cannot say, and silence about the path is not evidence against it. - self.reason = Reason::SelfInflicted; - self.silent_ticks = 0; + let (allowed, reason) = match self.slowest() { + Some(m) => (m.allowed_kbps(), m.reason().into()), + None if self.sent_kbps.is_none() => (self.target_kbps, Reason::Start), + None => return None, + }; + self.reason = reason; + self.target_kbps = self.limits.clamp(allowed); + // Every change is said: they are events -- a queue found, a share + // measured, a step proven -- not a level wandering by a percent. The + // first decision is always said, because the encoder started at + // whatever its environment gave it. + if self.sent_kbps == Some(self.target_kbps) { return None; } - - // What the queue drains at is what is getting through. With no report - // to say, the target is the best guess available -- and a target that - // is too high only makes the backlog look shorter than it is, so this - // errs towards patience rather than towards cutting the rate. - let drain_kbps = match report.as_ref().map(|r| (r.goodput_bps / 1000) as u32) { - Some(measured) if measured > 0 => measured, - _ => self.target_kbps, - }; - self.backlog_ms = path.backlog_ms(drain_kbps).unwrap_or(0); - - let next = match report.as_ref().and_then(|r| r.loss().map(|l| (r, l))) { - Some((report, loss)) => { - self.silent_ticks = 0; - self.decide_from_report(report, loss, path) - } - None => { - self.silent_ticks = self.silent_ticks.saturating_add(1); - if self.silent_ticks < SILENT_TICKS_BEFORE_FALLBACK { - self.reason = Reason::Holding; - return None; - } - self.decide_from_path(path) - } - }; - - self.target_kbps = self.limits.clamp(next); - // Every second moves the belief; only a change worth acting on is said. - // The first decision is always said -- the encoder started at whatever - // its environment gave it, and this end has no way to know it matches. - match self.sent_kbps { - Some(sent) if !worth_sending(sent, self.target_kbps, self.limits) => None, - _ => { - self.sent_kbps = Some(self.target_kbps); - Some(self.target_kbps) - } - } - } - - fn decide_from_report(&mut self, report: &ReceiverReport, loss: f32, path: PathView) -> u32 { - let measured = (report.goodput_bps / 1000) as u32; - // `backlog_ms` is zero both when the queue is empty and when the - // transport cannot say, which are the same thing for this purpose: a - // queue nobody can see is not evidence of one. - let known = path.backlog_bytes.is_some(); - let queued = known && self.backlog_ms > QUEUE_DECREASE_MS; - - if loss > LOSS_DECREASE || queued { - self.reason = if queued { - Reason::Backlogged - } else { - Reason::Congested - }; - // Anchored on what actually arrived, not on what we were asking for. - // `goodput` of zero means nothing completed at all, in which case - // there is no measurement to anchor to and the target is simply - // halved -- the alternative, backing off to zero, would take the - // stream below the floor on a single bad second. - let anchor = if measured == 0 { - self.target_kbps / 2 - } else { - measured.min(self.target_kbps) - }; - return (anchor as f32 * BACKOFF) as u32; - } - // Climbing is what digs the queue, so it needs the queue to be empty as - // well as the loss to be low. Without this the controller climbs all - // the way to the ceiling against a path it is already overrunning, - // because a path that queues instead of dropping reports no loss at all - // until the buffer finally overflows -- and by then the picture is - // seconds behind. - if loss < LOSS_INCREASE && (!known || self.backlog_ms < QUEUE_CLIMB_MS) { - self.reason = Reason::Climbing; - return self - .target_kbps - .saturating_add(self.limits.ceiling_kbps / CLIMB_FRACTION); - } - self.reason = Reason::Holding; - self.target_kbps - } - - fn decide_from_path(&mut self, path: PathView) -> u32 { - match path.estimate_kbps() { - Some(estimate) => { - self.reason = Reason::Fallback; - estimate - } - None => { - // Nothing said and nothing visible. Holding a high target on no - // evidence is how the original failure sustained itself, so this - // decays rather than holds. - self.reason = Reason::Blind; - (self.target_kbps as f32 * BACKOFF) as u32 - } - } + self.sent_kbps = Some(self.target_kbps); + Some(self.target_kbps) } } -/// Whether a change is big enough to be worth actuating. -/// -/// Reaching either end of the band always is: a target pinned to the floor or -/// the ceiling is a fact worth stating even when the step to it was small. -pub fn worth_sending(current: u32, next: u32, limits: Limits) -> bool { - if current == next { - return false; +/// Tell the encoder a target the controller has just decided. +pub fn actuate( + cmd_tx: &tokio::sync::mpsc::UnboundedSender>, + controller: &Controller, + kbps: u32, +) { + let mut cmd = vec![nesprotocol::MSG_ENCODE_SETTINGS]; + nesprotocol::encode_bitrate_only(&mut cmd, kbps); + if cmd_tx.send(cmd).is_err() { + tracing::warn!("encoder command channel closed"); + return; } - if next == limits.floor_kbps() || next == limits.ceiling_kbps { - return true; - } - let change = current.abs_diff(next) as f32; - change >= current as f32 * DEADBAND + // Debug, not info: each is an event, but a path that keeps changing can + // make several a second, which at info buries the lines that matter. + tracing::debug!( + "video target {kbps}/{} kbps: {:?}", + controller.limits().ceiling_kbps, + controller.reason(), + ); } #[cfg(test)] @@ -460,455 +328,130 @@ mod tests { const CEILING: u32 = 10_000; - fn limits() -> Limits { - Limits::new(CEILING) - } - fn controller() -> Controller { - Controller::new(limits()) + Controller::new(Limits::new(CEILING)) } - /// A second in which `released` of `total` frames made it, carrying - /// `goodput_kbps` of completed video. - fn report(released: u32, incomplete: u32, goodput_kbps: u64) -> ReceiverReport { - ReceiverReport { - goodput_bps: goodput_kbps * 1000, - released, - incomplete, - never_arrived: 0, - rtt_ms: 180, + fn client(n: u8) -> EndpointId { + iroh::SecretKey::from_bytes(&[n; 32]).public() + } + + /// A frame of `bytes` sent at `t_us`, arriving over a link of `kbps` + /// that `busy` says was still carrying the previous frame. + fn frame(seq: u16, t_us: u64, bytes: u32, kbps: u32, queue_us: u32) -> FrameFeedback { + let spread = (u64::from(bytes - 1_200) * 8_000 / u64::from(kbps)) as u32; + FrameFeedback { + seq, + bytes, + fragments: bytes.div_ceil(1_200) as u16, + first_bytes: 1_200, + sent_us: t_us, + first_arrival_us: t_us + 40_000 + u64::from(queue_us), + spread_us: spread, + first_q_us: queue_us, } } - fn healthy() -> ReceiverReport { - report(60, 0, 9_800) - } - - /// A path with `backlog` bytes handed over and not yet gone. - fn backlogged(backlog: u64) -> PathView { - PathView { - cwnd_bytes: Some(13_000), - rtt_ms: Some(180), - backlog_bytes: Some(backlog), - } - } - - /// The second reported failure, as measured over a 1000-mile link. - /// - /// Every frame arrived and every frame completed -- zero loss, zero QUIC - /// congestion events, a flat 180 ms round trip for the whole session -- and - /// the picture was still several seconds behind, because the offer was - /// several times what the path carried and the difference was sitting in - /// the hub's own send buffer. A controller reading loss alone sees a - /// perfect path here and climbs to the ceiling against it. - #[test] - fn a_path_that_queues_instead_of_dropping_must_not_read_as_healthy() { - let mut c = controller(); - // Two megabits get through; a megabyte is already queued behind them. - let arriving = report(60, 0, 2_000); - let path = backlogged(1024 * 1024); - - for _ in 0..30 { - c.tick(1, Some(arriving), path, false); - } - - assert_ne!( - c.reason(), - Reason::Climbing, - "climbing against a path with four seconds of backlog" - ); - assert!( - c.target_kbps() < 2_000, - "target {} kbps is at or above what is getting through, so the \ - queue can only grow", - c.target_kbps() - ); - } - - /// The sawtooth: floor, climb to ceiling, collapse, repeat every 20 s. - /// - /// Loss alone cannot break this cycle, because on a queueing path loss only - /// appears once the buffer finally overflows -- long after the latency has - /// made the session unplayable, and by then the queue is deep enough that - /// backing off to the floor is the only way out. - #[test] - fn the_target_settles_instead_of_sawtoothing() { - let mut c = controller(); - // A steady 2 Mbps path. The backlog is what the last second of - // over-sending left behind, drained at what actually gets through. - let mut backlog: i64 = 0; - let mut seen = Vec::new(); - - for _ in 0..60 { - let target = c.target_kbps(); - // Whatever was asked for above 2 Mbps piles up; the rest drains. - backlog = (backlog + (i64::from(target) - 2_000) * 1000 / 8).clamp(0, 4 * 1024 * 1024); - c.tick( - 1, - Some(report(60, 0, 2_000)), - backlogged(backlog as u64), - false, - ); - seen.push(c.target_kbps()); - } - - let settled = &seen[30..]; - let (lo, hi) = ( - *settled.iter().min().unwrap(), - *settled.iter().max().unwrap(), - ); - assert!( - hi - lo <= 1_000, - "target still swinging between {lo} and {hi} kbps after 30 seconds" - ); - assert!( - hi <= 2_400, - "settled at {hi} kbps against a path carrying 2000" - ); - } - - /// The reported depth is what the next session will be judged on, so it - /// has to be right: bytes over the rate they leave at, in milliseconds. - #[test] - fn the_queue_depth_is_reported_as_measured() { - let mut c = controller(); - // 250 kB queued behind a 2 Mbps drain is exactly one second. - c.tick(1, Some(report(60, 0, 2_000)), backlogged(250_000), false); - assert_eq!(c.backlog_ms(), 1_000); - - // A transport that cannot say must report no queue, not a wrong one. - c.tick(1, Some(report(60, 0, 2_000)), PathView::default(), false); - assert_eq!(c.backlog_ms(), 0); - } - - /// The backlog must not become a reason never to climb again. - #[test] - fn a_clear_queue_still_climbs() { - let mut c = controller(); - c.set_ceiling(4_000); - let clear = backlogged(0); - for _ in 0..5 { - c.tick(1, Some(report(60, 0, 3_900)), clear, false); - } - assert_eq!(c.reason(), Reason::Climbing); - } - - /// The reported failure, as measured: a 10 Mbps offer into a path carrying - /// under three, where *no* frame completed for minutes. - #[test] - fn the_reported_failure_is_corrected_in_seconds() { - let mut c = controller(); - assert_eq!(c.target_kbps(), CEILING); - - let mut ticks = 0; - while c.target_kbps() > 2_800 && ticks < 10 { - // 46 frames a second starting to arrive, none of them completing. - c.tick(1, Some(report(0, 46, 0)), PathView::default(), false); - ticks += 1; - } - assert!( - c.target_kbps() <= 2_800, - "still at {} kbps after {ticks} seconds", - c.target_kbps(), - ); - assert!( - ticks <= 5, - "took {ticks} seconds to stop overrunning the path" - ); - } - - /// With a goodput measurement to anchor on it should take one step, not - /// several -- that is the whole reason for backing off to what arrived - /// rather than to a fraction of what was asked for. - #[test] - fn a_measured_goodput_is_corrected_in_one_step() { - let mut c = controller(); - let sent = c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false); - assert_eq!(sent, Some(c.target_kbps())); - assert!( - c.target_kbps() < 2_850, - "backed off to {} kbps, which is above what actually arrived", - c.target_kbps(), - ); - } - - /// The negative that matters most: a path that is fine must be left alone. - #[test] - fn a_healthy_path_is_not_wandered_away_from() { - let mut c = controller(); - // The first decision is always stated. The encoder started at whatever - // its environment gave it and this end cannot know that matches, so the - // target is asserted once rather than assumed. - assert_eq!( - c.tick(1, Some(healthy()), PathView::default(), false), - Some(CEILING) - ); - for _ in 0..60 { - assert_eq!( - c.tick(1, Some(healthy()), PathView::default(), false), - None, - "a healthy path produced a bitrate change", - ); - } - assert_eq!(c.target_kbps(), CEILING); - } - #[test] fn the_first_decision_is_always_stated() { - // Otherwise a session whose path happens to match its ceiling never - // tells the encoder anything, and the encoder keeps whatever its - // environment gave it -- which is the failure this replaces. let mut c = controller(); - assert_eq!( - c.tick(1, Some(healthy()), PathView::default(), false), - Some(CEILING), - "the opening target was never stated", - ); - // And not repeated, now that the encoder has been told. - assert_eq!(c.tick(1, Some(healthy()), PathView::default(), false), None); + assert_eq!(c.tick(&[client(1)], PathView::default()), Some(CEILING)); + assert_eq!(c.tick(&[client(1)], PathView::default()), None); } #[test] - fn a_small_change_is_not_worth_saying() { - // Below the deadband nothing is sent, so the log and the encoder are not - // touched sixty times a minute for changes nobody could see. - let l = limits(); - assert!(!worth_sending(5_000, 5_200, l)); - assert!(worth_sending(5_000, 5_600, l)); - } - - #[test] - fn reaching_either_end_of_the_band_is_always_worth_saying() { - let l = limits(); - assert!(worth_sending(l.floor_kbps() + 1, l.floor_kbps(), l)); - assert!(worth_sending(CEILING - 1, CEILING, l)); - } - - #[test] - fn the_ceiling_is_never_exceeded() { + fn the_slowest_client_decides() { let mut c = controller(); - for _ in 0..200 { - c.tick(1, Some(healthy()), PathView::default(), false); - assert!(c.target_kbps() <= CEILING, "{} kbps", c.target_kbps()); - } - } - - #[test] - fn the_floor_is_never_gone_below() { - let mut c = controller(); - for _ in 0..200 { - c.tick(1, Some(report(0, 60, 0)), PathView::default(), false); - assert!( - c.target_kbps() >= c.limits().floor_kbps(), - "{} kbps is below the floor", - c.target_kbps(), - ); - } - } - - #[test] - fn it_climbs_back_after_a_bad_patch() { - let mut c = controller(); - for _ in 0..5 { - c.tick(1, Some(report(0, 46, 0)), PathView::default(), false); - } - let bottom = c.target_kbps(); - for _ in 0..40 { - c.tick(1, Some(healthy()), PathView::default(), false); + let (fast, slow) = (client(1), client(2)); + let mut t = 0; + let mut q = 0; + for seq in 0..120u16 { + // The fast client's frames drain at once; the slow one's queue + // grows by 6 ms a frame, a train arriving straight behind the last. + c.on_feedback(fast, &frame(seq, t, 20_000, 40_000, 0), t); + let mut f = frame(seq, t, 20_000, 6_000, q); + if seq > 0 { + f.first_arrival_us = t + 40_000 + u64::from(q); + } + c.on_feedback(slow, &f, t); + q += 6_000; + t += 16_667; } assert!( - c.target_kbps() > bottom, - "never recovered from {bottom} kbps" + c.target_kbps() < CEILING, + "the slow client did not bring the rate down" ); - assert_eq!(c.target_kbps(), CEILING, "did not climb all the way back"); } #[test] - fn a_second_the_hub_starved_is_not_read_as_a_bad_path() { - // While a client resynchronises its deltas are withheld, so fewer frames - // are sent, fewer arrive, goodput reads low and loss reads high. Backing - // off on that would cut the bitrate in response to the hub's own - // decision -- and make every stutter cost bandwidth as well. + fn the_ceiling_is_never_exceeded_and_the_floor_never_gone_below() { let mut c = controller(); - assert_eq!( - c.tick(1, Some(healthy()), PathView::default(), false), - Some(CEILING) - ); - - let starved = report(0, 46, 0); - for _ in 0..10 { - assert_eq!(c.tick(1, Some(starved), PathView::default(), true), None); - } - assert_eq!(c.target_kbps(), CEILING, "the hub cut its own bitrate"); - assert_eq!(c.reason(), Reason::SelfInflicted); - } - - #[test] - fn a_genuinely_bad_second_still_acts_once_the_hub_stops_starving_it() { - // The flag holds, it does not blind. The moment a second is the path's - // own, the same evidence is acted on. - let mut c = controller(); - c.tick(1, Some(healthy()), PathView::default(), false); - c.tick(1, Some(report(0, 46, 0)), PathView::default(), true); - assert_eq!(c.target_kbps(), CEILING); - - c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false); - assert!(c.target_kbps() < 2_850, "a real bad second was ignored too"); - } - - #[test] - fn a_starved_second_does_not_count_towards_silence() { - // It is not a missing report -- one arrived, it just cannot be read. - // Counting it as silence would slide towards the path-based fallback and - // then to decaying blind, which is a different wrong answer. - let mut c = controller(); - c.tick(1, Some(healthy()), PathView::default(), false); - for _ in 0..10 { - c.tick(1, Some(report(0, 60, 0)), PathView::default(), true); - } - assert_eq!(c.reason(), Reason::SelfInflicted); - assert_eq!(c.target_kbps(), CEILING); + c.tick(&[client(1)], PathView::default()); + assert!(c.target_kbps() <= CEILING); + c.set_ceiling(500); + assert!(c.target_kbps() <= 500); + assert!(c.target_kbps() >= c.limits().floor_kbps()); } #[test] fn a_hand_set_bitrate_stands_the_controller_down() { - // How this class of bug gets diagnosed. A controller that overrode a - // person's setting a second later would take the tool away. let mut c = controller(); - c.note_manual_target(1_000); - for _ in 0..30 { - assert_eq!( - c.tick(1, Some(report(0, 60, 0)), PathView::default(), false), - None - ); - } - assert_eq!(c.target_kbps(), 1_000); + c.note_manual_target(3_000); + assert_eq!(c.tick(&[client(1)], PathView::default()), None); assert_eq!(c.reason(), Reason::Manual); + assert_eq!(c.target_kbps(), 3_000); } #[test] fn constant_quality_has_no_bitrate_to_decide() { let mut c = controller(); c.set_constant_quality(true); - assert_eq!( - c.tick(1, Some(report(0, 60, 0)), PathView::default(), false), - None - ); + assert_eq!(c.tick(&[client(1)], PathView::default()), None); assert_eq!(c.reason(), Reason::ConstantQuality); } - #[test] - fn silence_is_tolerated_briefly_and_then_acted_on() { - let mut c = controller(); - // A missed report or two says nothing; the path was fine a second ago. - for _ in 0..(SILENT_TICKS_BEFORE_FALLBACK - 1) { - assert_eq!(c.tick(1, None, PathView::default(), false), None); - assert_eq!(c.target_kbps(), CEILING); - } - // Past that, with nothing visible from this end either, it decays rather - // than holding a high target on no evidence at all -- holding is how the - // original failure sustained itself. - c.tick(1, None, PathView::default(), false); - assert_eq!(c.reason(), Reason::Blind); - assert!(c.target_kbps() < CEILING); - } - - #[test] - fn this_ends_view_is_used_only_when_the_far_end_is_silent() { - let mut c = controller(); - let path = PathView { - cwnd_bytes: Some(120_000), - rtt_ms: Some(200), - backlog_bytes: None, - }; - // A report present means the path view is ignored, however tempting. - c.tick(1, Some(healthy()), path, false); - assert_eq!(c.reason(), Reason::Climbing); - - for _ in 0..SILENT_TICKS_BEFORE_FALLBACK { - c.tick(1, None, path, false); - } - assert_eq!(c.reason(), Reason::Fallback); - // 120 KB in flight per 200 ms is 4.8 Mbps. - assert_eq!(c.target_kbps(), 4_800); - } - #[test] fn nothing_is_decided_while_nobody_is_connected() { - // A box waiting for its first client used to read the silence as a dead - // path and wind itself down to the floor, then jump back up the moment - // somebody arrived. There is no path to have an opinion about yet. let mut c = controller(); - for _ in 0..30 { - assert_eq!(c.tick(0, None, PathView::default(), false), None); - } - assert_eq!(c.target_kbps(), CEILING); + assert_eq!(c.tick(&[], PathView::default()), None); assert_eq!(c.reason(), Reason::NoClients); } #[test] - fn a_lowered_ceiling_can_be_raised_again() { - // The trap in folding the box's ceiling together with the one in force: - // after lowering, the only number left to compare against is the lowered - // one, so the client can never get back up. + fn a_client_that_leaves_takes_its_path_with_it() { let mut c = controller(); - c.set_ceiling(2_000); - assert_eq!(c.limits().ceiling_kbps, 2_000); - c.set_ceiling(5_000); - assert_eq!(c.limits().ceiling_kbps, 5_000); - assert_eq!(c.box_ceiling_kbps(), CEILING); + c.on_feedback(client(1), &frame(0, 0, 20_000, 6_000, 0), 0); + c.tick(&[], PathView::default()); + assert!(c.paths.is_empty()); } #[test] - fn a_ceiling_change_is_restated_to_the_encoder() { - // The encoder is the thing that has to act on it, and a target that - // happens to land on the same number is still a different instruction - // when the band around it moved. + fn a_lowered_ceiling_can_be_raised_again() { let mut c = controller(); - assert!( - c.tick(1, Some(healthy()), PathView::default(), false) - .is_some() - ); - c.set_ceiling(3_000); - assert_eq!( - c.tick(1, Some(healthy()), PathView::default(), false), - Some(3_000) - ); + c.set_ceiling(4_000); + assert_eq!(c.limits().ceiling_kbps, 4_000); + c.set_ceiling(9_000); + assert_eq!(c.limits().ceiling_kbps, 9_000); } #[test] fn a_client_may_lower_its_ceiling_but_not_raise_it() { let mut c = controller(); - c.set_ceiling(4_000); - assert_eq!(c.limits().ceiling_kbps, 4_000); - assert!(c.target_kbps() <= 4_000, "the target outlived its ceiling"); + c.set_ceiling(CEILING * 2); + assert_eq!(c.limits().ceiling_kbps, CEILING); + } - c.set_ceiling(50_000); - assert_eq!( - c.limits().ceiling_kbps, - CEILING, - "a client raised the ceiling the tier bought", - ); + #[test] + fn a_ceiling_change_is_restated_to_the_encoder() { + let mut c = controller(); + c.tick(&[client(1)], PathView::default()); + c.set_ceiling(CEILING); + assert_eq!(c.tick(&[client(1)], PathView::default()), Some(CEILING)); } #[test] fn a_ceiling_under_the_floor_does_not_invert_the_band() { - // A misconfigured tier must not produce a floor above its own ceiling, - // which would put every target above the limit it exists to enforce. - let l = Limits::new(500); - assert!(l.floor_kbps() <= l.ceiling_kbps); - assert_eq!(l.clamp(10_000), 500); - } - - #[test] - fn a_silent_second_is_not_read_as_a_healthy_one() { - // No frames at all accounted for: nothing sent, or nothing arrived. - // Reading it as no loss would climb straight into a path that may be - // carrying nothing. - let mut c = controller(); - let empty = ReceiverReport::default(); - assert_eq!(empty.loss(), None); - c.tick(1, Some(empty), PathView::default(), false); - assert_eq!(c.target_kbps(), CEILING, "climbed on an empty report"); + let limits = Limits::new(500); + assert!(limits.floor_kbps() <= limits.ceiling_kbps); } } diff --git a/apps/neshub/src/dgram.rs b/apps/neshub/src/dgram.rs index 3743210f..02bb0f9b 100644 --- a/apps/neshub/src/dgram.rs +++ b/apps/neshub/src/dgram.rs @@ -14,10 +14,13 @@ use tracing::{debug, warn}; use crate::keyframe::KeyframeSender; use crate::pyro::PyroSender; -use nesprotocol::MSG_DATA; use nesprotocol::datagram::{ - DGRAM_BUFFER_BYTES, DGRAM_HDR_LEN, MAX_FRAGMENTS_PER_FRAME, MIN_DGRAM_PAYLOAD, fragment_count, - write_datagram_header, + DGRAM_BUFFER_BYTES, DGRAM_HDR_LEN, DGRAM_VIDEO, DatagramHeader, MAX_FRAGMENTS_PER_FRAME, + MIN_DGRAM_PAYLOAD, fragment_count, write_datagram_header, +}; +use nesprotocol::window::{ + DGRAM_WINDOW_HDR_LEN, WINDOW_DEFAULT, WindowId, max_fragment_payload, + write_video_datagram_header, }; use nesprotocol::pyrowave::PyroFrame; use nesprotocol::reliable::video_wants_reliable; @@ -30,19 +33,29 @@ use nesprotocol::reliable::video_wants_reliable; #[derive(Debug, Clone)] pub enum MediaItem { /// A hardware-codec frame or an audio packet, laid out as the hub - /// broadcasts it. + /// broadcasts it. Video sent this way is window `0`, in the encoding every + /// client has always read. Coded(Bytes), + /// A hardware-codec frame of another window, ref(d-0085). + Windowed(WindowId, Bytes), + /// The connection now shows another window: number from zero again and + /// forget any wait for a keyframe, since none of it describes the new + /// window. Sent through the same queue as the frames so it lands between + /// the last frame of the old window and the first of the new. + Resubscribe, /// A whole PyroWave frame, which travels by rules of its own. PyroWave(Arc), } /// Transport settings for an endpoint carrying media datagrams. /// -/// See [`DGRAM_BUFFER_BYTES`] for why the defaults are not enough. +/// See [`DGRAM_BUFFER_BYTES`] for why the defaults are not enough, and +/// `congestion.rs` for why the transport does not decide the rate. pub fn media_transport_config() -> QuicTransportConfig { QuicTransportConfig::builder() .datagram_send_buffer_size(DGRAM_BUFFER_BYTES) .datagram_receive_buffer_size(Some(DGRAM_BUFFER_BYTES)) + .congestion_controller_factory(std::sync::Arc::new(crate::congestion::MediaRateFactory)) .build() } @@ -99,7 +112,12 @@ impl DatagramSender { /// Fragment one frame body and send the pieces. /// /// `body` is `[1B type][2B seq][payload]` — the frame layout the stream path - /// used, minus the length prefix a datagram does not need. + /// used, minus the length prefix a datagram does not need — or, for a + /// window other than `0`, the windowed body. + /// + /// `window` is the video window the datagrams belong to: its header is two + /// bytes longer for any window but `0`, so the fragment size follows it. + /// Always `0` for audio. /// /// A failure is reported but never retried. Retrying a real-time frame means /// delivering it late, which is the behaviour this whole change removes. The @@ -107,7 +125,7 @@ impl DatagramSender { /// hold of the connection lock, so a path that shrinks mid-frame rejects the /// frame rather than leaving half of it on the wire for the receiver to time /// out. - pub fn send_frame(&self, seq: u16, body: &[u8]) -> Result<(), SendFrameError> { + pub fn send_frame(&self, seq: u16, window: WindowId, body: &[u8]) -> Result<(), SendFrameError> { // Ask QUIC what fits right now rather than assuming. The estimate moves // over a connection's life as path MTU discovery runs, and it can shrink // when a path changes underneath us — which for iroh includes migrating @@ -119,7 +137,13 @@ impl DatagramSender { .max_datagram_size() .ok_or(SendFrameError::Unsupported)?; - let payload_size = max.saturating_sub(DGRAM_HDR_LEN); + let windowed = self.kind == DGRAM_VIDEO && window != WINDOW_DEFAULT; + let header_len = if windowed { DGRAM_WINDOW_HDR_LEN } else { DGRAM_HDR_LEN }; + let payload_size = if self.kind == DGRAM_VIDEO { + max_fragment_payload(window, max) + } else { + max.saturating_sub(DGRAM_HDR_LEN) + }; if payload_size < MIN_DGRAM_PAYLOAD { return Err(SendFrameError::PathTooSmall { payload: payload_size, @@ -134,13 +158,27 @@ impl DatagramSender { // Lay every fragment down in one allocation, then hand out slices of it. // `BytesMut::split_to` gives each datagram an owned `Bytes` that shares // this buffer, so a 300-fragment keyframe costs one allocation, not 300. - let mut buf = BytesMut::with_capacity(total * DGRAM_HDR_LEN + body.len()); - let mut header = [0u8; DGRAM_HDR_LEN]; + let mut buf = BytesMut::with_capacity(total * header_len + body.len()); + let mut header = [0u8; DGRAM_WINDOW_HDR_LEN]; + // One reading for the whole frame: every fragment is handed over in the + // single call below, so they all leave this end at the same moment. + let sent_us = nesprotocol::clock::monotonic_us(); for index in 0..total { let start = index * payload_size; let end = (start + payload_size).min(body.len()); - write_datagram_header(&mut header, self.kind, seq, index as u16, total as u16); - buf.extend_from_slice(&header); + let h = DatagramHeader { + kind: self.kind, + seq, + index: index as u16, + total: total as u16, + sent_us, + }; + if windowed { + write_video_datagram_header(&mut header, &h, window); + } else { + write_datagram_header(&mut header, &h); + } + buf.extend_from_slice(&header[..header_len]); buf.extend_from_slice(&body[start..end]); } @@ -148,7 +186,7 @@ impl DatagramSender { .map(|index| { let start = index * payload_size; let end = (start + payload_size).min(body.len()); - buf.split_to(DGRAM_HDR_LEN + (end - start)).freeze() + buf.split_to(header_len + (end - start)).freeze() }) .collect(); @@ -274,8 +312,21 @@ pub async fn run_datagram_writer( while let Some(item) = rx.recv().await { let t0 = std::time::Instant::now(); - let payload = match item { - MediaItem::Coded(payload) => payload, + let (window, payload) = match item { + MediaItem::Coded(payload) => (WINDOW_DEFAULT, payload), + MediaItem::Windowed(window, payload) => (window, payload), + MediaItem::Resubscribe => { + seq = 0; + resync = ResyncGate::default(); + // Set here, in queue order, not only by whoever queued this: a + // keyframe from the old picture still queued ahead of it would + // otherwise clear the flag, and the new picture's first delta + // would go out as the first frame after the switch. + if let Some(ref awaiting) = awaiting_keyframe { + awaiting.store(true, Ordering::Relaxed); + } + continue; + } // No keyframe stream and no resync gate: every PyroWave frame // stands alone, so there is nothing to resynchronise and no frame // worth a reliable stream more than another. @@ -292,7 +343,9 @@ pub async fn run_datagram_writer( } }; body.clear(); - nesprotocol::encode_frame_body(&mut body, MSG_DATA, seq, &payload); + // Window 0 is exactly the `MSG_DATA` body every client reads; audio + // is always window 0. + nesprotocol::window::encode_video_frame_body(&mut body, window, seq, &payload); // A keyframe goes on a stream of its own when one will take it. The // relay timing below is not recorded for it: the write is asynchronous @@ -338,7 +391,7 @@ pub async fn run_datagram_writer( if let Some(ref keyframes) = keyframes && video_wants_reliable(&payload) { - if keyframes.send(seq, &body) { + if keyframes.send(seq, window, &body) { seq = seq.wrapping_add(1); continue; } @@ -352,7 +405,7 @@ pub async fn run_datagram_writer( ); } - match sender.send_frame(seq, &body) { + match sender.send_frame(seq, window, &body) { Ok(()) => { if let Some(ref relay) = relay_ms { let elapsed = t0.elapsed().as_secs_f32() * 1000.0; diff --git a/apps/neshub/src/e2e_tests.rs b/apps/neshub/src/e2e_tests.rs index d474c0a4..3feb6d43 100644 --- a/apps/neshub/src/e2e_tests.rs +++ b/apps/neshub/src/e2e_tests.rs @@ -42,7 +42,7 @@ pub(crate) fn synthetic_frame(id: u32, bytes: usize, packet: usize) -> PyroFrame PyroFrame { frame: id, meta: FrameMeta { - ts_ms: id * 16, + captured_us: u64::from(id) * 16_000, width: 1920, height: 1080, }, @@ -85,9 +85,12 @@ async fn rig_with(client_buffer: Option) -> Rig { ))); // Commands go nowhere here: no client in these tests states codecs. let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let (encoder_tx, _encoder_rx) = tokio::sync::mpsc::unbounded_channel(); async move { let _cmd_rx = _cmd_rx; - crate::ipc_listener::run_video_listener(path, mgr, controller, cmd_tx).await + let _encoder_rx = _encoder_rx; + crate::ipc_listener::run_video_listener(path, mgr, controller, cmd_tx, encoder_tx, Default::default()) + .await } }); @@ -236,7 +239,7 @@ async fn frames_cross_the_hub_byte_for_byte() { for (i, (g, s)) in got.iter().zip(&sent).enumerate() { assert!(g.is_whole(), "frame {i}: {}/{}", g.received, g.total); assert_eq!(g.seq, i as u16); - assert_eq!(g.ts_ms, s.meta.ts_ms); + assert_eq!(g.captured_us, s.meta.captured_us); assert_eq!(packets(g), originals(s), "frame {i} changed in transit"); } } @@ -317,3 +320,261 @@ async fn a_burst_is_skipped_whole_not_torn() { "frames went missing unaccounted" ); } + +/// A client's first media is live media: nothing produced before a carrier's +/// connection was up reaches it, and a video carrier asks for the keyframe it +/// cannot start without. +/// +/// The session exists from the first connection, and the others follow. Before +/// this, every packet produced in between was queued and delivered at once when +/// the carrier attached: 160 ms of audio at the start of a session, measured. +#[tokio::test(flavor = "multi_thread")] +async fn a_new_carrier_starts_from_live_media() { + use nesprotocol::{ALPN_AUDIO, MSG_IDR_REQUEST, decode_audio_payload, decode_frame}; + + let mgr = Arc::new(SessionManager::new(300_000)); + let hub = iroh::Endpoint::builder(presets::N0) + .alpns(vec![ALPN_VIDEO.to_vec(), ALPN_AUDIO.to_vec()]) + .relay_mode(iroh::endpoint::RelayMode::Disabled) + .transport_config(crate::dgram::media_transport_config()) + .bind() + .await + .expect("hub endpoint"); + let client_ep = iroh::Endpoint::builder(presets::N0) + .relay_mode(iroh::endpoint::RelayMode::Disabled) + .transport_config(crate::dgram::media_transport_config()) + .bind() + .await + .expect("client endpoint"); + let (cmd, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let controller = Arc::new(tokio::sync::Mutex::new(Controller::new(Limits::new(4_000)))); + + // One connection accepted and attached as the carrier its ALPN names. + let accept = |carrier: Carrier| { + let hub = hub.clone(); + let mgr = mgr.clone(); + let cmd = cmd.clone(); + let controller = controller.clone(); + tokio::spawn(async move { + let conn = hub.accept().await.expect("incoming").await.expect("conn"); + let (input, _) = tokio::sync::broadcast::channel(4); + mgr.attach(conn.remote_id(), carrier, conn, input, cmd, controller) + .await; + }) + }; + + // Video first: the session now exists, and audio has no connection yet. + let attached = accept(Carrier::Video); + let _video = client_ep + .connect(hub.addr(), ALPN_VIDEO) + .await + .expect("dial video"); + attached.await.expect("attach video"); + assert_eq!( + cmd_rx.try_recv().ok(), + Some(vec![MSG_IDR_REQUEST]), + "a new video carrier did not ask for a keyframe" + ); + + let packet = |position: u64| nesprotocol::encode_audio_payload(position, &[0xFC]); + for position in 0..32u64 { + mgr.broadcast_audio(packet(position * 240)).await; + } + + let attached = accept(Carrier::Audio); + let audio = client_ep + .connect(hub.addr(), ALPN_AUDIO) + .await + .expect("dial audio"); + attached.await.expect("attach audio"); + let live = 1_000_000 * 240; + mgr.broadcast_audio(packet(live)).await; + + let datagram = tokio::time::timeout(Duration::from_secs(5), audio.read_datagram()) + .await + .expect("no audio arrived") + .expect("audio connection failed"); + let (_, body) = nesprotocol::datagram::decode_datagram(&datagram).expect("a datagram"); + let (_, _, payload) = decode_frame(body).expect("a frame"); + let (position, _) = decode_audio_payload(payload).expect("an audio payload"); + assert_eq!( + position, live, + "the first audio a new carrier sent was produced before it attached" + ); +} + +/// Two encoders, two windows, through the hub for real, ref(d-0085). +mod windows { + use super::*; + use nesprotocol::window::{ + DGRAM_VIDEO_WINDOW, STREAM_WINDOWS, WindowEvent, decode_video_datagram, + decode_video_frame_body, encode_event_message, encode_window_pid, + }; + use nesprotocol::{CODEC_H264, FLAG_KEYFRAME, MSG_DATA, STREAM_VIDEO, encode_ipc_frame_from}; + + const GAME_A: u32 = 100; + const GAME_B: u32 = 200; + + async fn draw(rig: &Rig, pid: u32, n: u8, key: bool) { + let flags = if key { FLAG_KEYFRAME } else { 0 }; + let tag = (pid / 100) as u8; + let m = encode_ipc_frame_from(pid, STREAM_VIDEO, CODEC_H264, flags, 1, 64, 64, &[tag, n]); + rig.sender.send(&m).await.expect("ipc send"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + + /// `(raw kind byte, window in the datagram, window in the body, seq, + /// which game drew it, frame number)` for every video datagram that + /// arrives before the line goes quiet. + async fn received(conn: &Connection) -> Vec<(u8, u16, u16, u16, u8, u8)> { + let mut out = Vec::new(); + while let Ok(Ok(d)) = + tokio::time::timeout(Duration::from_millis(300), conn.read_datagram()).await + { + let (h, window, fragment) = decode_video_datagram(&d).expect("video"); + assert_eq!((h.index, h.total), (0, 1), "small frames are one datagram"); + let (body_window, seq, payload) = decode_video_frame_body(fragment).expect("body"); + let (_, data) = nesprotocol::decode_video_payload(payload).expect("payload"); + if window == 0 { + assert_eq!(fragment[0], MSG_DATA, "window 0 keeps the old body"); + } + out.push((d[0], window, body_window, seq, data[0], data[1])); + } + out + } + + async fn tell(rig: &Rig, f: impl Fn(&mut Vec)) { + let mut m = Vec::new(); + f(&mut m); + rig.mgr.on_nescope(m).await; + } + + fn opened(window: u16, launch: &str) -> WindowEvent { + WindowEvent::Opened { + window, + width: 64, + height: 64, + launch_id: launch.into(), + title: launch.into(), + app_id: String::new(), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn an_old_client_sees_the_first_game_and_a_subscriber_its_window() { + let rig = rig().await; + tell(&rig, |m| encode_event_message(m, &opened(0, "launch_a"))).await; + tell(&rig, |m| encode_event_message(m, &opened(1, "launch_b"))).await; + tell(&rig, |m| encode_window_pid(m, 0, GAME_A)).await; + tell(&rig, |m| encode_window_pid(m, 1, GAME_B)).await; + + // Never subscribed: the first game to draw, in the old encoding. + draw(&rig, GAME_A, 0, true).await; + draw(&rig, GAME_B, 0, true).await; + for n in 1..6 { + draw(&rig, GAME_A, n, false).await; + draw(&rig, GAME_B, n, false).await; + } + let got = received(&rig.client).await; + assert_eq!(got.len(), 5, "{got:?}"); + for (i, (kind, window, body_window, seq, game, n)) in got.iter().enumerate() { + assert_eq!((*kind, *window, *body_window), (0, 0, 0)); + assert_eq!((*game, *n), (1, i as u8 + 1), "only game A, in order"); + assert_eq!(*seq, i as u16 + 1, "seq 0 was the keyframe's"); + } + + // Subscribed to window 1: game B only, windowed, numbered afresh. + let (idr, mut idr_rx) = tokio::sync::mpsc::unbounded_channel(); + let me = rig._client_ep.addr().id; + rig.mgr.subscribe(me, 1, &idr).await; + // Only game B, whose window this is, is asked for a keyframe. + assert_eq!( + idr_rx.try_recv().ok(), + Some(crate::capture_cmd::keyframe_request(Some(GAME_B))) + ); + draw(&rig, GAME_B, 10, true).await; + for n in 11..15 { + draw(&rig, GAME_A, n, false).await; + draw(&rig, GAME_B, n, false).await; + } + let got = received(&rig.client).await; + assert_eq!(got.len(), 4, "{got:?}"); + for (i, (kind, window, body_window, seq, game, n)) in got.iter().enumerate() { + assert_eq!((*kind, *window, *body_window), (DGRAM_VIDEO_WINDOW, 1, 1)); + assert_eq!((*game, *n), (2, 11 + i as u8)); + assert_eq!(*seq, i as u16 + 1); + } + + // Its windows stream starts with the box as it stands. + let events = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let mut recv = rig.client.accept_uni().await.expect("uni"); + let mut head = [0u8; 2]; + recv.read_exact(&mut head).await.expect("head"); + if head[0] != STREAM_WINDOWS { + continue; + } + let mut events = Vec::new(); + for _ in 0..2 { + let mut len = [0u8; 4]; + recv.read_exact(&mut len).await.expect("len"); + let mut frame = vec![0u8; u32::from_le_bytes(len) as usize]; + recv.read_exact(&mut frame).await.expect("frame"); + events.push(WindowEvent::decode(frame[0], &frame[3..]).expect("event")); + } + return events; + } + }) + .await + .expect("a windows stream"); + assert_eq!(events, vec![opened(0, "launch_a"), opened(1, "launch_b")]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_primary_switch_starts_with_a_keyframe_and_a_fresh_sequence() { + let rig = rig().await; + draw(&rig, GAME_A, 0, true).await; + for n in 1..4 { + draw(&rig, GAME_A, n, false).await; + } + let got = received(&rig.client).await; + assert!(got.iter().all(|g| g.4 == 1), "{got:?}"); + // A stops; B draws deltas for long enough to take over. + let start = Instant::now(); + let mut n = 0u8; + while start.elapsed() < Duration::from_millis(1400) { + draw(&rig, GAME_B, n, false).await; + n = n.wrapping_add(1); + tokio::time::sleep(Duration::from_millis(15)).await; + } + // Deltas from the new encoder are held until its keyframe. + draw(&rig, GAME_B, 200, true).await; + for n in 201..204 { + draw(&rig, GAME_B, n, false).await; + } + let got = received(&rig.client).await; + assert_eq!(got.len(), 3, "only the deltas after B's keyframe: {got:?}"); + for (i, g) in got.iter().enumerate() { + assert_eq!((g.0, g.4, g.5), (0, 2, 201 + i as u8)); + assert_eq!(g.3, i as u16 + 1, "numbered afresh after the keyframe's 0"); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn one_untagged_game_is_the_old_stream() { + // An encoder that predates tagging, a nescope that predates windows. + let rig = rig().await; + let send = |n: u8, key: bool| { + let flags = if key { FLAG_KEYFRAME } else { 0 }; + nesprotocol::encode_ipc_frame(STREAM_VIDEO, CODEC_H264, flags, 1, 64, 64, &[1, n]) + }; + rig.sender.send(&send(0, true)).await.unwrap(); + for n in 1..4 { + tokio::time::sleep(Duration::from_millis(5)).await; + rig.sender.send(&send(n, false)).await.unwrap(); + } + let got = received(&rig.client).await; + assert_eq!(got.len(), 3); + assert!(got.iter().all(|g| (g.0, g.1) == (0, 0))); + } +} diff --git a/apps/neshub/src/ipc_listener.rs b/apps/neshub/src/ipc_listener.rs index c9b83731..99511e0b 100644 --- a/apps/neshub/src/ipc_listener.rs +++ b/apps/neshub/src/ipc_listener.rs @@ -22,11 +22,19 @@ fn set_recv_buffer(socket: &UnixDatagram, size: libc::c_int) { } } +/// Encoder processes the video listener has reported to the command +/// forwarder, ref(d-0085). +pub type KnownEncoders = Arc>>; + pub async fn run_video_listener( socket_path: PathBuf, session_manager: Arc, controller: Arc>, cmd_tx: tokio::sync::mpsc::UnboundedSender>, + // Each encoder process the first time it tags a frame, so commands can + // reach it on its own socket, ref(d-0085). + encoder_tx: tokio::sync::mpsc::UnboundedSender, + known_encoders: KnownEncoders, ) { if socket_path.exists() { let _ = std::fs::remove_file(&socket_path); @@ -60,6 +68,13 @@ pub async fn run_video_listener( match socket.recv(&mut buf).await { Ok(n) => { if let Some(decoded) = decode_ipc_frame(&buf[..n]) { + if decoded.stream_type == nesprotocol::STREAM_ENCODER_HELLO { + if let Some(pid) = decoded.pid { + let launch = String::from_utf8_lossy(decoded.data).into_owned(); + session_manager.note_encoder_launch(pid, launch); + } + continue; + } if decoded.stream_type == STREAM_PYROWAVE { let now_ms = started.elapsed().as_millis() as u64; match pyrowave.push(&decoded, now_ms) { @@ -82,19 +97,30 @@ pub async fn run_video_listener( ); continue; } + if let Some(pid) = decoded.pid + && known_encoders.lock().is_ok_and(|mut k| k.insert(pid)) + { + tracing::info!("encoder in process {pid} is drawing"); + let _ = encoder_tx.send(pid); + } // An encoder on a codec nobody here decodes is told again. session_manager .reassert_codec(decoded.codec, &controller, &cmd_tx) .await; - let mut frame_data = Vec::with_capacity(1 + decoded.data.len()); - frame_data.push(decoded.codec); - frame_data.push(decoded.flags); - frame_data.extend_from_slice(&decoded.timestamp_ms.to_le_bytes()); - frame_data.extend_from_slice(&decoded.width.to_le_bytes()); - frame_data.extend_from_slice(&decoded.height.to_le_bytes()); - frame_data.extend_from_slice(decoded.data); + let frame_data = nesprotocol::encode_video_payload( + &nesprotocol::VideoHead { + codec: decoded.codec, + flags: decoded.flags, + captured_us: decoded.stamp, + width: decoded.width, + height: decoded.height, + }, + decoded.data, + ); - session_manager.broadcast_video(frame_data).await; + session_manager + .broadcast_video(frame_data, decoded.pid) + .await; } else { tracing::warn!("invalid video IPC frame ({} bytes)", n); } @@ -150,7 +176,9 @@ pub async fn run_audio_listener(socket_path: PathBuf, session_manager: Arc bool { + use nesprotocol::window::*; + matches!( + kind, + nesprotocol::input::CURSOR_UPDATE + | nesprotocol::input::CURSOR_IMAGE + | MSG_WINDOW_OPENED + | MSG_WINDOW_TITLE + | MSG_WINDOW_ICON + | MSG_WINDOW_SIZE + | MSG_WINDOW_CLOSED + | MSG_WINDOW_PID + | MSG_CURSOR_WINDOW + ) +} + +/// nescope's socket: input to it, cursor, windows and stats from it. +/// +/// `cursor_tx` carries everything about the cursor and the windows, in order, +/// and an empty message each time nescope connects: a new connection is a new +/// compositor, whose window ids are not the last one's. pub async fn run_input_ipc_listener( socket_path: PathBuf, - input_tx: tokio::sync::broadcast::Sender>, + input_tx: tokio::sync::broadcast::Sender, cursor_tx: tokio::sync::mpsc::UnboundedSender>, nescope_stats_tx: tokio::sync::mpsc::UnboundedSender>, ) { @@ -205,19 +257,25 @@ pub async fn run_input_ipc_listener( let (mut read_half, mut write_half) = stream.into_split(); let mut input_rx = input_tx.subscribe(); - let _c_tx = cursor_tx.clone(); + let _ = cursor_tx.send(Vec::new()); let write_handle = tokio::spawn(async move { - loop { + // Fresh per connection: nescope starts every connection + // on window 0. + let mut tagger = crate::windows::FocusTagger::default(); + 'events: loop { match input_rx.recv().await { - Ok(data) => { - let len = data.len() as u16; - let mut frame = Vec::with_capacity(2 + data.len()); - frame.extend_from_slice(&len.to_le_bytes()); - frame.extend_from_slice(&data); - if write_half.write_all(&frame).await.is_err() { - tracing::debug!("input IPC write failed"); - break; + Ok((client, window, event)) => { + let now = std::time::Instant::now(); + for data in tagger.messages(client, window, &event, now) { + let len = data.len() as u16; + let mut frame = Vec::with_capacity(2 + data.len()); + frame.extend_from_slice(&len.to_le_bytes()); + frame.extend_from_slice(&data); + if write_half.write_all(&frame).await.is_err() { + tracing::debug!("input IPC write failed"); + break 'events; + } } } Err(RecvError::Lagged(n)) => { @@ -247,11 +305,9 @@ pub async fn run_input_ipc_listener( if read_half.read_exact(&mut payload).await.is_err() { break; } - // Route: cursor updates start with 0x80, stats start with 0x00-0x02 - if !payload.is_empty() - && (payload[0] == nesprotocol::input::CURSOR_UPDATE - || payload[0] == nesprotocol::input::CURSOR_IMAGE) - { + // Route: cursor and window messages in order on one + // channel; stats start with 0x00-0x02. + if payload.first().is_some_and(|k| is_cursor_or_window(*k)) { let _ = c_tx.send(payload); } else { let _ = ns_tx.send(payload); diff --git a/apps/neshub/src/keyframe.rs b/apps/neshub/src/keyframe.rs index 25d877d3..b774b104 100644 --- a/apps/neshub/src/keyframe.rs +++ b/apps/neshub/src/keyframe.rs @@ -11,7 +11,7 @@ use iroh::endpoint::Connection; use tracing::debug; use nesprotocol::reliable::MAX_KEYFRAME_STREAMS_IN_FLIGHT; -use nesprotocol::{STREAM_KEYFRAME, STREAM_VERSION}; +use nesprotocol::reliable::keyframe_stream_header; use crate::dgram::DatagramSender; @@ -50,7 +50,7 @@ impl KeyframeSender { /// stream writes are flow-controlled, so a receiver with no window left /// would otherwise hold up every delta frame queued behind this keyframe — /// and the deltas are the frames with an expiry date. - pub fn send(&self, seq: u16, body: &[u8]) -> bool { + pub fn send(&self, seq: u16, window: nesprotocol::window::WindowId, body: &[u8]) -> bool { if self.in_flight.load(Ordering::Relaxed) >= MAX_KEYFRAME_STREAMS_IN_FLIGHT { self.fell_back.fetch_add(1, Ordering::Relaxed); return false; @@ -79,7 +79,7 @@ impl KeyframeSender { // are a worse path for a keyframe, but they beat dropping the one // frame everything after it is predicted from. fell_back.fetch_add(1, Ordering::Relaxed); - if let Err(e) = fallback.send_frame(seq, &body) { + if let Err(e) = fallback.send_frame(seq, window, &body) { debug!("keyframe {seq}: stream failed and datagram fallback failed too: {e}"); } }); @@ -111,7 +111,8 @@ async fn write_keyframe_stream(conn: &Connection, body: &[u8]) -> bool { } }; - if let Err(e) = send.write_all(&[STREAM_KEYFRAME, STREAM_VERSION]).await { + let header = keyframe_stream_header(nesprotocol::clock::monotonic_us()); + if let Err(e) = send.write_all(&header).await { debug!("keyframe header write failed: {e}"); send.reset(0u32.into()).ok(); return false; diff --git a/apps/neshub/src/main.rs b/apps/neshub/src/main.rs index a2810ef1..17baae06 100644 --- a/apps/neshub/src/main.rs +++ b/apps/neshub/src/main.rs @@ -1,3 +1,4 @@ +mod congestion; mod control; mod dgram; #[cfg(test)] @@ -6,10 +7,14 @@ mod ipc_listener; mod keyframe; mod negotiation; mod pyro; +mod rate; mod screenshot; mod session; mod ticket; +mod windows; +mod capture_cmd; +use std::net::{Ipv4Addr, SocketAddrV4}; use std::path::PathBuf; use std::sync::Arc; @@ -28,6 +33,16 @@ struct Args { #[arg(long, env = "NESTRI_RELAY", default_value = "default")] relay: String, + /// The UDP port to bind for IPv4, which the host forwards to this box. + /// + /// Set by the guest init from `nestri.port=`. The host installs its forward + /// and its pinned masquerade for this port before the box boots, so every + /// flow, the relay's included, leaves on one public port, and the address + /// the relay reports is the one peers can punch through to. Unset means a + /// port of the hub's own choosing, which only works where nothing NATs it. + #[arg(long, env = "NESTRI_BIND_PORT")] + bind_port: Option, + /// Path for the video IPC socket (nescapture → neshub) #[arg( long, @@ -172,6 +187,19 @@ async fn main() -> Result<()> { .alpns(ALPNS.iter().map(|a| a.to_vec()).collect::>()) .transport_config(crate::dgram::media_transport_config()); + // Replaces iroh's own `0.0.0.0:0` default rather than adding a second + // IPv4 socket; IPv6 keeps its default. + match args.bind_port { + Some(port) => { + builder = builder.bind_addr(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port))?; + tracing::info!(port, "binding the host's forwarded port"); + } + None => tracing::info!( + "no NESTRI_BIND_PORT, so this hub picks its own port; behind the host's NAT, \ + peers behind a NAT of their own will stay on the relay" + ), + } + match args.relay.as_str() { "default" | "" => { builder = builder.relay_mode(iroh::endpoint::RelayMode::Default); @@ -208,7 +236,7 @@ async fn main() -> Result<()> { tracing::info!("endpoint online: {}", ep_id.fmt_short()); // Input broadcast channel: input reader -> input IPC listener -> nescope - let (input_broadcast_tx, _) = tokio::sync::broadcast::channel::>(256); + let (input_broadcast_tx, _) = tokio::sync::broadcast::channel::(256); // Cursor channel: IPC listener (read side) -> client sessions -> desktop-app let (cursor_tx, mut cursor_rx) = tokio::sync::mpsc::unbounded_channel::>(); @@ -229,15 +257,69 @@ async fn main() -> Result<()> { // IDR / encode settings command channel: input reader → nescapture let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::>(); + // Encoder processes as the video listener first hears from them. + let (encoder_tx, mut encoder_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Encoders the video listener has reported. One the forwarder finds gone + // is taken out, so its next frame reports it again. + let known_encoders: ipc_listener::KnownEncoders = Default::default(); { + let known = known_encoders.clone(); let mgr = session_manager.clone(); tokio::spawn(async move { - let cmd_path = std::path::PathBuf::from("/tmp/nescapture-cmd.sock"); - while let Some(mut bytes) = cmd_rx.recv().await { + let cmd_path = std::path::PathBuf::from(nesprotocol::CAPTURE_CMD_SOCKET); + let mut fanout = capture_cmd::Fanout::default(); + loop { + let bytes = tokio::select! { + Some(pid) = encoder_rx.recv() => { + // Every encoder the hub knows hears every command, on + // its own socket; a new one first hears what it + // missed, ref(d-0085). + for mut cmd in fanout.encoder(pid).unwrap_or_default() { + fill_pyrowave_packet_size(&mut cmd, mgr.pyro_packet_size().await); + let path = nesprotocol::capture_cmd_socket(pid); + if let Ok(sock) = std::os::unix::net::UnixDatagram::unbound() + && let Err(e) = sock.send_to(&cmd, &path) + { + tracing::debug!("telling encoder {pid} what it missed: {e}"); + } + } + continue; + } + cmd = cmd_rx.recv() => match cmd { + Some(cmd) => cmd, + None => break, + }, + }; + // A keyframe request for one window goes to that window's + // encoder alone; everything else to all of them. + let (target, mut bytes) = capture_cmd::target_of(bytes); fill_pyrowave_packet_size(&mut bytes, mgr.pyro_packet_size().await); let Ok(sock) = std::os::unix::net::UnixDatagram::unbound() else { continue; }; + let destinations = fanout.destinations_for(target); + if target.is_none() { + fanout.sent(&bytes); + } + if destinations.first() != Some(&cmd_path) { + for path in destinations { + match sock.send_to(&bytes, &path) { + Ok(_) => {} + // Refused or absent: that process has gone. + Err(e) if capture_cmd::encoder_is_gone(&e) => { + tracing::info!("encoder at {} is gone: {e}", path.display()); + if let Some(pid) = capture_cmd::pid_of(&path) { + fanout.forget(pid); + if let Ok(mut known) = known.lock() { + known.remove(&pid); + } + } + } + Err(e) => tracing::debug!("command to {}: {e}", path.display()), + } + } + continue; + } // The encoder's socket is not there until the game draws its // first frame, and a client can connect and say what it decodes // before that. A command sent then used to be lost -- the codec @@ -247,11 +329,17 @@ async fn main() -> Result<()> { let mut waited = 0u32; while sock.send_to(&bytes, &cmd_path).is_err() { if waited == 0 { - tracing::info!("nescapture is not listening at {} yet; waiting", cmd_path.display()); + tracing::info!( + "nescapture is not listening at {} yet; waiting", + cmd_path.display() + ); } waited += 1; if waited > 600 { - tracing::warn!("nescapture cmd send failed at {} for a minute; dropped", cmd_path.display()); + tracing::warn!( + "nescapture cmd send failed at {} for a minute; dropped", + cmd_path.display() + ); break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -260,15 +348,16 @@ async fn main() -> Result<()> { }); } - // Spawn cursor relay + // Cursor and window relay { let mgr = session_manager.clone(); tokio::spawn(async move { while let Some(data) = cursor_rx.recv().await { - mgr.broadcast_cursor(data).await; + mgr.on_nescope(data).await; } }); } + tokio::spawn(session_manager.clone().serve_subscriptions(cmd_tx.clone())); // Spawn nescope stats relay { @@ -332,29 +421,15 @@ async fn main() -> Result<()> { loop { interval.tick().await; - // One decision a second, on the same tick as the stats, because - // a report describes the second that just passed and there is - // nothing to gain from deciding more often than they arrive. + // The rate is decided per frame, as feedback arrives (see + // `session.rs`). Once a second is for what feedback cannot say: + // a path that went silent, and clients that left. { - let clients = mgr.client_count().await; - let (report, path, self_inflicted) = mgr.worst_report().await; + let streaming = mgr.streaming_clients().await; + let (_, path, _) = mgr.worst_report().await; let mut controller = controller.lock().await; - if let Some(kbps) = controller.tick(clients, report, path, self_inflicted) { - let mut cmd = vec![nesprotocol::MSG_ENCODE_SETTINGS]; - nesprotocol::encode_bitrate_only(&mut cmd, kbps); - if cmd_tx.send(cmd).is_err() { - tracing::warn!("encoder command channel closed"); - } else { - // Trace, not info: under a path that keeps moving - // this fires every second, and a per-second line at - // info buries everything worth reading. - tracing::trace!( - "video ceiling {}/{} kbps: {:?}", - kbps, - controller.limits().ceiling_kbps, - controller.reason(), - ); - } + if let Some(kbps) = controller.tick(&streaming, path) { + crate::control::actuate(&cmd_tx, &controller, kbps); } } @@ -405,6 +480,8 @@ async fn main() -> Result<()> { pipeline_p95_ms, pipeline_max_ms, backlog_ms: controller.backlog_ms().min(u32::from(u16::MAX)) as u16, + capacity_kbps: controller.capacity_kbps(), + delivery_kbps: controller.delivery_kbps(), }, ); } @@ -433,8 +510,19 @@ async fn main() -> Result<()> { let stats_ipc = args.stats_ipc.clone(); let stats_tx_clone = nescope_stats_tx.clone(); tokio::spawn({ - let (mgr, controller, cmd_tx) = (session_manager.clone(), controller.clone(), cmd_tx.clone()); - async move { ipc_listener::run_video_listener(video_ipc, mgr, controller, cmd_tx).await } + let (mgr, controller, cmd_tx) = + (session_manager.clone(), controller.clone(), cmd_tx.clone()); + async move { + ipc_listener::run_video_listener( + video_ipc, + mgr, + controller, + cmd_tx, + encoder_tx, + known_encoders, + ) + .await + } }); tokio::spawn({ let mgr = session_manager.clone(); diff --git a/apps/neshub/src/pyro.rs b/apps/neshub/src/pyro.rs index 524c3fc7..00ca9c0f 100644 --- a/apps/neshub/src/pyro.rs +++ b/apps/neshub/src/pyro.rs @@ -44,13 +44,14 @@ impl PyroSender { debug!("{label}: peer does not support QUIC datagrams"); return; }; - let datagrams = match pack_datagrams(self.seq, frame, max) { - Ok(d) => d, - Err(e) => { - debug!("{label}: dropping PyroWave frame {}: {e}", frame.frame); - return; - } - }; + let datagrams = + match pack_datagrams(self.seq, frame, max, nesprotocol::clock::monotonic_us()) { + Ok(d) => d, + Err(e) => { + debug!("{label}: dropping PyroWave frame {}: {e}", frame.frame); + return; + } + }; let backlog = DGRAM_BUFFER_BYTES.saturating_sub(self.conn.datagram_send_buffer_space()); if let Some(skip) = should_skip(backlog, datagrams.buf.len(), DGRAM_BUFFER_BYTES) { diff --git a/apps/neshub/src/rate.rs b/apps/neshub/src/rate.rs new file mode 100644 index 00000000..a8cf9ec0 --- /dev/null +++ b/apps/neshub/src/rate.rs @@ -0,0 +1,506 @@ +//! What rate one client's path can carry, from how its frames arrived. +//! +//! See nescore's `docs/superpowers/specs/2026-10-16-video-rate-from-the-path-design.md`. +//! +//! # Every frame is a measurement +//! +//! A frame's fragments leave this end back to back (`congestion.rs` sees that +//! nothing holds them), so they reach the client spaced by the slowest link +//! they crossed. Two things follow, both read from the client's per-frame +//! feedback rather than guessed: +//! +//! - **Capacity.** A frame's bytes after its first fragment, over the time its +//! fragments took to arrive, is the bottleneck's rate. Every multi-fragment +//! frame measures it without sending above it for longer than one frame. +//! - **Whether we are over it.** Below capacity a frame's train has drained +//! before the next frame reaches the bottleneck, so consecutive trains arrive +//! with a gap between them. When the next train starts arriving no later than +//! the previous one's next fragment would have, the bottleneck never went idle +//! between them: it is busy with our traffic. Busy is not yet too much -- a +//! keyframe keeps it busy for a few frames and then it drains -- so the test +//! is whether the queue the first fragments find is *growing* across the busy +//! period, and whether more is being sent into it than comes out. +//! +//! # Down at once, up on evidence +//! +//! When the queue grows, what the path delivered over the busy period is its +//! measured share, and the queue already built is drained exactly: sending as +//! far below that share as the rate went above it, for as long, until the +//! trains have a gap between them again. Then the rate sits at the share. +//! +//! Going up uses the capacity the frames measured, and holds each step on the +//! same evidence the audio playout uses: a rate may be used again once the path +//! has gone quiet for longer than the longest it has ever gone between two +//! overloads at or below it, playout's start counting as the first. + +use nesprotocol::datagram::FrameFeedback; + +/// Why a client's rate is what it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Reason { + /// Where the session started; nothing has said otherwise yet. + Start, + /// The queue was growing; sending below the path's share to drain it. + Draining, + /// Settled at the share the path delivered while it was queueing. + Measured, + /// Raised to what the path has proven it carries. + Stepped, + /// No frame has come back; held at the last rate the path proved. + Silent, +} + +/// One time the path was overloaded. +#[derive(Debug, Clone, Copy)] +struct Overload { + at_us: u64, + /// What was being sent into it. + sent_kbps: u32, +} + +/// A run of frames during which the bottleneck never went idle. +#[derive(Debug, Clone, Copy)] +struct Busy { + start_sent_us: u64, + start_arrival_us: u64, + /// Bytes of the frames in the run before the latest one. + bytes: u64, + /// The queue the second frame of the run found: the first frame's own + /// burst included, so a run that only drains never reads as growing. + first_q_us: u32, + /// Whether this run has already been counted as an overload. + counted: bool, +} + +#[derive(Debug)] +pub struct PathModel { + allowed_kbps: u32, + reason: Reason, + started_us: u64, + prev: Option, + busy: Option, + draining: bool, + /// The share the path last delivered while queueing. + delivery_kbps: Option, + /// What frames have measured of the capacity since the last overload. + capacity: Median, + overloads: Vec, + /// Whether any feedback arrived since the last [`tick`](Self::tick). + heard: bool, +} + +/// The median of everything pushed, kept as two heaps so each push costs +/// `log n` however long the session runs. +/// +/// The median rather than the highest, because the error in a dispersion +/// sample is one-sided at each end. Fragments the client reads in one batch +/// share a timestamp, so a train's spread reads short and its rate high -- on a +/// fast path, measured 2026-10-16, a highest-ever of 9 Gbps through a link +/// carrying 8 Mbps. A train interleaved with other traffic spreads long and +/// reads low. Neither is the bottleneck; the middle of the frames is. +#[derive(Debug, Default)] +struct Median { + /// The lower half, largest on top. + low: std::collections::BinaryHeap, + /// The upper half, smallest on top. + high: std::collections::BinaryHeap>, +} + +impl Median { + fn push(&mut self, v: u32) { + if self.low.peek().is_none_or(|&top| v <= top) { + self.low.push(v); + } else { + self.high.push(std::cmp::Reverse(v)); + } + if self.low.len() > self.high.len() + 1 { + self.high.push(std::cmp::Reverse(self.low.pop().unwrap())); + } else if self.high.len() > self.low.len() { + self.low.push(self.high.pop().unwrap().0); + } + } + + fn get(&self) -> Option { + self.low.peek().copied() + } +} + +/// Bits per millisecond, which is kbps, from bytes over microseconds. +fn kbps(bytes: u64, us: u64) -> u32 { + (bytes.saturating_mul(8_000) / us.max(1)).min(u64::from(u32::MAX)) as u32 +} + +impl PathModel { + pub fn new(start_kbps: u32, now_us: u64) -> Self { + Self { + allowed_kbps: start_kbps, + reason: Reason::Start, + started_us: now_us, + prev: None, + busy: None, + draining: false, + delivery_kbps: None, + capacity: Median::default(), + overloads: Vec::new(), + heard: false, + } + } + + pub fn allowed_kbps(&self) -> u32 { + self.allowed_kbps + } + pub fn reason(&self) -> Reason { + self.reason + } + pub fn capacity_kbps(&self) -> Option { + self.capacity.get() + } + pub fn delivery_kbps(&self) -> Option { + self.delivery_kbps + } + + /// Take one frame's feedback. + pub fn observe(&mut self, frame: &FrameFeedback, now_us: u64) { + self.heard = true; + if frame.fragments >= 2 && frame.spread_us > 0 { + let measured = kbps( + u64::from(frame.bytes.saturating_sub(u32::from(frame.first_bytes))), + u64::from(frame.spread_us), + ); + self.capacity.push(measured); + } + + if let Some(prev) = self.prev.replace(*frame) { + if busy_between(&prev, frame) { + self.continue_busy(&prev, frame, now_us); + } else { + self.busy = None; + if self.draining { + self.draining = false; + if let Some(share) = self.delivery_kbps { + self.allowed_kbps = share; + self.reason = Reason::Measured; + } + } else { + self.step_up(now_us); + } + } + } + } + + fn continue_busy(&mut self, prev: &FrameFeedback, frame: &FrameFeedback, now_us: u64) { + let busy = self.busy.get_or_insert(Busy { + start_sent_us: prev.sent_us, + start_arrival_us: prev.first_arrival_us, + bytes: 0, + first_q_us: frame.first_q_us, + counted: false, + }); + busy.bytes += u64::from(prev.bytes); + let delivered = kbps( + busy.bytes, + frame.first_arrival_us.saturating_sub(busy.start_arrival_us), + ); + let sent = kbps(busy.bytes, frame.sent_us.saturating_sub(busy.start_sent_us)); + let growing = frame.first_q_us > busy.first_q_us; + if !(growing && sent > delivered) { + return; + } + // Drained at the rate it was built: as far below the share as the + // sender went above it, for as long as the run has lasted. + self.allowed_kbps = (2 * u64::from(delivered)) + .saturating_sub(u64::from(sent)) + .max(1) as u32; + self.delivery_kbps = Some(delivered); + self.draining = true; + self.reason = Reason::Draining; + if !busy.counted { + busy.counted = true; + self.overloads.push(Overload { + at_us: now_us, + sent_kbps: sent, + }); + // What the frames measured above this share was not true of the + // path as it is now; it is measured again from here. + self.capacity = Median::default(); + self.capacity.push(delivered); + } + } + + /// Raise the rate to the highest the path has proven it carries. + fn step_up(&mut self, now_us: u64) { + let Some(capacity) = self.capacity.get() else { + return; + }; + if capacity <= self.allowed_kbps { + return; + } + // The capacity frames measured, or the highest rate below it that the + // path has gone quiet at for long enough to be trusted. + let mut candidates: Vec = self + .overloads + .iter() + .map(|o| o.sent_kbps) + .filter(|&r| r > self.allowed_kbps && r < capacity) + .chain(std::iter::once(capacity)) + .collect(); + candidates.sort_unstable_by(|a, b| b.cmp(a)); + let Some(&to) = candidates.iter().find(|&&r| self.proven(r, now_us)) else { + return; + }; + if to > self.allowed_kbps { + self.allowed_kbps = to; + self.reason = Reason::Stepped; + } + } + + /// Whether `rate` has been quiet long enough: since the last overload at or + /// below it, longer than the longest gap between two such overloads. + fn proven(&self, rate: u32, now_us: u64) -> bool { + let mut last = self.started_us; + let mut longest = 0; + let mut any = false; + for o in self.overloads.iter().filter(|o| o.sent_kbps <= rate) { + longest = longest.max(o.at_us.saturating_sub(last)); + last = o.at_us; + any = true; + } + !any || now_us.saturating_sub(last) > longest + } + + /// Once a second. A second in which no frame came back is itself a + /// measurement: whatever was sent did not arrive, so the rate goes to the + /// last share the path proved, as for a queue. + pub fn tick(&mut self) { + if !std::mem::take(&mut self.heard) && self.prev.is_some() { + if let Some(share) = self.delivery_kbps { + self.allowed_kbps = self.allowed_kbps.min(share); + } + self.reason = Reason::Silent; + } + } +} + +/// Whether the bottleneck went from `prev`'s train straight into `frame`'s. +/// +/// On a link that never idled, `frame`'s first fragment lands exactly its own +/// serialisation after `prev`'s last, at the rate a train measured. So the +/// test is whether it landed no later than that. With no train long enough to +/// show a rate there is nothing to compare with, and two single-fragment frames +/// cannot tell a busy link from an idle one. +fn busy_between(prev: &FrameFeedback, frame: &FrameFeedback) -> bool { + let per_byte = [prev, frame] + .into_iter() + .find(|f| f.fragments >= 2 && f.bytes > u32::from(f.first_bytes)) + .map(|f| { + ( + u64::from(f.spread_us), + u64::from(f.bytes - u32::from(f.first_bytes)), + ) + }); + let Some((spread, bytes)) = per_byte else { + return false; + }; + let first = (u64::from(frame.first_bytes) * spread).div_ceil(bytes); + let prev_end = prev.first_arrival_us + u64::from(prev.spread_us); + frame.first_arrival_us <= prev_end + first +} + +#[cfg(test)] +mod tests { + use super::*; + + const FRAGMENT: u64 = 1_200; + const INTERVAL_US: u64 = 16_667; + const PROPAGATION_US: u64 = 40_000; + + /// A path with a FIFO bottleneck, fed one frame at a time. + struct Bottleneck { + /// What the bottleneck carries for us, in kbps, by time. + capacity: Box u32>, + /// Extra bytes other traffic puts into it, by frame. + cross: Box u64>, + /// When the bottleneck is next free. + free_at: u64, + seq: u16, + } + + impl Bottleneck { + fn new(capacity: impl Fn(u64) -> u32 + 'static) -> Self { + Self { + capacity: Box::new(capacity), + cross: Box::new(|_| 0), + free_at: 0, + seq: 0, + } + } + + /// Send a frame of `bytes` at `sent_us`; return what the client says. + fn send(&mut self, n: u64, sent_us: u64, bytes: u64) -> FrameFeedback { + let c = u64::from((self.capacity)(sent_us)).max(1); + let at = sent_us + PROPAGATION_US; + // Cross traffic lands just ahead of the frame. + self.free_at = self.free_at.max(at) + (self.cross)(n) * 8_000 / c; + let start = self.free_at.max(at); + let first_q = start - at; + let fragments = bytes.div_ceil(FRAGMENT).max(1); + let serialise = |b: u64| b * 8_000 / c; + let first_done = start + serialise(FRAGMENT.min(bytes)); + let last_done = start + serialise(bytes); + self.free_at = last_done; + self.seq = self.seq.wrapping_add(1); + FrameFeedback { + seq: self.seq, + bytes: bytes as u32, + fragments: fragments as u16, + first_bytes: FRAGMENT.min(bytes) as u16, + sent_us, + first_arrival_us: first_done, + spread_us: (last_done - first_done) as u32, + first_q_us: first_q as u32, + } + } + } + + /// Run `frames` frames at whatever the model allows, the encoder holding + /// its target as an average, as a CBR encoder does. `keyframe_every` + /// frames, one is 8x a delta, and the deltas around it are smaller by what + /// it took. + fn run( + path: &mut Bottleneck, + model: &mut PathModel, + frames: u64, + keyframe_every: Option, + ) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + for n in 0..frames { + let sent = n * INTERVAL_US; + let budget = u64::from(model.allowed_kbps()) * INTERVAL_US / 8_000; + let size = match keyframe_every { + Some(gop) => { + let delta = budget * gop / (gop + 7); + if n % gop == 0 { delta * 8 } else { delta } + } + None => budget, + }; + let fb = path.send(n, sent, size.max(1)); + model.observe(&fb, fb.first_arrival_us); + out.push((model.allowed_kbps(), fb.first_q_us)); + } + out + } + + #[test] + fn a_rate_above_the_path_comes_down_to_what_it_delivers() { + let mut path = Bottleneck::new(|_| 6_000); + let mut model = PathModel::new(10_000, 0); + let trace = run(&mut path, &mut model, 600, None); + let (settled, q) = *trace.last().unwrap(); + assert!( + (5_400..=6_000).contains(&settled), + "settled at {settled} kbps on a 6000 kbps path" + ); + assert!(q < 2_000, "the queue never drained: {q} us"); + let first_low = trace.iter().position(|(r, _)| *r <= 6_000).unwrap(); + assert!(first_low < 20, "took {first_low} frames to come down"); + } + + #[test] + fn a_rate_below_the_path_steps_up_to_what_frames_measured() { + let mut path = Bottleneck::new(|_| 12_000); + let mut model = PathModel::new(3_000, 0); + let trace = run(&mut path, &mut model, 120, None); + let (settled, q) = *trace.last().unwrap(); + assert!( + (11_000..=12_050).contains(&settled), + "settled at {settled} kbps on a 12000 kbps path" + ); + assert!(q < 2_000, "stepping up built a queue: {q} us"); + } + + #[test] + fn a_keyframe_is_not_mistaken_for_overload() { + let mut path = Bottleneck::new(|_| 12_000); + let mut model = PathModel::new(6_000, 0); + run(&mut path, &mut model, 600, Some(120)); + assert!( + model.overloads.is_empty(), + "{} overloads", + model.overloads.len() + ); + } + + #[test] + fn a_capacity_drop_is_followed_down_and_back_up_without_a_sawtooth() { + let mut path = Bottleneck::new(|t| { + if (10_000_000..20_000_000).contains(&t) { + 4_000 + } else { + 9_000 + } + }); + let mut model = PathModel::new(9_000, 0); + let trace = run(&mut path, &mut model, 60 * 40, None); + let during = trace[60 * 15].0; + assert!( + (3_600..=4_000).contains(&during), + "{during} kbps during the drop" + ); + let after = trace.last().unwrap().0; + assert!( + (8_000..=9_000).contains(&after), + "{after} kbps after the drop" + ); + assert!( + model.overloads.len() <= 4, + "{} overloads: a sawtooth", + model.overloads.len() + ); + } + + #[test] + fn recurring_cross_traffic_is_learned_and_not_stepped_into_forever() { + let mut path = Bottleneck::new(|_| 10_000); + // Another flow sends 60 KB every 5 s. + path.cross = Box::new(|n| if n % 300 == 150 { 60_000 } else { 0 }); + let mut model = PathModel::new(9_000, 0); + run(&mut path, &mut model, 60 * 120, None); + let second_minute = model + .overloads + .iter() + .filter(|o| o.at_us > 60_000_000) + .count(); + assert!( + second_minute <= 2, + "{second_minute} overloads in the second minute" + ); + } + + /// What the record caught: most frames measure the path, a few batched + /// ones read absurdly fast. The estimate is the path's. + #[test] + fn a_few_batched_trains_do_not_set_the_capacity() { + let mut m = Median::default(); + for n in 0..100 { + m.push(if n % 10 == 0 { + 9_000_000 + } else { + 11_800 + n % 7 * 100 + }); + } + let c = m.get().unwrap(); + assert!((11_800..=12_400).contains(&c), "{c} kbps"); + } + + #[test] + fn silence_holds_the_rate_at_the_last_proven_share() { + let mut path = Bottleneck::new(|_| 6_000); + let mut model = PathModel::new(10_000, 0); + run(&mut path, &mut model, 300, None); + model.tick(); + model.tick(); + assert_eq!(model.reason(), Reason::Silent); + assert!(model.allowed_kbps() <= model.delivery_kbps().unwrap()); + } +} diff --git a/apps/neshub/src/session.rs b/apps/neshub/src/session.rs index 9e5742bf..c615548d 100644 --- a/apps/neshub/src/session.rs +++ b/apps/neshub/src/session.rs @@ -6,7 +6,9 @@ use tokio::sync::Mutex; use tracing::{debug, info, warn}; use nesprotocol::datagram::{DGRAM_AUDIO, DGRAM_BUFFER_BYTES, DGRAM_VIDEO}; -use nesprotocol::input::{INPUT_KEY, INPUT_MOUSE_BUTTON, INPUT_MOUSE_MOVE, INPUT_MOUSE_WHEEL}; +use nesprotocol::input::{ + INPUT_KEY, INPUT_MOUSE_BUTTON, INPUT_MOUSE_MOVE, INPUT_MOUSE_WHEEL, INPUT_OUTPUT_SIZE, +}; use nesprotocol::{BIDI_CONTROL, BIDI_INPUT, Carrier, STREAM_CURSOR, STREAM_STATS}; use nesprotocol::{ControlMode, ReceiverReport, decode_control_mode, decode_receiver_report}; use nesprotocol::{FRAME_HDR_LEN, STREAM_VERSION, encode_frame}; @@ -18,12 +20,41 @@ use nesprotocol::{ use crate::control::{Controller, PathView}; use crate::negotiation::{Negotiator, effective_caps}; +use crate::capture_cmd::keyframe_request; use crate::dgram::{MediaItem, run_datagram_writer}; +use crate::windows::{Source, Subscription, WindowTable, route, wants_cursor}; +use nesprotocol::window::{MSG_WINDOW_SUBSCRIBE, STREAM_WINDOWS, WINDOW_DEFAULT, WindowEvent, WindowId}; use bytes::Bytes; use nesprotocol::pyrowave::PyroFrame; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +/// Input on its way to the box, ref(d-0085): which client sent it (its +/// gamepad session number), the window it belongs to, and the event. An empty +/// event asks only that the window be focused, which the box's side grants +/// only when no other client is typing; see `windows::FocusTagger`. +pub type TaggedInput = (u32, WindowId, Vec); + +/// What a client's input reader needs to say which window its input is for. +#[derive(Clone)] +struct InputWindow { + subscription: Arc>, + /// The window of the box's primary picture, which is what a client that + /// never subscribed is looking at. + primary: Arc, +} + +impl InputWindow { + /// `None` while subscribed to a window that no longer exists. + fn current(&self) -> Option { + match *self.subscription.lock().ok()? { + Subscription::Legacy => Some(self.primary.load(Ordering::Relaxed) as WindowId), + Subscription::Window(w) => Some(w), + Subscription::Torn => None, + } + } +} + /// One client, across the several connections it opens. /// /// A connection is the unit congestion control, pacing and the datagram send @@ -69,7 +100,18 @@ pub struct ClientSession { /// The rate a PyroWave request that names none runs at. pyrowave_kbps: u32, relay_ms: Arc, - input_broadcast: tokio::sync::broadcast::Sender>, + input_broadcast: tokio::sync::broadcast::Sender, + /// Which window this client is shown, ref(d-0085). + subscription: Arc>, + /// The encoder whose frames this client was last sent (0: none yet), so + /// its keyframe requests go to that encoder alone. + encoder_pid: Arc, + primary_window: Arc, + /// Where a subscribe request goes: the manager, which holds the windows. + subscribe_tx: tokio::sync::mpsc::UnboundedSender<(iroh::EndpointId, WindowId)>, + /// Lifecycle events for this client's windows stream, `[type][payload]`. + send_windows: tokio::sync::mpsc::UnboundedSender>, + pending_windows: Option>>, /// This client's number on the gamepad socket, so the box's side can tell /// two clients' controllers apart. Assigned by the manager, never reused /// within one hub's lifetime. @@ -113,8 +155,12 @@ impl Drop for ClientSession { impl ClientSession { /// A client with no connections yet. They attach as they are accepted. + #[allow(clippy::too_many_arguments)] pub fn new( - input_broadcast: tokio::sync::broadcast::Sender>, + input_broadcast: tokio::sync::broadcast::Sender, + primary_window: Arc, + encoder_pid: u32, + subscribe_tx: tokio::sync::mpsc::UnboundedSender<(iroh::EndpointId, WindowId)>, gamepad_session: u32, gamepad_tx: tokio::sync::broadcast::Sender>, relay_ms: Arc, @@ -129,7 +175,14 @@ impl ClientSession { let (audio_tx, audio_rx) = tokio::sync::mpsc::unbounded_channel::(); let (cursor_tx, cursor_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (stats_tx, stats_rx) = tokio::sync::mpsc::unbounded_channel::>(); + let (windows_tx, windows_rx) = tokio::sync::mpsc::unbounded_channel::>(); Self { + subscription: Arc::new(std::sync::Mutex::new(Subscription::Legacy)), + encoder_pid: Arc::new(AtomicU32::new(encoder_pid)), + primary_window, + subscribe_tx, + send_windows: windows_tx, + pending_windows: Some(windows_rx), video_conn: None, other_conns: Vec::new(), awaiting_keyframe: Arc::new(AtomicBool::new(false)), @@ -181,6 +234,29 @@ impl ClientSession { return; }; self.video_conn = Some(conn.clone()); + // A writer that has just started has sent no reference frame, + // so nothing but a keyframe can be decoded from it. Asked for + // now rather than after the client fails to decode a delta and + // says so, a round trip later; and deltas are held back until + // it is sent, as for any client waiting on one. + self.awaiting_keyframe.store(true, Ordering::Relaxed); + let _ = self + .idr_cmd_tx + .send(keyframe_request(pid_or_all(&self.encoder_pid))); + // The client says how each frame arrived, back on this same + // connection; the rate is decided on each one as it comes. + self.tasks.push(tokio::spawn(run_feedback_reader( + conn.clone(), + self.client, + self.controller.clone(), + self.idr_cmd_tx.clone(), + ))); + // Lifecycle events, on a stream opened only once there is one + // to send, which is only once this client has subscribed. + if let Some(windows) = self.pending_windows.take() { + self.tasks + .push(tokio::spawn(run_windows_sender(conn.clone(), windows))); + } let relay_ms = self.relay_ms.clone(); let awaiting = self.awaiting_keyframe.clone(); let withheld = self.withheld.clone(); @@ -237,8 +313,12 @@ impl ClientSession { session: self.gamepad_session, to_box: self.gamepad_tx.clone(), }; + let window = InputWindow { + subscription: self.subscription.clone(), + primary: self.primary_window.clone(), + }; self.tasks.push(tokio::spawn(async move { - run_input_reader(conn, broadcast, gamepad, feedback).await + run_input_reader(conn, broadcast, window, gamepad, feedback).await })); } Carrier::Control => { @@ -269,9 +349,16 @@ impl ClientSession { client: self.client, negotiator: self.negotiator.clone(), }; + let subscribe = ( + self.client, + self.subscribe_tx.clone(), + self.encoder_pid.clone(), + ); self.tasks.push(tokio::spawn(async move { - run_control_reader(conn, idr, reports, controller, awaiting, pyrowave, caps) - .await + run_control_reader( + conn, idr, reports, controller, awaiting, pyrowave, caps, subscribe, + ) + .await })); } } @@ -339,13 +426,29 @@ impl ClientSession { /// only way it fails is a closed channel, which means the writer is already /// gone, and sixty warnings a second on the way down bury whatever actually /// ended the session. + /// + /// **Nothing is queued for a carrier that has not attached.** A client's + /// connections arrive one after another and its session exists from the + /// first, so a channel created with the session would collect every frame + /// produced before its own connection is up and deliver the lot the moment + /// it is: measured 2026-10-16 as 160 ms of audio landing at once as a + /// session began, and over half a second of video. Media is live or it is + /// nothing, so until the writer exists there is nobody to give it to. pub fn send_video_frame(&self, item: MediaItem) { + if self.pending_video.is_some() { + return; + } if let Err(e) = self.send_video.send(item) { debug!("failed to send video data: {e}"); } } + /// Hand one audio packet to this client's writer, once it has one. See + /// [`send_video_frame`](Self::send_video_frame). pub fn send_audio_packet(&self, data: Bytes) { + if self.pending_audio.is_some() { + return; + } if let Err(e) = self.send_audio.send(MediaItem::Coded(data)) { debug!("failed to send audio data: {e}"); } @@ -369,6 +472,60 @@ impl ClientSession { debug!("failed to send stats data: {e}"); } } + + /// Which window this client is shown. + pub fn subscription(&self) -> Subscription { + self.subscription + .lock() + .map(|s| *s) + .unwrap_or(Subscription::Legacy) + } + + /// Show this client another window, and return what it was shown before. + /// + /// In the order the protocol asks for: the switch first, so the next frame + /// routed to this client is the new window's, then the writer's numbering + /// and resynchronisation reset in line with the frames, then a keyframe + /// requested, since nothing the client holds predicts the new window. A + /// keyframe requested before the switch could arrive for the old one. + pub fn subscribe( + &self, + to: Subscription, + encoder: Option, + idr_cmd_tx: &tokio::sync::mpsc::UnboundedSender>, + ) -> Subscription { + let before = match self.subscription.lock() { + Ok(mut s) => std::mem::replace(&mut *s, to), + Err(_) => return Subscription::Legacy, + }; + if before == to { + return before; + } + self.restart_picture(); + self.encoder_pid.store(encoder.unwrap_or(0), Ordering::Relaxed); + if matches!(to, Subscription::Window(_)) { + let _ = idr_cmd_tx.send(keyframe_request(encoder)); + } + before + } + + /// What this client is shown is now another encoder's picture: number from + /// zero, hold deltas until a keyframe, and forget what it reported about + /// the old one. The caller asks for the keyframe. + pub fn restart_picture(&self) { + self.awaiting_keyframe.store(true, Ordering::Relaxed); + if self.pending_video.is_none() { + let _ = self.send_video.send(MediaItem::Resubscribe); + } + let _ = self.take_report(); + } + + /// Queue one lifecycle message, `[type][payload]`, for the windows stream. + pub fn send_window_message(&self, message: Vec) { + if let Err(e) = self.send_windows.send(message) { + debug!("failed to queue a window event: {e}"); + } + } } #[allow(clippy::too_many_arguments)] @@ -490,8 +647,8 @@ fn split_input_events(payload: &[u8]) -> Vec> { let len = match payload[offset] { // [type][up/down][keycode u16 LE] INPUT_KEY => 4, - // [type][dx i16 LE][dy i16 LE] - INPUT_MOUSE_MOVE | INPUT_MOUSE_WHEEL => 5, + // [type][dx i16 LE][dy i16 LE], and [type][w u16 LE][h u16 LE] + INPUT_MOUSE_MOVE | INPUT_MOUSE_WHEEL | INPUT_OUTPUT_SIZE => 5, // [type][button][up/down] INPUT_MOUSE_BUTTON => 3, other => { @@ -508,6 +665,20 @@ fn split_input_events(payload: &[u8]) -> Vec> { out } +/// How often a client's gamepad may ask for its window's focus. Only a bound +/// on the asking: a request for the window that already has focus changes +/// nothing. +const GAMEPAD_FOCUS_ASK: std::time::Duration = std::time::Duration::from_millis(100); + +/// The encoder a client's keyframe requests are for, or every encoder when +/// it has not been sent anyone's frames yet. +fn pid_or_all(pid: &AtomicU32) -> Option { + match pid.load(Ordering::Relaxed) { + 0 => None, + pid => Some(pid), + } +} + /// Where one client's gamepad messages go. struct Gamepads { session: u32, @@ -521,25 +692,50 @@ struct Gamepads { /// controller is and what to make of it is the box's side to decide, not the /// hub's. The stream's return half carries that side's feedback -- rumble -- /// back. +/// +/// Each event goes to the box with the window this client is shown, which is +/// where the box's focus tags come from, ref(d-0085). A gamepad message also +/// asks for that window's focus: the pads reach the game by another route, +/// and a game reads them only while it has focus. Asked at most every +/// [`GAMEPAD_FOCUS_ASK`]; whether it is granted is the tagger's decision. async fn run_input_reader( conn: Connection, - input_broadcast: tokio::sync::broadcast::Sender>, + input_broadcast: tokio::sync::broadcast::Sender, + window: InputWindow, gamepads: Gamepads, feedback: tokio::sync::mpsc::UnboundedReceiver>, ) { let outbound = Some((MSG_GAMEPAD_FEEDBACK, feedback)); + let mut focus_asked: Option = None; run_framed_reader(conn, BIDI_INPUT, "input", outbound, |msg_type, payload| { let input_broadcast = input_broadcast.clone(); let to_box = gamepads.to_box.clone(); let session = gamepads.session; + let target = window.current(); + let mut ask_focus = None; + if msg_type == MSG_GAMEPAD + && let Some(w) = target + { + let now = std::time::Instant::now(); + if focus_asked.is_none_or(|at| now.duration_since(at) >= GAMEPAD_FOCUS_ASK) { + focus_asked = Some(now); + ask_focus = Some(w); + } + } async move { match msg_type { MSG_INPUT_BATCH => { + // Nothing to deliver to: the window this client was shown + // went with its compositor. + let Some(w) = target else { return }; for event in split_input_events(&payload) { - let _ = input_broadcast.send(event); + let _ = input_broadcast.send((session, w, event)); } } MSG_GAMEPAD => { + if let Some(w) = ask_focus { + let _ = input_broadcast.send((session, w, Vec::new())); + } let mut frame = Vec::with_capacity(6 + payload.len()); nesprotocol::gamepad::encode_ipc(&mut frame, session, &payload); // An error is nobody listening: the box's gamepad side is @@ -600,6 +796,7 @@ struct CapsSink { } /// Everything the client says that is not an input event. +#[allow(clippy::too_many_arguments)] async fn run_control_reader( conn: Connection, idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, @@ -608,6 +805,13 @@ async fn run_control_reader( awaiting_keyframe: Arc, pyrowave: PyroWaveGate, caps_sink: CapsSink, + // Who this is, where subscribe requests go, and the encoder this + // client's keyframe requests are for. + subscribe: ( + iroh::EndpointId, + tokio::sync::mpsc::UnboundedSender<(iroh::EndpointId, WindowId)>, + Arc, + ), ) { run_framed_reader(conn, BIDI_CONTROL, "control", None, |msg_type, payload| { let idr_cmd_tx = idr_cmd_tx.clone(); @@ -616,8 +820,16 @@ async fn run_control_reader( let awaiting_keyframe = awaiting_keyframe.clone(); let pyrowave = pyrowave.clone(); let caps_sink = caps_sink.clone(); + let subscribe = subscribe.clone(); async move { match msg_type { + MSG_WINDOW_SUBSCRIBE => match nesprotocol::window::decode_subscribe(&payload) { + Some(window) => { + info!("client subscribes to window {window}"); + let _ = subscribe.1.send((subscribe.0, window)); + } + None => debug!("unreadable window subscribe ({} bytes)", payload.len()), + }, MSG_IDR_REQUEST => { // Client-side rate limited to one every two seconds, which // is still far too often for info when a struggling @@ -627,7 +839,9 @@ async fn run_control_reader( // undecodable and starves the keyframe that would fix it. // See `dgram::ResyncGate`. awaiting_keyframe.store(true, Ordering::Relaxed); - let _ = idr_cmd_tx.send(vec![MSG_IDR_REQUEST]); + // To the encoder of the window this client is shown, + // ref(d-0085): the other windows are fine. + let _ = idr_cmd_tx.send(keyframe_request(pid_or_all(&subscribe.2))); } MSG_CLIENT_CAPS => { // Passed through untouched, and in particular *without* @@ -738,6 +952,45 @@ async fn run_control_reader( .await; } +/// The windows stream, ref(d-0085): `[STREAM_WINDOWS][STREAM_VERSION]` then +/// one frame per lifecycle event. Opened on the first event, so a client that +/// never subscribes never sees it. +async fn run_windows_sender( + conn: Connection, + mut rx: tokio::sync::mpsc::UnboundedReceiver>, +) { + let Some(first) = rx.recv().await else { + return; + }; + let mut send = match conn.open_uni().await { + Ok(s) => s, + Err(e) => { + debug!("windows open_uni failed: {e}"); + return; + } + }; + if send + .write_all(&[STREAM_WINDOWS, STREAM_VERSION]) + .await + .is_err() + { + return; + } + let mut buf = Vec::new(); + let mut next = Some(first); + while let Some(message) = next { + if let Some((&msg_type, payload)) = message.split_first() { + buf.clear(); + encode_frame(&mut buf, msg_type, 0, payload); + if send.write_all(&buf).await.is_err() { + return; + } + } + next = rx.recv().await; + } + let _ = send.finish(); +} + async fn run_cursor_sender( conn: Connection, mut rx: tokio::sync::mpsc::UnboundedReceiver>, @@ -867,6 +1120,37 @@ async fn run_stats_sender(conn: Connection, mut rx: tokio::sync::mpsc::Unbounded debug!("stats sender exiting"); } +/// Read a client's per-frame feedback and decide the rate on each frame. +/// +/// Acted on here, as it arrives, rather than collected for a once-a-second +/// tick: a queue that starts building is answered a frame interval later, not +/// up to a second later, which at 60 frames a second is the difference +/// between one frame's queue and sixty. +async fn run_feedback_reader( + conn: Connection, + client: iroh::EndpointId, + controller: Arc>, + cmd_tx: tokio::sync::mpsc::UnboundedSender>, +) { + loop { + let datagram = match conn.read_datagram().await { + Ok(d) => d, + Err(e) => { + debug!("feedback reader ended: {e}"); + return; + } + }; + let Some(frame) = nesprotocol::datagram::decode_feedback(&datagram) else { + continue; + }; + let now_us = nesprotocol::clock::monotonic_us(); + let mut controller = controller.lock().await; + if let Some(kbps) = controller.on_feedback(client, &frame, now_us) { + crate::control::actuate(&cmd_tx, &controller, kbps); + } + } +} + /// Whether a broadcast video payload is a keyframe. /// /// The payload is `[1B codec][1B flags][4B ts][2B w][2B h][data]`, the same @@ -899,12 +1183,11 @@ pub struct SessionManager { last_keyframes: AtomicU64, /// How much this box's own pipeline delay varied, frame to frame. /// - /// **Variation, not absolute delay**, and it cannot be otherwise: the - /// encoder's `timestamp_ms` counts from its own start rather than from any - /// epoch, so the difference to wall clock holds an unknown constant even - /// though both run on this machine. Variation is also the comparable - /// quantity -- the client measures the same thing about the total, using the - /// same code, so the difference between the two is what the network added. + /// Variation above the pipeline's own best, measured with the same code the + /// client measures the whole path with, so the two are comparable and the + /// difference between them is what the network added. The encoder stamps + /// capture on the clock this hub reads at hand-off, so there is no offset + /// to remove here and no skew either. pipeline: std::sync::Mutex, audio_bytes: AtomicU64, last_audio_bytes: AtomicU64, @@ -913,12 +1196,34 @@ pub struct SessionManager { pyrowave_kbps: u32, /// What every connected client decodes, and so what the encoder may pick. negotiator: Arc, + /// The box's windows and which encoder draws which, ref(d-0085). + windows: std::sync::Mutex, + /// The window of the primary picture, for input from clients that never + /// subscribed. + primary_window: Arc, + /// The window the cursor updates describe, as nescope last said. + cursor_window: AtomicU32, + subscribe_tx: tokio::sync::mpsc::UnboundedSender<(iroh::EndpointId, WindowId)>, + subscribe_rx: + std::sync::Mutex>>, + /// The encoder of the primary picture (0: untagged or none yet). + primary_pid: Arc, + /// Where commands for the encoders go; set by `serve_subscriptions`. + cmd_tx: std::sync::OnceLock>>, } impl SessionManager { pub fn new(pyrowave_kbps: u32) -> Self { let (gamepad_tx, _) = tokio::sync::broadcast::channel(256); + let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel(); Self { + windows: std::sync::Mutex::new(WindowTable::default()), + primary_window: Arc::new(AtomicU32::new(0)), + cursor_window: AtomicU32::new(0), + subscribe_tx, + subscribe_rx: std::sync::Mutex::new(Some(subscribe_rx)), + primary_pid: Arc::new(AtomicU32::new(0)), + cmd_tx: std::sync::OnceLock::new(), sessions: Arc::new(Mutex::new(HashMap::new())), gamepad_tx, next_gamepad_session: AtomicU32::new(1), @@ -948,7 +1253,7 @@ impl SessionManager { id: iroh::EndpointId, carrier: Carrier, conn: Connection, - input_broadcast: tokio::sync::broadcast::Sender>, + input_broadcast: tokio::sync::broadcast::Sender, idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, controller: Arc>, ) { @@ -957,6 +1262,9 @@ impl SessionManager { let session = sessions.entry(id).or_insert_with(|| { ClientSession::new( input_broadcast, + self.primary_window.clone(), + self.primary_pid.load(Ordering::Relaxed), + self.subscribe_tx.clone(), self.next_gamepad_session.fetch_add(1, Ordering::Relaxed), self.gamepad_tx.clone(), self.relay_ms.clone(), @@ -1044,25 +1352,19 @@ impl SessionManager { /// Note how long this box took over a frame, relative to its own best. /// - /// `ts_ms` is stamped at capture by an encoder on this same machine, so the - /// clock is shared even though its origin is arbitrary -- which is exactly - /// what [`DelayTracker`] is built for. + /// The frame's capture time is stamped by an encoder on this same machine, + /// on the clock read here at hand-off (`nesprotocol::clock`). /// /// [`DelayTracker`]: nesprotocol::delay::DelayTracker fn note_pipeline_delay(&self, payload: &[u8]) { - let Some(ts_bytes) = payload.get(2..6) else { - return; - }; - self.note_pipeline_ts(u32::from_le_bytes(ts_bytes.try_into().unwrap())); + if let Some((head, _)) = nesprotocol::decode_video_payload(payload) { + self.note_pipeline_ts(head.captured_us); + } } - fn note_pipeline_ts(&self, ts_ms: u32) { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); + fn note_pipeline_ts(&self, captured_us: u64) { if let Ok(mut pipeline) = self.pipeline.lock() { - pipeline.observe(ts_ms, now_ms, std::time::Instant::now()); + pipeline.observe(captured_us, nesprotocol::clock::monotonic_us()); } } @@ -1073,16 +1375,21 @@ impl SessionManager { /// same as a second with no variation, but the wire has no room to say so /// and the client can tell from the frame counters beside it. pub fn pipeline_delays(&self) -> (u16, u16, u16) { + // The stats stream carries whole milliseconds; the measurement is finer. + fn ms(us: u32) -> u16 { + (us / 1000).min(u32::from(u16::MAX)) as u16 + } let Ok(mut pipeline) = self.pipeline.lock() else { return (0, 0, 0); }; pipeline .take() - .map(|s| (s.p50_ms, s.p95_ms, s.max_ms)) + .map(|s| (ms(s.p50_us), ms(s.p95_us), ms(s.max_us))) .unwrap_or((0, 0, 0)) } - pub async fn broadcast_video(&self, data: Vec) { + /// Hand a frame from `source` to every client shown its window. + pub async fn broadcast_video(&self, data: Vec, source: Source) { self.note_pipeline_delay(&data); // Counted before the early return, like audio, so the figure measures // what the encoder produced rather than what a client happened to be @@ -1095,10 +1402,202 @@ impl SessionManager { self.video_delta_bytes .fetch_add(data.len() as u64, Ordering::Relaxed); } - let data = MediaItem::Coded(Bytes::from(data)); + let (window, note) = { + let mut windows = self.windows.lock().unwrap_or_else(|e| e.into_inner()); + let window = windows.resolve(source); + if let (Some(w), Some(pid)) = (window, source) { + windows.note_drawn(w, pid); + } + let note = windows.note_primary(source, std::time::Instant::now()); + if note.primary { + self.primary_window.store( + u32::from(window.unwrap_or(WINDOW_DEFAULT)), + Ordering::Relaxed, + ); + self.primary_pid.store(source.unwrap_or(0), Ordering::Relaxed); + } + (window, note) + }; + let data = Bytes::from(data); + let sessions = self.sessions.lock().await; + if note.switched { + // Clients that never subscribed are now shown another encoder: + // nothing they hold predicts it, ref(d-0085). + info!("primary picture is now encoder {source:?}"); + let mut any = false; + for session in sessions.values() { + if session.subscription() == Subscription::Legacy { + session.restart_picture(); + session + .encoder_pid + .store(source.unwrap_or(0), Ordering::Relaxed); + any = true; + } + } + if any && let Some(tx) = self.cmd_tx.get() { + let _ = tx.send(keyframe_request(source)); + } + } + for session in sessions.values() { + match route(session.subscription(), window, note.primary) { + Some(WINDOW_DEFAULT) => session.send_video_frame(MediaItem::Coded(data.clone())), + Some(w) => session.send_video_frame(MediaItem::Windowed(w, data.clone())), + None => {} + } + } + } + + /// Encoder `pid` said which launch it draws for, ref(d-0085). + pub fn note_encoder_launch(&self, pid: u32, launch: String) { + self.windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .note_launch(pid, launch); + } + + /// One message from nescope on the input socket that is not stats: + /// cursor updates and the box's windows, in the order nescope sent them. + /// An empty one means nescope has (re)connected. + pub async fn on_nescope(&self, message: Vec) { + use nesprotocol::window::{MSG_CURSOR_WINDOW, MSG_WINDOW_PID, decode_window_pid}; + let Some(&kind) = message.first() else { + self.compositor_restarted().await; + return; + }; + match kind { + nesprotocol::input::CURSOR_UPDATE | nesprotocol::input::CURSOR_IMAGE => { + self.broadcast_cursor(message).await + } + MSG_CURSOR_WINDOW => match nesprotocol::window::decode_subscribe(&message[1..]) { + Some(w) => self.cursor_window.store(u32::from(w), Ordering::Relaxed), + None => debug!("unreadable cursor window"), + }, + MSG_WINDOW_PID => match decode_window_pid(&message[1..]) { + Some((window, pid)) => { + debug!("window {window} is process {pid}"); + self.windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .bind_pid(window, pid); + } + None => debug!("unreadable window pid"), + }, + _ => match WindowEvent::decode(kind, &message[1..]) { + Some(event) => self.window_event(event, message).await, + None => debug!("unknown message {kind:#04x} from nescope"), + }, + } + } + + /// Record a lifecycle event and pass it to every client with a windows + /// stream. + async fn window_event(&self, event: WindowEvent, message: Vec) { + match &event { + WindowEvent::Opened { window, launch_id, title, .. } => { + info!("window {window} opened: launch {launch_id:?}, {title:?}") + } + WindowEvent::Closed { window } => info!("window {window} closed"), + _ => {} + } + self.windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .apply(&event); let sessions = self.sessions.lock().await; for session in sessions.values() { - session.send_video_frame(data.clone()); + if session.subscription() != Subscription::Legacy { + session.send_window_message(message.clone()); + } + } + } + + /// nescope connected again, so the box's compositor is a new one and every + /// window id before now means nothing: say each window closed, and end + /// every subscription to one. + async fn compositor_restarted(&self) { + let closed = self + .windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .reset(); + self.cursor_window.store(0, Ordering::Relaxed); + if closed.is_empty() { + return; + } + info!("compositor restarted; {} window(s) gone", closed.len()); + let sessions = self.sessions.lock().await; + for session in sessions.values() { + let sub = session.subscription(); + if sub == Subscription::Legacy { + continue; + } + for window in &closed { + let mut m = Vec::new(); + nesprotocol::window::encode_event_message( + &mut m, + &WindowEvent::Closed { window: *window }, + ); + session.send_window_message(m); + } + if let Subscription::Window(_) = sub + && let Ok(mut s) = session.subscription.lock() + { + *s = Subscription::Torn; + } + } + } + + /// A client asked to be shown `window`. + /// + /// The first subscription opens its windows stream, starting with every + /// window already open so a late client learns the box as it stands. + pub async fn subscribe( + &self, + client: iroh::EndpointId, + window: WindowId, + idr_cmd_tx: &tokio::sync::mpsc::UnboundedSender>, + ) { + let snapshot = self + .windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .snapshot(); + let sessions = self.sessions.lock().await; + let Some(session) = sessions.get(&client) else { + return; + }; + let encoder = self + .windows + .lock() + .unwrap_or_else(|e| e.into_inner()) + .encoder_of(window); + let before = session.subscribe(Subscription::Window(window), encoder, idr_cmd_tx); + if before == Subscription::Legacy { + for event in snapshot { + let mut m = Vec::new(); + nesprotocol::window::encode_event_message(&mut m, &event); + session.send_window_message(m); + } + } + } + + /// Serve subscribe requests from every client's control reader. Run once, + /// on a task of its own. + pub async fn serve_subscriptions( + self: Arc, + idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, + ) { + let _ = self.cmd_tx.set(idr_cmd_tx.clone()); + let rx = self + .subscribe_rx + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + let Some(mut rx) = rx else { + return; + }; + while let Some((client, window)) = rx.recv().await { + self.subscribe(client, window, &idr_cmd_tx).await; } } @@ -1108,13 +1607,19 @@ impl SessionManager { /// counting them as keyframes would report sixty keyframes a second, which /// describes nothing. pub async fn broadcast_pyrowave(&self, frame: PyroFrame) { - self.note_pipeline_ts(frame.meta.ts_ms); + self.note_pipeline_ts(frame.meta.captured_us); self.video_delta_bytes .fetch_add(frame.data.len() as u64, Ordering::Relaxed); let item = MediaItem::PyroWave(Arc::new(frame)); let sessions = self.sessions.lock().await; + // PyroWave has no windowed form: it is window 0's, ref(d-0085). for session in sessions.values() { - session.send_video_frame(item.clone()); + if matches!( + session.subscription(), + Subscription::Legacy | Subscription::Window(WINDOW_DEFAULT) + ) { + session.send_video_frame(item.clone()); + } } } @@ -1156,8 +1661,12 @@ impl SessionManager { if sessions.is_empty() { return; } + // To the clients shown the window the cursor describes, ref(d-0085). + let window = self.cursor_window.load(Ordering::Relaxed) as WindowId; for session in sessions.values() { - session.send_cursor_data(data.clone()); + if wants_cursor(session.subscription(), window) { + session.send_cursor_data(data.clone()); + } } } @@ -1246,6 +1755,18 @@ impl SessionManager { .count() } + /// Which clients have somewhere to send a picture: the paths the rate + /// controller has to answer to. + pub async fn streaming_clients(&self) -> Vec { + self.sessions + .lock() + .await + .iter() + .filter(|(_, s)| s.is_streaming()) + .map(|(id, _)| *id) + .collect() + } + /// Opus actually received from neswire since the last call, in kbps. /// /// Measured, not configured. The reported figure used to be @@ -1289,14 +1810,18 @@ impl SessionManager { mod pipeline_delay_tests { use super::SessionManager; - /// A video payload with `ts_ms` where the wire format puts it. - fn payload(ts_ms: u32) -> Vec { - let mut p = vec![0u8, 0u8]; - p.extend_from_slice(&ts_ms.to_le_bytes()); - p.extend_from_slice(&1920u16.to_le_bytes()); - p.extend_from_slice(&1080u16.to_le_bytes()); - p.extend_from_slice(&[0u8; 32]); - p + /// A video payload captured `ago_us` before now, on the clock the hub reads. + fn payload(ago_us: u64) -> Vec { + nesprotocol::encode_video_payload( + &nesprotocol::VideoHead { + codec: 0, + flags: 0, + captured_us: nesprotocol::clock::monotonic_us() - ago_us, + width: 1920, + height: 1080, + }, + &[0u8; 32], + ) } #[test] @@ -1307,14 +1832,11 @@ mod pipeline_delay_tests { #[test] fn a_steady_pipeline_reports_no_variation() { - // The encoder's timestamp counts from its own start, so the difference - // to wall clock holds a large unknown constant. A pipeline taking the - // same time on every frame must read as zero, not as that constant -- - // which is what a plain subtraction would have reported, pinned at the - // maximum the wire can carry. + // A pipeline taking the same time on every frame reads as no + // variation, however long that time is. let mgr = SessionManager::new(0); - for i in 0..60u32 { - mgr.note_pipeline_delay(&payload(i * 16)); + for _ in 0..60 { + mgr.note_pipeline_delay(&payload(40_000)); } let (p50, _, max) = mgr.pipeline_delays(); assert_eq!(p50, 0); diff --git a/apps/neshub/src/windows.rs b/apps/neshub/src/windows.rs new file mode 100644 index 00000000..f71a862c --- /dev/null +++ b/apps/neshub/src/windows.rs @@ -0,0 +1,651 @@ +//! Which window a frame, a cursor update or an input event belongs to, +//! ref(d-0085). +//! +//! The box tells the hub about windows on the input socket: nescope mints the +//! ids and says which process owns each one. Encoders tag their frames with +//! their process id. This module joins the two, decides which connection is +//! shown what, and tags input on its way back to the box. +//! +//! The compatibility rule everything here follows: a connection that never +//! subscribes is shown what a client saw before windows existed, in the old +//! encoding. That is the box's *primary* picture: one encoder's frames, +//! replaced only after it has gone silent, by an encoder that has been drawing +//! for a while, with a keyframe and a fresh sequence on every switch. With one +//! game in the box it is that game, whatever window id nescope gave it, and +//! the bytes are the ones the client always received. + +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use nesprotocol::input::{INPUT_OUTPUT_SIZE, encode_window_focus, encode_window_output_size}; +use nesprotocol::window::{WINDOW_DEFAULT, WindowEvent, WindowId}; + +/// Who drew a frame: an encoder's process id, or `None` for an encoder that +/// predates tagging, which is the box's first window. +pub type Source = Option; + +/// How long the primary encoder may go without a frame before another one +/// takes its place for connections that never subscribed. +pub const PRIMARY_SILENCE: Duration = Duration::from_secs(1); + +/// How long an encoder must have been drawing, without a gap of +/// [`PRIMARY_SILENCE`], before it may become the primary picture. A game that +/// draws one frame on its way out does not take the picture. +pub const PRIMARY_SETTLE: Duration = Duration::from_millis(250); + +/// One encoder's recent drawing, for choosing the primary picture. +#[derive(Debug, Clone, Copy)] +struct Drawing { + /// When its current run of frames began. + since: Instant, + last: Instant, + /// The order encoders were first heard from: higher is newer. + order: u64, +} + +/// What [`WindowTable::note_primary`] decided about one frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrimaryNote { + /// The frame is the primary picture. + pub primary: bool, + /// The primary picture just moved to this frame's encoder: its watchers + /// need a keyframe and a fresh sequence. + pub switched: bool, +} + +/// What a connection is shown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Subscription { + /// Never subscribed: the box's primary picture, encoded as window `0`. + Legacy, + /// One window, by id. + Window(WindowId), + /// Subscribed to a window of a compositor that has since gone. Its ids + /// mean nothing now, so nothing is shown until the client subscribes again. + Torn, +} + +/// The window a frame is sent as to a connection with `sub`, or `None` if +/// that connection is not shown it. `window` is the frame's window, if known; +/// `primary` is whether it is the primary picture. +pub fn route(sub: Subscription, window: Option, primary: bool) -> Option { + match sub { + Subscription::Legacy => primary.then_some(WINDOW_DEFAULT), + Subscription::Window(w) => (window == Some(w)).then_some(w), + Subscription::Torn => None, + } +} + +/// Whether a connection with `sub` is sent a cursor update describing +/// `cursor_window`. A connection that never subscribed gets every update, as +/// it always did. +pub fn wants_cursor(sub: Subscription, cursor_window: WindowId) -> bool { + match sub { + Subscription::Legacy => true, + Subscription::Window(w) => w == cursor_window, + Subscription::Torn => false, + } +} + +/// A window as it stands, enough to tell a late subscriber about it. +#[derive(Debug, Clone)] +struct OpenWindow { + opened: WindowEvent, + icon: Option, +} + +/// The box's windows, their owners, and which encoder is primary. +#[derive(Debug, Default)] +pub struct WindowTable { + open: BTreeMap, + pids: HashMap, + /// Processes whose window has closed. Their last frames belong to nobody, + /// not to whichever window is left. + retired: std::collections::HashSet, + /// The launch each encoder said it draws for, ref(d-0085). The encoder + /// says so itself: the hub runs as another user and cannot read a game's + /// environment. + launch_of: HashMap, + /// The encoder that last drew each window, so a keyframe request can go to + /// that encoder alone. + drawn_by: HashMap, + drawing: HashMap, + next_order: u64, + primary: Option, +} + +impl WindowTable { + /// Take in a lifecycle event from the box. + pub fn apply(&mut self, event: &WindowEvent) { + match event { + WindowEvent::Opened { window, .. } => { + self.open.insert( + *window, + OpenWindow { + opened: event.clone(), + icon: None, + }, + ); + } + WindowEvent::Title { window, title } => { + if let Some(WindowEvent::Opened { title: t, .. }) = + self.open.get_mut(window).map(|w| &mut w.opened) + { + *t = title.clone(); + } + } + WindowEvent::Size { + window, + width, + height, + } => { + if let Some(WindowEvent::Opened { + width: w, + height: h, + .. + }) = self.open.get_mut(window).map(|w| &mut w.opened) + { + (*w, *h) = (*width, *height); + } + } + WindowEvent::Icon { window, .. } => { + if let Some(open) = self.open.get_mut(window) { + open.icon = Some(event.clone()); + } + } + WindowEvent::Closed { window } => { + self.open.remove(window); + let retired = &mut self.retired; + self.pids.retain(|pid, w| { + let keep = w != window; + if !keep { + retired.insert(*pid); + } + keep + }); + } + } + } + + /// Window `window` is owned by process `pid`. + pub fn bind_pid(&mut self, window: WindowId, pid: u32) { + self.pids.insert(pid, window); + self.retired.remove(&pid); + } + + /// Encoder `pid` draws for launch `launch` (empty: it did not say). + pub fn note_launch(&mut self, pid: u32, launch: String) { + if launch.is_empty() { + self.launch_of.remove(&pid); + } else { + self.launch_of.insert(pid, launch); + } + } + + /// Encoder `pid` drew a frame of `window`. + pub fn note_drawn(&mut self, window: WindowId, pid: u32) { + self.drawn_by.insert(window, pid); + } + + /// The encoder that last drew `window`. + pub fn encoder_of(&self, window: WindowId) -> Option { + self.drawn_by.get(&window).copied() + } + + /// The encoder of the primary picture, when it is a tagged one. + pub fn primary_pid(&self) -> Option { + self.primary.flatten() + } + + /// Every open window, as the events that tell a late subscriber about it. + pub fn snapshot(&self) -> Vec { + let mut out = Vec::new(); + for open in self.open.values() { + out.push(open.opened.clone()); + out.extend(open.icon.clone()); + } + out + } + + /// The compositor went: forget everything, and say which windows closed. + pub fn reset(&mut self) -> Vec { + let closed = self.open.keys().copied().collect(); + *self = Self { + primary: self.primary, + drawing: std::mem::take(&mut self.drawing), + next_order: self.next_order, + launch_of: std::mem::take(&mut self.launch_of), + ..Self::default() + }; + closed + } + + /// Which window a frame from `source` is. + /// + /// An untagged encoder is window `0`. A tagged one is the window its + /// process owns; failing that, the newest window of the launch the process + /// belongs to (a renderer process drawing for a window another process of + /// the same launch owns); failing that, window `0` while the box has no + /// other window. Otherwise nobody can say, and the frame is shown only as + /// the primary picture, if it is that. + pub fn resolve(&mut self, source: Source) -> Option { + let Some(pid) = source else { + return Some(WINDOW_DEFAULT); + }; + if let Some(window) = self.pids.get(&pid) { + return Some(*window); + } + if self.retired.contains(&pid) { + return None; + } + if let Some(launch) = self.launch_of.get(&pid) { + let newest = self.open.iter().rev().find_map(|(id, w)| match &w.opened { + WindowEvent::Opened { launch_id, .. } if launch_id == launch => Some(*id), + _ => None, + }); + if newest.is_some() { + return newest; + } + } + self.open + .keys() + .all(|w| *w == WINDOW_DEFAULT) + .then_some(WINDOW_DEFAULT) + } + + /// Note a frame from `source` and decide whether it is the primary + /// picture. + /// + /// The first encoder to draw is primary, and stays so while it keeps + /// drawing. Only once it has been silent for [`PRIMARY_SILENCE`] may + /// another take over, and only one that has drawn for [`PRIMARY_SETTLE`]; + /// of those, the newest encoder, which is the most recent launch. The + /// first choice is not a switch: nobody has been shown anything yet. + pub fn note_primary(&mut self, source: Source, now: Instant) -> PrimaryNote { + let order = &mut self.next_order; + let d = self.drawing.entry(source).or_insert_with(|| { + *order += 1; + Drawing { + since: now, + last: now, + order: *order, + } + }); + if now.duration_since(d.last) >= PRIMARY_SILENCE { + d.since = now; + } + d.last = now; + + let not = PrimaryNote { + primary: false, + switched: false, + }; + let Some(current) = self.primary else { + self.primary = Some(source); + return PrimaryNote { + primary: true, + switched: false, + }; + }; + if current == source { + return PrimaryNote { + primary: true, + switched: false, + }; + } + let current_silent = self + .drawing + .get(¤t) + .is_none_or(|c| now.duration_since(c.last) >= PRIMARY_SILENCE); + if !current_silent { + return not; + } + let best = self + .drawing + .iter() + .filter(|(_, d)| { + now.duration_since(d.last) < PRIMARY_SILENCE + && now.duration_since(d.since) >= PRIMARY_SETTLE + }) + .max_by_key(|(_, d)| d.order) + .map(|(s, _)| *s); + if best != Some(source) { + return not; + } + self.primary = Some(source); + PrimaryNote { + primary: true, + switched: true, + } + } +} + +/// Tags input on its way to the box with the window it belongs to. +/// +/// One per connection to nescope, starting at window `0`, which is what +/// nescope assumes on every new connection. A focus tag goes out only when the +/// window changes, so a box where every event is window `0`'s sees exactly the +/// bytes it always did. +#[derive(Debug, Default)] +pub struct FocusTagger { + last: WindowId, + /// Who last sent a keyboard or pointer event, and when. + typing: Option<(u32, Instant)>, +} + +/// How long a client's keyboard or pointer input keeps another client's +/// gamepad from taking focus. +pub const TYPING_HOLDS_FOCUS: Duration = Duration::from_secs(1); + +impl FocusTagger { + /// The messages to write for one input event from `client`, for + /// `window`. An empty event asks only for focus: a gamepad message, whose + /// pads reach the game by another route and are read only by the focused + /// window. It moves focus only to a different window, and not while + /// another client has typed or pointed in the last [`TYPING_HOLDS_FOCUS`]. + pub fn messages( + &mut self, + client: u32, + window: WindowId, + event: &[u8], + now: Instant, + ) -> Vec> { + let mut out = Vec::with_capacity(2); + if event.first() == Some(&INPUT_OUTPUT_SIZE) && event.len() >= 5 { + // Carries its window itself and moves no focus. Window 0 keeps + // the old form, which a compositor that predates windows reads. + if window == WINDOW_DEFAULT { + out.push(event.to_vec()); + } else { + let width = u16::from_le_bytes([event[1], event[2]]); + let height = u16::from_le_bytes([event[3], event[4]]); + let mut m = Vec::with_capacity(7); + encode_window_output_size(&mut m, window, width, height); + out.push(m); + } + return out; + } + if event.is_empty() { + let held = self.typing.is_some_and(|(who, at)| { + who != client && now.duration_since(at) < TYPING_HOLDS_FOCUS + }); + if held { + return out; + } + } else { + self.typing = Some((client, now)); + } + if window != self.last { + let mut tag = Vec::with_capacity(3); + encode_window_focus(&mut tag, window); + out.push(tag); + self.last = window; + } + if !event.is_empty() { + out.push(event.to_vec()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nesprotocol::input::{ + DecodedInput, decode_input_event, encode_key_event, encode_mouse_move, encode_output_size, + }; + + fn opened(window: WindowId, launch: &str) -> WindowEvent { + WindowEvent::Opened { + window, + width: 1920, + height: 1080, + launch_id: launch.into(), + title: format!("w{window}"), + app_id: String::new(), + } + } + + + #[test] + fn an_untagged_encoder_is_window_zero() { + let mut t = WindowTable::default(); + assert_eq!(t.resolve(None), Some(0)); + t.apply(&opened(0, "a")); + t.apply(&opened(1, "b")); + assert_eq!(t.resolve(None), Some(0)); + } + + #[test] + fn a_tagged_encoder_is_the_window_its_process_owns() { + let mut t = WindowTable::default(); + t.apply(&opened(0, "a")); + t.apply(&opened(1, "b")); + t.bind_pid(0, 100); + t.bind_pid(1, 200); + assert_eq!(t.resolve(Some(100)), Some(0)); + assert_eq!(t.resolve(Some(200)), Some(1)); + t.apply(&WindowEvent::Closed { window: 1 }); + // Its window gone: its last frames are nobody's, even with only + // window 0 left. + assert_eq!(t.resolve(Some(200)), None); + } + + #[test] + fn an_unknown_process_is_found_by_its_launch() { + let mut t = WindowTable::default(); + t.apply(&opened(0, "steam")); + t.apply(&opened(1, "launch_b")); + t.apply(&opened(2, "launch_b")); + t.note_launch(300, "launch_b".into()); + assert_eq!(t.resolve(Some(300)), Some(2), "the newest of its launch"); + assert_eq!(t.resolve(Some(301)), None); + } + + #[test] + fn with_one_window_any_encoder_is_window_zero() { + // One game, nescope that predates pid messages: as it always was. + let mut t = WindowTable::default(); + assert_eq!(t.resolve(Some(4242)), Some(0)); + t.apply(&opened(0, "")); + assert_eq!(t.resolve(Some(4242)), Some(0)); + } + + fn ms(t0: Instant, ms: u64) -> Instant { + t0 + Duration::from_millis(ms) + } + + #[test] + fn the_first_encoder_to_draw_is_primary_until_it_stops() { + let mut t = WindowTable::default(); + let t0 = Instant::now(); + let yes = PrimaryNote { primary: true, switched: false }; + let no = PrimaryNote { primary: false, switched: false }; + assert_eq!(t.note_primary(Some(1), t0), yes, "the first is no switch"); + assert_eq!(t.note_primary(Some(2), ms(t0, 10)), no); + assert_eq!(t.note_primary(Some(1), ms(t0, 500)), yes); + assert_eq!(t.note_primary(Some(2), ms(t0, 1400)), no, "1 is not silent yet"); + // 1 silent, but 2 only began this run at 1400: not settled. + assert_eq!(t.note_primary(Some(2), ms(t0, 1600)), no); + assert_eq!( + t.note_primary(Some(2), ms(t0, 1700)), + PrimaryNote { primary: true, switched: true } + ); + assert_eq!(t.note_primary(Some(1), ms(t0, 1710)), no); + assert_eq!(t.primary_pid(), Some(2)); + } + + #[test] + fn a_fresh_encoder_waits_to_settle_and_the_newest_wins() { + let mut t = WindowTable::default(); + let t0 = Instant::now(); + t.note_primary(Some(1), t0); + // 2 and 3 draw in turn from 100 ms; 1 has stopped. + for at in (100..1000).step_by(20) { + assert!(!t.note_primary(Some(2), ms(t0, at)).primary); + assert!(!t.note_primary(Some(3), ms(t0, at + 10)).primary); + } + // 1 silent, both settled: 2's frame does not take it, 3 is newer. + assert!(!t.note_primary(Some(2), ms(t0, 1000)).primary); + assert!(t.note_primary(Some(3), ms(t0, 1010)).switched); + + // A newcomer that has drawn under 250 ms is passed over. + let mut t = WindowTable::default(); + t.note_primary(Some(1), t0); + assert!(!t.note_primary(Some(4), ms(t0, 1100)).primary, "not settled"); + assert!(t.note_primary(Some(4), ms(t0, 1360)).switched); + } + + #[test] + fn alternating_bursts_do_not_flap_faster_than_the_silence() { + let mut t = WindowTable::default(); + let t0 = Instant::now(); + let mut switches = Vec::new(); + for frame in 0..1000u64 { + let at = frame * 16; + let who = if (at / 1500) % 2 == 0 { 1 } else { 2 }; + if t.note_primary(Some(who), ms(t0, at)).switched { + switches.push(at); + } + } + assert!(!switches.is_empty()); + for w in switches.windows(2) { + assert!(w[1] - w[0] >= 1000, "switched at {} then {}", w[0], w[1]); + } + } + + #[test] + fn routing() { + use Subscription::*; + assert_eq!( + route(Legacy, Some(3), true), + Some(0), + "old encoding for an old client" + ); + assert_eq!(route(Legacy, Some(0), false), None); + assert_eq!(route(Window(3), Some(3), false), Some(3)); + assert_eq!(route(Window(0), Some(0), true), Some(0)); + assert_eq!(route(Window(3), Some(4), true), None); + assert_eq!(route(Window(3), None, true), None); + assert_eq!(route(Torn, Some(0), true), None); + assert!(wants_cursor(Legacy, 7)); + assert!(wants_cursor(Window(7), 7)); + assert!(!wants_cursor(Window(7), 0)); + assert!(!wants_cursor(Torn, 0)); + } + + #[test] + fn snapshot_reflects_the_box_as_it_stands() { + let mut t = WindowTable::default(); + t.apply(&opened(0, "a")); + t.apply(&opened(1, "b")); + t.apply(&WindowEvent::Title { + window: 1, + title: "Paused".into(), + }); + t.apply(&WindowEvent::Size { + window: 1, + width: 1280, + height: 720, + }); + t.apply(&WindowEvent::Icon { + window: 1, + width: 1, + height: 1, + rgba: vec![1, 2, 3, 4], + }); + t.apply(&WindowEvent::Closed { window: 0 }); + let snap = t.snapshot(); + assert_eq!(snap.len(), 2); + assert_eq!( + snap[0], + WindowEvent::Opened { + window: 1, + width: 1280, + height: 720, + launch_id: "b".into(), + title: "Paused".into(), + app_id: String::new(), + } + ); + assert!(matches!(snap[1], WindowEvent::Icon { window: 1, .. })); + assert_eq!(t.reset(), vec![1]); + assert!(t.snapshot().is_empty()); + } + + #[test] + fn one_window_input_is_the_old_bytes() { + let mut tagger = FocusTagger::default(); + let mut key = Vec::new(); + encode_key_event(&mut key, true, 30); + let mut size = Vec::new(); + encode_output_size(&mut size, 1280, 720); + assert_eq!(tagger.messages(1, 0, &key, Instant::now()), vec![key.clone()]); + assert_eq!(tagger.messages(1, 0, &size, Instant::now()), vec![size.clone()]); + assert!(tagger.messages(1, 0, &[], Instant::now()).is_empty()); + } + + #[test] + fn a_change_of_window_is_tagged_once() { + let mut tagger = FocusTagger::default(); + let mut mv = Vec::new(); + encode_mouse_move(&mut mv, 1, 1); + let out = tagger.messages(1, 2, &mv, Instant::now()); + assert_eq!(out.len(), 2); + assert!(matches!( + decode_input_event(&out[0]), + Some(DecodedInput::WindowFocus { window: 2 }) + )); + assert_eq!(out[1], mv); + assert_eq!( + tagger.messages(1, 2, &mv, Instant::now()), + vec![mv.clone()], + "no tag while unchanged" + ); + // Back to window 0: tagged, since the box is now on 2. + assert_eq!(tagger.messages(1, 0, &mv, Instant::now()).len(), 2); + // Focus alone. + let out = tagger.messages(1, 5, &[], Instant::now()); + assert_eq!(out.len(), 1); + assert!(tagger.messages(1, 5, &[], Instant::now()).is_empty()); + } + + #[test] + fn a_gamepad_does_not_take_focus_from_someone_typing() { + let mut tagger = FocusTagger::default(); + let t0 = Instant::now(); + let mut key = Vec::new(); + encode_key_event(&mut key, true, 30); + // Client 1 types into window 0; client 2's pad asks for window 4. + tagger.messages(1, 0, &key, t0); + assert!(tagger.messages(2, 4, &[], t0 + Duration::from_millis(500)).is_empty()); + // A second of quiet: it may. + assert_eq!(tagger.messages(2, 4, &[], t0 + Duration::from_millis(1100)).len(), 1); + // Asking for the window that has focus changes nothing. + assert!(tagger.messages(2, 4, &[], t0 + Duration::from_millis(1200)).is_empty()); + // The typist's own pad is not held back by its own typing. + tagger.messages(1, 4, &key, t0 + Duration::from_millis(1300)); + assert_eq!(tagger.messages(1, 0, &[], t0 + Duration::from_millis(1310)).len(), 1); + } + + #[test] + fn a_window_resize_is_rewritten_except_for_window_zero() { + let mut tagger = FocusTagger::default(); + let mut size = Vec::new(); + encode_output_size(&mut size, 1280, 720); + let out = tagger.messages(1, 3, &size, Instant::now()); + assert_eq!( + out.len(), + 1, + "a resize carries its window and moves no focus" + ); + assert!(matches!( + decode_input_event(&out[0]), + Some(DecodedInput::WindowOutputSize { + window: 3, + width: 1280, + height: 720 + }) + )); + } +} diff --git a/apps/nesinit/src/services.rs b/apps/nesinit/src/services.rs index 1e5c1027..4c396edd 100644 --- a/apps/nesinit/src/services.rs +++ b/apps/nesinit/src/services.rs @@ -119,6 +119,21 @@ pub struct Service { /// socket list lives inside a module's arguments, and a drop-in that /// re-declares that module loads it twice. pub umask: Option, + /// Whether this service may give its own threads a realtime class, up to + /// [`REALTIME_PRIORITY`], and a nice level down to [`REALTIME_NICE`]. + /// + /// For the two processes that carry the audio graph's 5 ms cycle. Granted + /// as `RLIMIT_RTPRIO` and `RLIMIT_NICE` at exec, which PipeWire's `module-rt` checks first + /// and uses to set `SCHED_FIFO` itself. The grant used to come from rtkit, + /// asked over the system bus with polkit's approval, and in a box that + /// chain granted nothing. Measured 2026-10-16: every PipeWire, wireplumber + /// and neswire thread was `SCHED_OTHER`, and cycles that ran late behind a + /// busy game reached the client as 15-25 ms gaps in the stream, which + /// emptied its 10 ms cushion. + /// + /// Not given to the workload: a game that gave itself a realtime class + /// could starve the graph that this exists to protect. + pub realtime: bool, /// A path that exists once this service can be talked to. /// /// `None` means "started is ready", which is true of anything nothing else @@ -181,6 +196,26 @@ pub const AUDIO_DIR: &str = "/run/pipewire"; /// Where a service's runtime sockets live. pub const RUNTIME_DIR: &str = "/run/user/1000"; +/// The highest realtime priority a [`Service::realtime`] service may take. +/// +/// PipeWire's own packaging grants its group 95 (`limits.d/25-pw-rlimits.conf`), +/// and `module-rt` asks for 88 on the graph and 83 on clients. A limit below +/// what it asks makes it clamp rather than fail, so 95 just means it gets +/// what it asks for. +pub const REALTIME_PRIORITY: libc::rlim_t = 95; + +/// How far below 0 a [`Service::realtime`] service may lower its nice level, +/// as `RLIMIT_NICE` counts it: `20 - nice`, so 39 allows nice -19. +/// +/// **Not optional alongside the priority.** The server's `module-rt` asks for +/// nice -11 before it asks for a realtime class, and when the nice level is +/// refused it gives up on doing it itself and asks rtkit, which in a box +/// grants nothing. Measured 2026-10-16: with only `RLIMIT_RTPRIO`, neswire's +/// client loop went `SCHED_FIFO` 83 (its config asks for no nice level) and +/// the server's graph loop stayed `SCHED_OTHER`. -19 is what PipeWire's own +/// packaging grants beside rtprio 95. +pub const REALTIME_NICE: libc::rlim_t = 39; + /// Somewhere every service may write. /// /// # The service user's home is on a read-only root @@ -235,6 +270,7 @@ pub const STACK: &[Service] = &[ cost: "nothing that speaks on the system bus can find it", required: true, umask: None, + realtime: false, // Now something does connect to it: the two services below, once each // as they start, and neither one retries. A bus that has been forked and // has not bound its socket yet is a connection refused to both. @@ -262,6 +298,7 @@ pub const STACK: &[Service] = &[ cost: "rtkit cannot be authorised, so audio runs at normal priority", required: false, umask: None, + realtime: false, ready: None, ready_name: Some("org.freedesktop.PolicyKit1"), }, @@ -273,6 +310,7 @@ pub const STACK: &[Service] = &[ cost: "audio threads cannot get realtime priority and underrun when the CPUs are busy", required: false, umask: None, + realtime: false, ready: None, ready_name: Some("org.freedesktop.RealtimeKit1"), }, @@ -290,6 +328,7 @@ pub const STACK: &[Service] = &[ cost: "audio and anything else expecting a session bus will not start", required: true, umask: None, + realtime: false, // Every service after this one is handed this path as its bus address, // and a bus address that is not bound yet is a service that starts, // finds nothing, and carries on without a bus. @@ -309,6 +348,7 @@ pub const STACK: &[Service] = &[ required: true, // So the workload, which is not this user, can open the socket. umask: Some(0), + realtime: true, // Both the session manager and the sender connect here, and so does // the workload once it starts. ready: Some("/run/pipewire/pipewire-0"), @@ -329,6 +369,7 @@ pub const STACK: &[Service] = &[ cost: "audio devices exist but nothing routes them", required: false, umask: None, + realtime: false, ready: None, ready_name: None, }, @@ -344,6 +385,7 @@ pub const STACK: &[Service] = &[ cost: "the client gets pictures and no sound", required: false, umask: None, + realtime: true, ready: None, ready_name: None, }, @@ -357,6 +399,7 @@ pub const STACK: &[Service] = &[ cost: "the session has no address, so no client can reach it", required: true, umask: None, + realtime: false, ready: None, ready_name: None, }, @@ -373,6 +416,7 @@ pub const STACK: &[Service] = &[ cost: "controllers plugged into the client do not reach the game", required: false, umask: None, + realtime: false, // It dials the hub rather than the other way round, and redials until // the hub is there, so nothing waits on it. ready: None, @@ -494,15 +538,22 @@ impl Stack { if let Some(kbps) = self.video.bitrate_kbps { command.env("NESTRI_MAX_BITRATE", kbps.to_string()); } + // The port the host forwarded, from the command line, by the same + // route: `neshub` binds it so its first packet already leaves through + // the host's pinned masquerade. See `system::media_port`. + if let Some(port) = crate::system::media_port() { + command.env("NESTRI_BIND_PORT", port.to_string()); + } // The service's own entry last, so a service that states one of these // for itself wins over the defaults above. command.envs(service.env.iter().copied()); let mask = service.umask; + let realtime = service.realtime; if let Some((uid, gid)) = service.user { // SAFETY: the closure runs between fork and exec in the child, - // where only async-signal-safe calls are allowed. These two are, - // and it allocates nothing. + // where only async-signal-safe calls are allowed. These are, and it + // allocates nothing. unsafe { command.pre_exec(move || { if let Some(mask) = mask { @@ -510,6 +561,22 @@ impl Stack { // child, between fork and exec. libc::umask(mask as libc::mode_t); } + // Before the uid drops: raising a hard limit takes root, + // and once dropped the child could never get it back. + if realtime { + for (resource, value) in [ + (libc::RLIMIT_RTPRIO, REALTIME_PRIORITY), + (libc::RLIMIT_NICE, REALTIME_NICE), + ] { + let limit = libc::rlimit { + rlim_cur: value, + rlim_max: value, + }; + if libc::setrlimit(resource, &limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + } + } // gid first: dropping the uid first would lose the // privilege needed to set the gid at all. if libc::setgid(gid) != 0 { @@ -911,7 +978,10 @@ mod tests { #[test] fn the_system_bus_is_waited_for_before_what_connects_to_it() { let bus = STACK.iter().find(|s| s.name == "dbus-system").unwrap(); - assert!(bus.ready.is_some(), "polkitd and rtkit would find no socket"); + assert!( + bus.ready.is_some(), + "polkitd and rtkit would find no socket" + ); for name in ["polkitd", "rtkit"] { let svc = STACK.iter().find(|s| s.name == name).unwrap(); assert!(svc.ready_name.is_some(), "{name} is reached by name"); @@ -1155,6 +1225,25 @@ mod tests { assert!(at("rtkit") < at("pipewire")); } + /// The graph and the sender run its 5 ms cycle and may go realtime; nothing + /// else in the table may, because a realtime thread that is not the graph + /// is one that can take the CPU away from it. + #[test] + fn only_the_audio_graph_may_go_realtime() { + let realtime: Vec<_> = STACK + .iter() + .filter(|s| s.realtime) + .map(|s| s.name) + .collect(); + assert_eq!(realtime, ["pipewire", "neswire"]); + assert!( + STACK + .iter() + .filter(|s| s.realtime) + .all(|s| s.user.is_some()) + ); + } + /// A required service whose absence has a workaround should not be /// required, and an optional one whose absence has none should not be /// optional. Only the second half is checkable, and it is the one that diff --git a/apps/nesinit/src/session.rs b/apps/nesinit/src/session.rs index 6032902a..9cc6dddc 100644 --- a/apps/nesinit/src/session.rs +++ b/apps/nesinit/src/session.rs @@ -10,14 +10,22 @@ // something again is the caller's decision, because the caller is the only end // that can see whether restarting is repair or a loop. ref(d-0033) // -// # One launch at a time +// # Several launches at once // -// A box may be launched into many times over its life, and not twice at once. -// A launch arriving while one is running is refused with the id it was asked -// for, rather than queued or silently replacing it — a box has one compositor -// and one screen, so a second concurrent launch has nowhere to draw. Nothing -// in the wire format forbids it if that ever changes; this is a property of -// this implementation and it says so where it refuses. +// A box is a person's machine with any of their games in it, and every launch +// in it is named by its id. ref(d-0085) A second launch runs beside the first +// rather than replacing it; only a second launch under an id that is already +// running is refused, because two processes answering to one id cannot be told +// apart by anything reported about them. Content for a launch may be mounted +// after boot (`Mount`), which is how a game the box did not boot with reaches it. +// +// # The box ends when its last terminal launch has gone +// +// A launch whose exit is terminal ends the session when it exits, as it always +// has -- unless other launches are still running. Then the box stays up for +// them, and ends when the last of them exits: closing every window is the +// person saying they are done, and closing one is not. A box with one launch +// behaves exactly as it did before there could be two. use nesprotocol::lifecycle::{ CONTROL_VERSION, Exec, Exit, GuestToHost, HostToGuest, LaunchId, OnExit, Payload, from_line, @@ -27,7 +35,12 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader use crate::payload::Ports; use crate::services::{Died, Services}; -use crate::workload::{Exited, Failure, Workload}; +use crate::workload::{Failure, Workload}; +use std::collections::BTreeMap; +use tokio::task::JoinSet; + +/// Every launch's exit, as it arrives, named by the launch. +type Exits = JoinSet<(LaunchId, std::io::Result)>; use tokio::sync::mpsc::Receiver; /// How a session ended. @@ -49,14 +62,12 @@ pub enum Outcome { Refused(Failure), } -/// The launch that is running, and what it would take to start it again. +/// A launch that is running, and what it would take to start it again. struct Current { - id: LaunchId, /// Kept so a restart is the same command rather than a new one the caller /// has to re-send. exec: Exec, on_exit: OnExit, - exited: Exited, /// Set by `restart`, read when the exit arrives. A restart is a kill /// followed by a launch and this is the "followed by". relaunch: bool, @@ -129,7 +140,16 @@ where ) .await?; - let mut running: Option = None; + let mut running: BTreeMap = BTreeMap::new(); + let mut exits: Exits = JoinSet::new(); + // A terminal launch's exit, once it has ended while others were still + // running. The box stays up for them, takes no new launches, and ends with + // this exit when the last of them has gone. + let mut ending: Option = None; + // Each launch's content mounted after boot, by launch, so it can be taken + // down with the launch. Recorded when asked for, before it has finished. + let mut mounted: Mounted = BTreeMap::new(); + let mut mounting: JoinSet<(LaunchId, Result<(), Failure>)> = JoinSet::new(); let mut booted = false; let mut relay_open = true; let mut carrier_open = true; @@ -139,32 +159,37 @@ where let mut services_open = true; loop { - let event = match running.as_mut() { - Some(current) => tokio::select! { - ended = &mut current.exited => Event::Ended(ended?), - line = lines.next_line() => Event::Line(line?), - up = payload.from_workload.recv(), if relay_open => Event::FromWorkload(up), - found = addresses.recv(), if carrier_open => Event::Address(found), - gone = services.deaths().recv(), if services_open => Event::ServiceDied(gone), + // The exits are guarded like the channels below: an empty set resolves + // at once, and an unguarded branch on it would spin. + let event = tokio::select! { + ended = exits.join_next(), if !exits.is_empty() => match ended { + Some(Ok((id, exit))) => Event::Ended(id, exit?), + Some(Err(error)) => return Err(std::io::Error::other(error)), + None => continue, }, - None => tokio::select! { - line = lines.next_line() => Event::Line(line?), - up = payload.from_workload.recv(), if relay_open => Event::FromWorkload(up), - found = addresses.recv(), if carrier_open => Event::Address(found), - gone = services.deaths().recv(), if services_open => Event::ServiceDied(gone), + done = mounting.join_next(), if !mounting.is_empty() => match done { + Some(Ok((id, result))) => Event::MountDone(id, result), + Some(Err(error)) => return Err(std::io::Error::other(error)), + None => continue, }, + line = lines.next_line() => Event::Line(line?), + up = payload.from_workload.recv(), if relay_open => Event::FromWorkload(up), + found = addresses.recv(), if carrier_open => Event::Address(found), + gone = services.deaths().recv(), if services_open => Event::ServiceDied(gone), }; let line = match event { - Event::Ended(exit) => { + Event::Ended(id, exit) => { // Taken before anything is sent: whatever happens next, this - // launch is no longer the running one, and a failure to + // launch is no longer a running one, and a failure to // relaunch must not leave a dead launch looking live. - let ended = running.take().expect("an exit came from a launch"); + let ended = running + .remove(&id) + .expect("an exit came from a running launch"); send( &mut writer, &GuestToHost::WorkloadExited { - id: ended.id.clone(), + id: id.clone(), exit, }, ) @@ -175,26 +200,76 @@ where // command, and this is the second half. The id is kept, so // a caller sees the exit and then the same launch running // again rather than having to correlate a new name. - running = start( + let started = start( &mut writer, workload, untrusted, - ended.id, + (&mut running, &mut exits), + id.clone(), ended.exec, ended.on_exit, ) .await?; + if !started { + tear_down(workload, &mut mounted, &id); + // The last launch of an ending box would not come + // back, so nothing is left to wait for. + if let (Some(exit), true) = (ending, running.is_empty()) { + return Ok(Outcome::WorkloadExited(exit)); + } + } continue; } + tear_down(workload, &mut mounted, &id); // Terminal is the launch's own answer, not this end's. A // non-terminal exit leaves the box up and waiting to be // launched into again, which is the whole point of the box // outliving what runs in it. ref(d-0064) - if ended.on_exit.terminal { + // + // A terminal one ends the box only once nothing else is + // running in it. ref(d-0085) + // + // The exit the box ends with is the terminal launch's own, + // whichever launch happens to be the last to go. + if ended.on_exit.terminal && ending.is_none() { + ending = Some(exit); + } + if let (Some(exit), true) = (ending, running.is_empty()) { return Ok(Outcome::WorkloadExited(exit)); } - tracing::info!(launch = %ended.id, "a launch ended and the box stays up"); + if ending.is_some() { + tracing::info!( + launch = %id, + still_running = running.len(), + "a terminal launch ended; the box stays up until the others have" + ); + } else { + tracing::info!(launch = %id, "a launch ended and the box stays up"); + } + continue; + } + Event::MountDone(id, result) => { + match result { + Ok(()) => { + tracing::info!(launch = %id, "mounted a launch's content"); + send(&mut writer, &GuestToHost::LaunchMounted { id }).await? + } + Err(failure) => { + // Nothing of it is recorded as mounted: what did mount + // before the failure is taken down with it. + tear_down(workload, &mut mounted, &id); + tracing::warn!(launch = %id, reason = %failure.reason, "a launch's content would not mount"); + send( + &mut writer, + &GuestToHost::LaunchMountFailed { + id, + reason: failure.reason, + }, + ) + .await? + } + } continue; } Event::ServiceDied(Some(Died { name, exit })) => { @@ -351,13 +426,67 @@ where refuse(&mut writer, id, "the box has not been told what it is").await?; continue; } - if let Some(current) = &running { - let reason = format!("this box is already running a launch: {}", current.id); + if running.contains_key(&id) { + // Two processes under one id could not be told apart by + // anything reported about either. + let reason = format!("a launch with this id is already running: {id}"); refuse(&mut writer, id, &reason).await?; continue; } + if ending.is_some() { + // The box was ended by a terminal launch and is waiting + // only for the launches already in it. A new one would + // keep alive a box its person has finished with. + refuse( + &mut writer, + id, + "the box is ending: a launch that ends it has exited", + ) + .await?; + continue; + } - running = start(&mut writer, workload, untrusted, id, exec, on_exit).await?; + let started = start( + &mut writer, + workload, + untrusted, + (&mut running, &mut exits), + id.clone(), + exec, + on_exit, + ) + .await?; + if !started { + tear_down(workload, &mut mounted, &id); + } + } + HostToGuest::Mount { + id, + images, + overlays, + } => { + // A launch's content, mounted into a box that is already up. + // Never ends the session: a game whose image will not mount is + // one launch that cannot start. ref(d-0085) + // + // Carried out off this loop, which also carries every other + // launch's exits and stops: a mount over a share can take a + // while, and nothing else should wait on it. + let refused = if !booted { + Some("the box has not been told what it is".to_string()) + } else if mounted.contains_key(&id) { + Some(format!("content is already mounted for this launch: {id}")) + } else { + None + }; + if let Some(reason) = refused { + tracing::warn!(launch = %id, reason, "refusing a mount"); + send(&mut writer, &GuestToHost::LaunchMountFailed { id, reason }).await?; + continue; + } + mounted.insert(id.clone(), (images.clone(), overlays.clone())); + let pending = workload.mount_launch(images, overlays); + mounting.spawn(async move { (id, pending.await) }); } HostToGuest::Attach { id, exec, size } => { // A shell is not a launch: it does not touch `running`, takes no @@ -377,21 +506,21 @@ where .await?; } } - HostToGuest::Stop { id } => match &running { + HostToGuest::Stop { id } => match running.contains_key(&id) { // Idempotent, and never ends the session by itself. - Some(current) if current.id == id => workload.signal_stop(), + true => workload.stop_launch(&id), // Not an error and not worth failing: a caller stopping // something that has already stopped got what it asked for. // Signalling anyway would aim a kill at whatever is running // now, which is the one thing this must not do. - _ => tracing::debug!(launch = %id, "nothing with that id is running to stop"), + false => tracing::debug!(launch = %id, "nothing with that id is running to stop"), }, - HostToGuest::Restart { id } => match running.as_mut() { - Some(current) if current.id == id => { + HostToGuest::Restart { id } => match running.get_mut(&id) { + Some(current) => { // Kill now, launch when the exit arrives. Doing both here // would start the second before the first had gone. current.relaunch = true; - workload.signal_stop(); + workload.stop_launch(&id); } // Nothing is remembered about a launch that has already ended, // so this cannot be a launch in disguise: the caller has the @@ -413,16 +542,19 @@ where /// Start one launch, reporting which of the two things happened. /// -/// Returns `None` when it could not be started, which is not the end of the -/// session: a box that dies of a bad `argv` cannot be told a better one. +/// A launch that could not be started is reported and is not the end of the +/// session: a box that dies of a bad `argv` cannot be told a better one. One +/// that started is added to the running set, and its exit to `exits`. +#[allow(clippy::too_many_arguments)] async fn start( writer: &mut Wr, workload: &mut W, untrusted: &crate::ticket::Untrusted, + (running, exits): (&mut BTreeMap, &mut Exits), id: LaunchId, exec: Exec, on_exit: OnExit, -) -> std::io::Result> +) -> std::io::Result where W: Workload, Wr: AsyncWrite + Unpin, @@ -448,7 +580,7 @@ where ), ) .await?; - return Ok(None); + return Ok(false); } // Before the workload exists, so there is no window in which it is running @@ -456,24 +588,49 @@ where // address. untrusted.is(exec.uid); - match workload.start(&exec) { + match workload.start(&id, &exec) { Ok(exited) => { send(writer, &GuestToHost::Started { id: id.clone() }).await?; - Ok(Some(Current { - id, - exec, - on_exit, - exited, - relaunch: false, - })) + running.insert( + id.clone(), + Current { + exec, + on_exit, + relaunch: false, + }, + ); + exits.spawn(async move { (id, exited.await) }); + Ok(true) } Err(failure) => { refuse(writer, id, &failure.reason).await?; - Ok(None) + Ok(false) } } } +/// Each launch's content mounted after boot: its images and its overlays. +type Mounted = BTreeMap, Vec)>; + +/// Take down what was mounted for a launch that has gone, so the loop devices +/// under it clear themselves. Off this loop, like the mount, and logged +/// rather than reported: the launch it was for has already been reported. +fn tear_down(workload: &mut W, mounted: &mut Mounted, id: &LaunchId) { + let Some((images, overlays)) = mounted.remove(id) else { + return; + }; + let pending = workload.unmount_launch(images, overlays); + let id = id.clone(); + tokio::spawn(async move { + match pending.await { + Ok(()) => tracing::info!(launch = %id, "unmounted a launch's content"), + Err(failure) => { + tracing::warn!(launch = %id, reason = %failure.reason, "a launch's content did not unmount") + } + } + }); +} + /// Say that a launch will not happen, in the words of whatever refused it. async fn refuse(writer: &mut Wr, id: LaunchId, reason: &str) -> std::io::Result<()> where @@ -493,7 +650,8 @@ where /// What the session is waiting on. enum Event { Line(Option), - Ended(Exit), + Ended(LaunchId, Exit), + MountDone(LaunchId, Result<(), Failure>), FromWorkload(Option), Address(Option), ServiceDied(Option), @@ -859,14 +1017,318 @@ mod tests { assert!(workload.started.is_empty()); } - /// Two at once has nowhere to draw. The refusal carries the id that was - /// refused, not the one that is running. - #[tokio::test] - async fn a_second_launch_is_refused_while_the_first_is_running() { + /// Boot a box and hand back the caller, ready to launch into. + async fn booted(given: Given) -> (Caller, tokio::task::JoinHandle<(Outcome, Double, Stack)>) { let (guest, host) = tokio::io::duplex(4096); let mut caller = Caller::new(host); - let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + let session = spawn(guest, given); + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + (caller, session) + } + fn started(id: &str) -> GuestToHost { + GuestToHost::Started { + id: LaunchId::new(id), + } + } + + fn exited(id: &str) -> GuestToHost { + GuestToHost::WorkloadExited { + id: LaunchId::new(id), + exit: Exit::code(0), + } + } + + /// A box is a person's machine with any of their games in it: a second + /// launch runs beside the first. ref(d-0085) + #[tokio::test] + async fn a_second_launch_runs_beside_the_first() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + + caller.say(&HostToGuest::Shutdown).await; + let (outcome, workload, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown); + assert_eq!( + workload.started_ids, + vec![LaunchId::new("l-1"), LaunchId::new("l-2")] + ); + } + + /// Two processes under one id could not be told apart, so the second is + /// refused, by its id, and the first keeps running. + #[tokio::test] + async fn a_launch_under_an_id_that_is_running_is_refused() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch("l-1")).await; + let GuestToHost::StartFailed { id, reason } = caller.expect().await else { + panic!("a second launch under a running id was accepted") + }; + assert_eq!(id, LaunchId::new("l-1")); + assert!(reason.contains("already running"), "{reason}"); + + caller.say(&HostToGuest::Shutdown).await; + let (_, workload, _) = session.await.unwrap(); + assert_eq!(workload.started.len(), 1); + assert!(workload.stopped.iter().all(|s| s.is_none())); + } + + /// A stop names one launch and reaches that one only; the box and the + /// other launch carry on. + #[tokio::test] + async fn a_stop_reaches_its_own_launch_and_no_other() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch_and_stay("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + + caller + .say(&HostToGuest::Stop { + id: LaunchId::new("l-2"), + }) + .await; + assert_eq!(caller.expect().await, exited("l-2")); + + caller.say(&HostToGuest::Shutdown).await; + let (outcome, workload, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown); + assert_eq!(workload.stopped, vec![Some(LaunchId::new("l-2"))]); + } + + /// A terminal launch ending while another runs leaves the box up; the + /// box ends when the last launch has gone. ref(d-0085) + #[tokio::test] + async fn the_box_ends_when_its_last_launch_has_gone() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + + caller + .say(&HostToGuest::Stop { + id: LaunchId::new("l-1"), + }) + .await; + assert_eq!(caller.expect().await, exited("l-1")); + + // Still up, for the launch still in it. + for id in ["l-2"] { + caller + .say(&HostToGuest::Stop { + id: LaunchId::new(id), + }) + .await; + assert_eq!(caller.expect().await, exited(id)); + } + + let (outcome, _, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::WorkloadExited(Exit::code(0))); + } + + /// The box ends with the terminal launch's exit, not with whichever + /// launch happened to go last. + #[tokio::test] + async fn an_ending_box_reports_the_exit_of_the_launch_that_ended_it() { + let mut double = Double::exits_when_stopped(Exit::code(0)); + double.exit_for.insert(LaunchId::new("l-1"), Exit::code(7)); + let (mut caller, session) = booted(Given::new(double)).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-1") }).await; + assert!(matches!(caller.expect().await, GuestToHost::WorkloadExited { .. })); + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-2") }).await; + assert_eq!(caller.expect().await, exited("l-2")); + + let (outcome, _, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::WorkloadExited(Exit::code(7))); + } + + /// Once a terminal launch has ended, the box waits only for what is + /// already in it: a new launch is refused. + #[tokio::test] + async fn an_ending_box_takes_no_new_launch() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-1") }).await; + assert_eq!(caller.expect().await, exited("l-1")); + + caller.say(&launch_and_stay("l-3")).await; + let GuestToHost::StartFailed { id, reason } = caller.expect().await else { + panic!("an ending box took a new launch") + }; + assert_eq!(id, LaunchId::new("l-3")); + assert!(reason.contains("ending"), "{reason}"); + + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-2") }).await; + assert_eq!(caller.expect().await, exited("l-2")); + let (outcome, workload, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::WorkloadExited(Exit::code(0))); + assert_eq!(workload.started.len(), 2); + } + + /// The last launch of an ending box failing to restart leaves nothing to + /// wait for, and the box ends at once. + #[tokio::test] + async fn an_ending_box_whose_last_restart_fails_ends() { + let mut double = Double::exits_when_stopped(Exit::code(0)); + double.exit_for.insert(LaunchId::new("l-1"), Exit::code(3)); + double.fail_restart_of = Some(LaunchId::new("l-2")); + let (mut caller, session) = booted(Given::new(double)).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-1") }).await; + assert!(matches!(caller.expect().await, GuestToHost::WorkloadExited { .. })); + + caller.say(&HostToGuest::Restart { id: LaunchId::new("l-2") }).await; + assert_eq!(caller.expect().await, exited("l-2")); + assert!(matches!(caller.expect().await, GuestToHost::StartFailed { .. })); + + let (outcome, _, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::WorkloadExited(Exit::code(3))); + } + + fn overlay_for(id: &str) -> nesprotocol::lifecycle::Overlay { + nesprotocol::lifecycle::Overlay { + lower: format!("/nestri/launch-images/{id}/build.erofs"), + upper: format!("/nestri/launches/{id}/upper.ext4"), + at: format!("/nestri/installs/{id}"), + } + } + + /// A second mount for the same launch is refused, and what a launch + /// mounted is taken down when it ends. + #[tokio::test] + async fn a_launchs_content_is_mounted_once_and_taken_down_with_it() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + + let mount = HostToGuest::Mount { + id: LaunchId::new("l-2"), + images: Vec::new(), + overlays: vec![overlay_for("l-2")], + }; + caller.say(&mount).await; + assert_eq!(caller.expect().await, GuestToHost::LaunchMounted { id: LaunchId::new("l-2") }); + caller.say(&mount).await; + let GuestToHost::LaunchMountFailed { reason, .. } = caller.expect().await else { + panic!("a second mount for one launch was accepted") + }; + assert!(reason.contains("already mounted"), "{reason}"); + + caller.say(&launch_and_stay("l-2")).await; + assert_eq!(caller.expect().await, started("l-2")); + caller.say(&HostToGuest::Stop { id: LaunchId::new("l-2") }).await; + assert_eq!(caller.expect().await, exited("l-2")); + + caller.say(&HostToGuest::Shutdown).await; + let (_, workload, _) = session.await.unwrap(); + assert_eq!(workload.unmounted, vec![(Vec::new(), vec![overlay_for("l-2")])]); + } + + /// Content mounted for a launch that then will not start is taken down + /// too, rather than left for the life of the box. + #[tokio::test] + async fn content_for_a_launch_that_will_not_start_is_taken_down() { + let mut double = Double::exits_when_stopped(Exit::code(0)); + double.start_failure = Some(Failure::new("ENOENT")); + let (mut caller, session) = booted(Given::new(double)).await; + caller + .say(&HostToGuest::Mount { + id: LaunchId::new("l-2"), + images: Vec::new(), + overlays: vec![overlay_for("l-2")], + }) + .await; + assert_eq!(caller.expect().await, GuestToHost::LaunchMounted { id: LaunchId::new("l-2") }); + caller.say(&launch("l-2")).await; + assert!(matches!(caller.expect().await, GuestToHost::StartFailed { .. })); + caller.say(&HostToGuest::Shutdown).await; + let (_, workload, _) = session.await.unwrap(); + assert_eq!(workload.unmounted.len(), 1); + } + + /// Content for a launch reaches a box that is already up, and the answer + /// names the launch. + #[tokio::test] + async fn a_running_box_mounts_a_launchs_content_on_request() { + let (mut caller, session) = + booted(Given::new(Double::exits_when_stopped(Exit::code(0)))).await; + + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); + + let overlay = nesprotocol::lifecycle::Overlay { + lower: "/nestri/launch-images/l-2/build.erofs".into(), + upper: "/nestri/launches/l-2/upper.ext4".into(), + at: "/nestri/installs/l-2".into(), + }; + caller + .say(&HostToGuest::Mount { + id: LaunchId::new("l-2"), + images: Vec::new(), + overlays: vec![overlay.clone()], + }) + .await; + assert_eq!( + caller.expect().await, + GuestToHost::LaunchMounted { + id: LaunchId::new("l-2") + } + ); + + caller.say(&HostToGuest::Shutdown).await; + let (_, workload, _) = session.await.unwrap(); + assert_eq!(workload.overlays.last(), Some(&vec![overlay])); + assert!( + workload.stopped.is_empty(), + "mounting for one launch touched another" + ); + } + + /// A launch's content that will not mount is that launch's failure, and + /// the box stays up. + #[tokio::test] + async fn content_that_will_not_mount_does_not_take_the_box_down() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let mut double = Double::exits_when_stopped(Exit::code(0)); + double.named_image_failure = Some(Failure::new("/nestri/compat-2: ENOENT")); + let session = spawn(guest, Given::new(double)); caller.expect_ready().await; caller .say(&HostToGuest::Boot { @@ -875,35 +1337,48 @@ mod tests { .await; caller.expect_booted().await; - caller.say(&launch("l-1")).await; - assert_eq!( - caller.expect().await, - GuestToHost::Started { - id: LaunchId::new("l-1") - } - ); - - caller.say(&launch("l-2")).await; - let GuestToHost::StartFailed { id, reason } = caller.expect().await else { - panic!("a second concurrent launch was accepted") + caller + .say(&HostToGuest::Mount { + id: LaunchId::new("l-2"), + images: vec![nesprotocol::lifecycle::Image { + device: "/nestri/launch-images/l-2/compat.erofs".into(), + at: "/nestri/compat-2".into(), + }], + overlays: Vec::new(), + }) + .await; + let GuestToHost::LaunchMountFailed { id, reason } = caller.expect().await else { + panic!("a failed mount was not reported against its launch") }; - assert_eq!( - id, - LaunchId::new("l-2"), - "the refusal names the launch that is running rather than the one refused" - ); - assert!( - reason.contains("l-1"), - "the refusal does not say why: {reason}" - ); + assert_eq!(id, LaunchId::new("l-2")); + assert!(reason.contains("ENOENT"), "{reason}"); + caller.say(&launch("l-1")).await; + assert_eq!(caller.expect().await, started("l-1")); caller.say(&HostToGuest::Shutdown).await; - let (_, workload, _) = session.await.unwrap(); - assert_eq!( - workload.started.len(), - 1, - "the second launch ran anyway, so the first was replaced underneath it" - ); + assert_eq!(session.await.unwrap().0, Outcome::Shutdown); + } + + /// Before the descriptor there is nothing to mount into. + #[tokio::test] + async fn a_mount_before_the_box_exists_is_refused_by_its_launch() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + caller.expect_ready().await; + caller + .say(&HostToGuest::Mount { + id: LaunchId::new("l-2"), + images: Vec::new(), + overlays: Vec::new(), + }) + .await; + assert!(matches!( + caller.expect().await, + GuestToHost::LaunchMountFailed { .. } + )); + caller.say(&HostToGuest::Shutdown).await; + assert_eq!(session.await.unwrap().0, Outcome::Shutdown); } /// A launch that says its ending is not the session's leaves the box up and diff --git a/apps/nesinit/src/system.rs b/apps/nesinit/src/system.rs index 8246b58a..2f78bc88 100644 --- a/apps/nesinit/src/system.rs +++ b/apps/nesinit/src/system.rs @@ -463,6 +463,36 @@ fn resolver(cmdline: &str) { tracing::info!(%server, "the box resolves through this"); } +/// The UDP port the host forwarded to this box, which `neshub` binds. +/// +/// The host has to choose it, not the hub: the host installs its forward and +/// the masquerade that keeps the box's flows on one public port before the box +/// boots, and both name this port. A hub on a port of its own choosing sends +/// its first packets past them, and from then on the relay sees one public port +/// while peers see another, so hole punching fails against any NAT. +/// +/// `None` when the command line says nothing, or says something that is not a +/// port; the hub then picks its own, as it did before the host chose one. +pub fn media_port() -> Option { + let cmdline = std::fs::read_to_string("/proc/cmdline").unwrap_or_default(); + port(&cmdline) +} + +fn port(cmdline: &str) -> Option { + let value = parameter(cmdline, "port")?; + match value.parse::() { + Ok(port) if port != 0 => Some(port), + _ => { + tracing::warn!( + %value, + "nestri.port= is not a port, so the hub picks its own and peers behind a NAT \ + stay on the relay" + ); + None + } + } +} + /// Read one `nestri.=` from a kernel command line. /// /// Split out because this is the part worth asserting: the rest of `network` @@ -638,6 +668,19 @@ mod tests { assert_eq!(parameter("othernestri.ip=1.2.3.4", "ip"), None); } + /// The host's port is read as a port, and anything else leaves the hub + /// to pick its own rather than binding something the host did not forward. + #[test] + fn the_media_port_is_the_hosts_or_none() { + let cmdline = "console=hvc0 nestri.ip=10.213.0.2/24 nestri.gw=10.213.0.1 \ + nestri.dns=1.1.1.1 nestri.port=47900"; + assert_eq!(port(cmdline), Some(47900)); + assert_eq!(port("console=hvc0 nestri.ip=10.213.0.2/24"), None); + assert_eq!(port("nestri.port=0"), None); + assert_eq!(port("nestri.port=70000"), None); + assert_eq!(port("nestri.port=hub"), None); + } + /// Each of these is a thing that stops working, said in those terms. The /// same rule the early filesystems hold themselves to. #[test] diff --git a/apps/nesinit/src/workload.rs b/apps/nesinit/src/workload.rs index 47ac774f..cd9d7c4c 100644 --- a/apps/nesinit/src/workload.rs +++ b/apps/nesinit/src/workload.rs @@ -11,7 +11,9 @@ use std::future::Future; use std::io; use std::pin::Pin; -use nesprotocol::lifecycle::{AttachId, Exec, Exit, Image, Mount, Overlay, Winsize}; +use nesprotocol::lifecycle::{ + AttachId, Exec, Exit, Image, LAUNCH_ID_ENV, LaunchId, Mount, Overlay, Winsize, +}; use std::os::unix::process::CommandExt; @@ -37,6 +39,9 @@ impl Failure { /// Resolves when the workload ends. pub type Exited = Pin> + Send>>; +/// Resolves when a mount or an unmount made off the session's loop is done. +pub type Pending = Pin> + Send>>; + pub trait Workload { /// Make the shares the descriptor names, where it says to put them. fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>; @@ -51,6 +56,15 @@ pub trait Workload { /// layer, and the two together where it says. fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>; + /// Mount one launch's content into a box that is already up: its images, + /// then its overlays. Off the caller's thread, because the caller also + /// carries every other launch's exits and stops. ref(d-0085) + fn mount_launch(&mut self, images: Vec, overlays: Vec) -> Pending; + + /// Take down what [`Workload::mount_launch`] mounted, overlays first, so + /// the loop devices under it clear themselves. Lazily when busy. + fn unmount_launch(&mut self, images: Vec, overlays: Vec) -> Pending; + /// Start a process on a terminal and connect it to the host on a data port /// of its own. /// @@ -60,14 +74,22 @@ pub trait Workload { /// is made in the background. fn attach(&mut self, id: AttachId, exec: &Exec, size: Option) -> Result<(), Failure>; - /// Start the command the descriptor names. + /// Start one launch's command, named by its launch. /// /// Returning the exit as a future, rather than a `wait` method, is what /// keeps the caller free to read the channel while the workload runs — a /// stop has to arrive during the workload's life or it is not a stop. - fn start(&mut self, exec: &Exec) -> Result; + /// + /// Several launches may be running at once: a box is a person's machine + /// with any of their games in it, not one game's. ref(d-0085) + fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result; - /// Ask the workload to stop. Idempotent, and never ends the session by + /// Ask one launch to stop. Idempotent, and never ends the session by + /// itself. A launch that is not running is not signalled: its pid may + /// already belong to something else. + fn stop_launch(&mut self, id: &LaunchId); + + /// Ask every launch to stop. Idempotent, and never ends the session by /// itself. fn signal_stop(&mut self); } @@ -75,7 +97,10 @@ pub trait Workload { /// The workload as a local process. pub struct Process { waiters: Waiters, - running: Option, + /// Every launch started, by id. An entry stays after its exit is + /// delivered and `Watched::running` is what tells the two apart; it is + /// replaced when the same id is started again (a restart). + running: std::collections::BTreeMap, } impl Process { @@ -84,7 +109,7 @@ impl Process { pub fn new(waiters: Waiters) -> Self { Self { waiters, - running: None, + running: std::collections::BTreeMap::new(), } } @@ -93,7 +118,12 @@ impl Process { /// Nothing here ever signals the process group or every process: the order /// a shutdown promises is only true if the workload can be stopped alone. pub fn signal(&self, signal: libc::c_int) { - let Some(watched) = &self.running else { return }; + for watched in self.running.values() { + Self::signal_one(watched, signal); + } + } + + fn signal_one(watched: &Watched, signal: libc::c_int) { // Nothing is signalled once the exit has been delivered. The pid was // freed by the reap that produced it, and the kernel is entitled to // give that number to something else — a stop or a kill aimed at it @@ -113,14 +143,17 @@ impl Process { /// one pid rather than for any child, so a slow service cannot be mistaken /// for the workload still running. pub fn await_exit(&self, grace: std::time::Duration) -> bool { - let Some(watched) = &self.running else { - return true; - }; + let deadline = std::time::Instant::now() + grace; + self.running + .values() + .all(|watched| Self::await_one(watched, deadline)) + } + + fn await_one(watched: &Watched, deadline: std::time::Instant) -> bool { if !watched.running() { return true; // already reaped, and its exit already reported } let pid = watched.pid; - let deadline = std::time::Instant::now() + grace; loop { let mut status: libc::c_int = 0; // SAFETY: waitpid writes only into `status`. @@ -188,7 +221,48 @@ impl Workload for Process { Ok(()) } - fn start(&mut self, exec: &Exec) -> Result { + fn mount_launch(&mut self, images: Vec, overlays: Vec) -> Pending { + off_loop(move || { + for image in &images { + mount_image(image)?; + } + for overlay in &overlays { + mount_overlay(overlay)?; + } + Ok(()) + }) + } + + fn unmount_launch(&mut self, images: Vec, overlays: Vec) -> Pending { + off_loop(move || { + // Everything is attempted, and the first failure is the one said. + let mut first = None; + let mut note = |result: Result<(), Failure>| { + if let Err(failure) = result { + first.get_or_insert(failure); + } + }; + for overlay in overlays.iter().rev() { + match OverlayPlan::new(overlay) { + Ok(plan) => { + for at in [&plan.at, &plan.rw_at, &plan.lower_at] { + note(unmount_one(at)); + } + } + Err(failure) => note(Err(failure)), + } + } + for image in images.iter().rev() { + match ImagePlan::new(image) { + Ok(plan) => note(unmount_one(&plan.at)), + Err(failure) => note(Err(failure)), + } + } + first.map_or(Ok(()), Err) + }) + } + + fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result { let Some((program, args)) = exec.argv.split_first() else { return Err(Failure::new("the command is empty")); }; @@ -229,7 +303,7 @@ impl Workload for Process { None } }; - command.envs(environment(exec, runtime.as_deref())); + command.envs(launch_environment(exec, runtime.as_deref(), id)); // SAFETY: the closure runs between fork and exec in the child, where // only async-signal-safe calls are allowed. These two are, and it @@ -267,7 +341,10 @@ impl Workload for Process { // The caller gets the exit and reports it; this handle keeps the pid // and whether that pid is still this child's. let exit = watched.take_exit().expect("a new watch has its exit"); - self.running = Some(watched); + // A launch that ended leaves its entry behind until its id is used + // again; dropping those here keeps the map the size of what runs. + self.running.retain(|_, watched| watched.running()); + self.running.insert(id.clone(), watched); Ok(Box::pin(async move { exit.await @@ -275,11 +352,32 @@ impl Workload for Process { })) } + fn stop_launch(&mut self, id: &LaunchId) { + if let Some(watched) = self.running.get(id) { + Self::signal_one(watched, libc::SIGTERM); + } + } + fn signal_stop(&mut self) { self.signal(libc::SIGTERM); } } +/// A launch's environment: everything [`environment`] gives, then its id. +/// +/// The id goes last so nothing in the caller's environment can name a launch +/// other than the one this is. ref(d-0085) +pub(crate) fn launch_environment( + exec: &Exec, + runtime: Option<&str>, + id: &LaunchId, +) -> Vec<(String, String)> { + let mut env = environment(exec, runtime); + env.retain(|(k, _)| k != LAUNCH_ID_ENV); + env.push((LAUNCH_ID_ENV.to_string(), id.as_str().to_string())); + env +} + /// Everything a launch is started with, in the order that decides ties. /// /// The image's own graphics settings first and the caller's environment last, @@ -400,16 +498,20 @@ fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> { // Everything that can be refused without touching the filesystem is // refused first, so a descriptor this cannot act on leaves nothing behind. let plan = OverlayPlan::new(overlay)?; + let lower = device_for(&plan.lower, true) + .map_err(|error| plan.failed("the build image", &plan.lower, error))?; + let upper = device_for(&plan.upper, false) + .map_err(|error| plan.failed("the writable layer", &plan.upper, error))?; mount_one( - &plan.lower, + &lower, &plan.lower_at, c"erofs", plan.lower_flags, None, ) .map_err(|error| plan.failed("the build image", &plan.lower_at, error))?; - mount_one(&plan.upper, &plan.rw_at, c"ext4", plan.upper_flags, None) + mount_one(&upper, &plan.rw_at, c"ext4", plan.upper_flags, None) .map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?; for dir in [&plan.upper_dir, &plan.work_dir] { @@ -485,8 +587,202 @@ impl ImagePlan { /// Mount one read-only image where the descriptor says. fn mount_image(image: &Image) -> Result<(), Failure> { let plan = ImagePlan::new(image)?; - mount_one(&plan.device, &plan.at, c"erofs", plan.flags, None) - .map_err(|error| plan.failed(error)) + let device = device_for(&plan.device, true).map_err(|error| plan.failed(error))?; + mount_one(&device, &plan.at, c"erofs", plan.flags, None).map_err(|error| plan.failed(error)) +} + +/// Run blocking filesystem work on the runtime's blocking pool. +fn off_loop(work: impl FnOnce() -> Result<(), Failure> + Send + 'static) -> Pending { + Box::pin(async move { + tokio::task::spawn_blocking(work) + .await + .unwrap_or_else(|error| Err(Failure::new(format!("the mount work did not finish: {error}")))) + }) +} + +/// Unmount one filesystem, lazily if it is still in use: a launch that has +/// exited can leave a child holding a file open, and the mount must still go +/// so the loop device under it is freed when that child does. +fn unmount_one(target: &std::ffi::CStr) -> Result<(), Failure> { + // SAFETY: a nul-terminated path that outlives the call. + if unsafe { libc::umount2(target.as_ptr(), 0) } == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + // Never mounted, or already gone: what was asked for. + Some(libc::EINVAL) | Some(libc::ENOENT) => Ok(()), + Some(libc::EBUSY) => { + // SAFETY: as above. + if unsafe { libc::umount2(target.as_ptr(), libc::MNT_DETACH) } == 0 { + Ok(()) + } else { + Err(Failure::new(format!( + "{}: {}", + target.to_string_lossy(), + io::Error::last_os_error() + ))) + } + } + _ => Err(Failure::new(format!("{}: {error}", target.to_string_lossy()))), + } +} + +/// The block device to mount `source` from. +/// +/// A block device is used as it is, which is every mount made at boot. A +/// regular file is attached to a free loop device first: that is how content +/// reaches a box that is already running, as a file in a share, because a +/// box cannot be given a new block device after it has booted. ref(d-0085) +/// +/// The loop device clears itself when the filesystem on it is unmounted, so +/// nothing here has to remember it. +/// +/// A symbolic link anywhere in the path is refused rather than followed: a +/// file in a share is the host's to name, and a link would let whatever wrote +/// it name another. Anything that is neither a block device nor a regular file +/// is refused too. +fn device_for(source: &std::ffi::CStr, read_only: bool) -> io::Result { + use std::os::unix::fs::FileTypeExt; + let probe = open_no_symlinks(source, libc::O_RDONLY)?; + let kind = probe.metadata()?.file_type(); + if kind.is_block_device() { + return Ok(source.to_owned()); + } + if !kind.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a mount source has to be a block device or a regular file", + )); + } + drop(probe); + loop_attach(source, read_only) +} + +/// `struct open_how` from ``. +#[repr(C)] +struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, +} + +const RESOLVE_NO_SYMLINKS: u64 = 0x04; + +/// Open a path, refusing a symbolic link at any component (`openat2`), not +/// only the last one. +fn open_no_symlinks(path: &std::ffi::CStr, flags: libc::c_int) -> io::Result { + use std::os::fd::FromRawFd; + let how = OpenHow { + flags: (flags | libc::O_CLOEXEC | libc::O_NONBLOCK) as u64, + mode: 0, + resolve: RESOLVE_NO_SYMLINKS, + }; + // SAFETY: a nul-terminated path and an `open_how` that outlive the call. + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + libc::AT_FDCWD, + path.as_ptr(), + &how as *const OpenHow, + std::mem::size_of::(), + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: a descriptor the kernel just returned, owned by nothing else. + Ok(unsafe { std::fs::File::from_raw_fd(fd as i32) }) +} + +/// `struct loop_info64` from ``. +#[repr(C)] +struct LoopInfo64 { + device: u64, + inode: u64, + rdevice: u64, + offset: u64, + sizelimit: u64, + number: u32, + encrypt_type: u32, + encrypt_key_size: u32, + flags: u32, + file_name: [u8; 64], + crypt_name: [u8; 64], + encrypt_key: [u8; 32], + init: [u64; 2], +} + +/// `struct loop_config` from ``. +#[repr(C)] +struct LoopConfig { + fd: u32, + block_size: u32, + info: LoopInfo64, + reserved: [u64; 8], +} + +const LOOP_CTL_GET_FREE: libc::c_ulong = 0x4C82; +const LOOP_CONFIGURE: libc::c_ulong = 0x4C0A; +const LO_FLAGS_READ_ONLY: u32 = 1; +const LO_FLAGS_AUTOCLEAR: u32 = 4; + +/// Attach a file to a free loop device and name the device. +/// +/// Retried a few times on `EBUSY`: a device handed out as free can be taken +/// by somebody else before it is configured. +fn loop_attach(file: &std::ffi::CStr, read_only: bool) -> io::Result { + use std::os::fd::AsRawFd; + + // Opened again, with the same refusal, because the file may have been + // swapped since it was looked at. + let backing = open_no_symlinks(file, if read_only { libc::O_RDONLY } else { libc::O_RDWR })?; + if !backing.metadata()?.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file")); + } + let control = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/loop-control")?; + + let mut last = io::Error::from_raw_os_error(libc::EBUSY); + for _ in 0..8 { + // SAFETY: an ioctl with no argument on a descriptor this owns. + let number = unsafe { libc::ioctl(control.as_raw_fd(), LOOP_CTL_GET_FREE as _) }; + if number < 0 { + return Err(io::Error::last_os_error()); + } + let path = format!("/dev/loop{number}"); + let device = std::fs::OpenOptions::new() + .read(true) + .write(!read_only) + .open(&path)?; + let mut flags = LO_FLAGS_AUTOCLEAR; + if read_only { + flags |= LO_FLAGS_READ_ONLY; + } + // SAFETY: zero is a valid value for every field of this plain C struct. + let mut config: LoopConfig = unsafe { std::mem::zeroed() }; + config.fd = backing.as_raw_fd() as u32; + config.info.flags = flags; + // SAFETY: the kernel reads `config` and nothing else, and it outlives + // the call. + let done = unsafe { + libc::ioctl( + device.as_raw_fd(), + LOOP_CONFIGURE as _, + &config as *const LoopConfig, + ) + }; + if done == 0 { + return CString::new(path).map_err(io::Error::other); + } + last = io::Error::last_os_error(); + if last.raw_os_error() != Some(libc::EBUSY) { + return Err(last); + } + } + Err(last) } /// Mount one filesystem, creating its mount point first. @@ -684,6 +980,75 @@ fn failed(share: &Mount, error: io::Error) -> Failure { Failure::new(format!("{}: {error}", share.at)) } +#[cfg(test)] +mod loop_tests { + use super::*; + + /// The layouts the kernel reads, checked against ``. + #[test] + fn the_loop_structs_have_the_kernels_layout() { + assert_eq!(std::mem::size_of::(), 232); + assert_eq!(std::mem::size_of::(), 304); + assert_eq!(std::mem::offset_of!(LoopConfig, info), 8); + } + + /// Only a block device (every mount made at boot) or a regular file is a + /// source. A character device, a directory and the like are refused. + #[test] + fn a_source_that_is_neither_a_block_device_nor_a_file_is_refused() { + for path in [c"/dev/null", c"/tmp"] { + let error = device_for(path, true).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{path:?}: {error}"); + } + } + + /// A link is refused at the last component and at any earlier one. + #[test] + fn a_source_reached_through_a_symbolic_link_is_refused() { + let dir = std::env::temp_dir().join(format!("nesinit-link-{}", std::process::id())); + let real = dir.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("build.erofs"), b"x").unwrap(); + std::os::unix::fs::symlink(real.join("build.erofs"), dir.join("file-link")).unwrap(); + std::os::unix::fs::symlink(&real, dir.join("dir-link")).unwrap(); + for path in [dir.join("file-link"), dir.join("dir-link").join("build.erofs")] { + let source = CString::new(path.to_string_lossy().as_bytes()).unwrap(); + let error = device_for(&source, true).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(libc::ELOOP), "{path:?}: {error}"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn unmounting_what_is_not_mounted_is_not_a_failure() { + assert_eq!(unmount_one(c"/nonexistent/nesinit-test"), Ok(())); + } + + #[test] + fn a_source_that_is_not_there_names_the_error() { + let error = device_for(c"/nonexistent/build.erofs", true).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + /// The launch id is in the environment, and the caller cannot name + /// another launch in its place. + #[test] + fn a_launch_is_told_its_own_id() { + let mut exec = Exec { + argv: vec!["/bin/true".into()], + env: Default::default(), + cwd: None, + uid: 1001, + gid: 1001, + }; + exec.env.insert(LAUNCH_ID_ENV.into(), "launch_other".into()); + let env = launch_environment(&exec, None, &LaunchId::new("launch_01")); + let ids: Vec<_> = env.iter().filter(|(k, _)| k == LAUNCH_ID_ENV).collect(); + assert_eq!(ids.len(), 1, "{env:?}"); + assert_eq!(ids[0].1, "launch_01"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -988,12 +1353,25 @@ pub mod double { pub attached: Vec<(AttachId, Exec, Option)>, pub attach_failure: Option, pub started: Vec, + /// An exit of its own for a launch, over `exit`. + pub exit_for: std::collections::BTreeMap, + /// Launches whose start fails after the first, for a restart test. + pub fail_restart_of: Option, + /// What `unmount_launch` was asked to take down, in order. + pub unmounted: Vec<(Vec, Vec)>, + /// Which launch each start was for, in the same order as `started`. + pub started_ids: Vec, + /// Every stop asked for, one launch or all (`None`). + pub stopped: Vec>, pub stops: usize, pub mount_failure: Option, pub image_failure: Option, + /// Like `image_failure`, but only for a call that names an image, so a + /// box can boot with none and fail a later mount. + pub named_image_failure: Option, pub start_failure: Option, exit: Exit, - on_stop: Option>, + on_stop: std::collections::BTreeMap>, holds_until_stopped: bool, } @@ -1017,12 +1395,18 @@ pub mod double { attached: Vec::new(), attach_failure: None, started: Vec::new(), + unmounted: Vec::new(), + exit_for: Default::default(), + fail_restart_of: None, + started_ids: Vec::new(), + stopped: Vec::new(), stops: 0, mount_failure: None, image_failure: None, + named_image_failure: None, start_failure: None, exit, - on_stop: None, + on_stop: Default::default(), holds_until_stopped, } } @@ -1054,12 +1438,27 @@ pub mod double { fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure> { self.order.push("images"); self.images.push(images.to_vec()); + if let Some(failure) = self.named_image_failure.as_ref().filter(|_| !images.is_empty()) { + return Err(failure.clone()); + } match &self.image_failure { Some(failure) => Err(failure.clone()), None => Ok(()), } } + fn mount_launch(&mut self, images: Vec, overlays: Vec) -> Pending { + let result = self + .mount_images(&images) + .and_then(|()| self.mount_overlays(&overlays)); + Box::pin(async move { result }) + } + + fn unmount_launch(&mut self, images: Vec, overlays: Vec) -> Pending { + self.unmounted.push((images, overlays)); + Box::pin(async { Ok(()) }) + } + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> { self.order.push("overlays"); self.overlays.push(overlays.to_vec()); @@ -1069,27 +1468,41 @@ pub mod double { } } - fn start(&mut self, exec: &Exec) -> Result { + fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result { self.started.push(exec.clone()); + let again = self.started_ids.contains(id); + self.started_ids.push(id.clone()); if let Some(failure) = &self.start_failure { return Err(failure.clone()); } - let exit = self.exit; + if again && self.fail_restart_of.as_ref() == Some(id) { + return Err(Failure::new("it would not come back")); + } + let exit = self.exit_for.get(id).copied().unwrap_or(self.exit); if !self.holds_until_stopped { return Ok(Box::pin(async move { Ok(exit) })); } let (tx, rx) = oneshot::channel(); - self.on_stop = Some(tx); + self.on_stop.insert(id.clone(), tx); Ok(Box::pin(async move { rx.await .map_err(|_| io::Error::other("the workload was dropped")) })) } + fn stop_launch(&mut self, id: &LaunchId) { + self.stops += 1; + self.stopped.push(Some(id.clone())); + if let Some(tx) = self.on_stop.remove(id) { + let _ = tx.send(self.exit_for.get(id).copied().unwrap_or(self.exit)); + } + } + fn signal_stop(&mut self) { self.stops += 1; - if let Some(tx) = self.on_stop.take() { - let _ = tx.send(self.exit); + self.stopped.push(None); + for (id, tx) in std::mem::take(&mut self.on_stop) { + let _ = tx.send(self.exit_for.get(&id).copied().unwrap_or(self.exit)); } } } diff --git a/apps/nesinit/tests/services_stop.rs b/apps/nesinit/tests/services_stop.rs index 536df357..7ba105b2 100644 --- a/apps/nesinit/tests/services_stop.rs +++ b/apps/nesinit/tests/services_stop.rs @@ -20,6 +20,7 @@ static SLEEPERS: &[Service] = &[ cost: "nothing: this is a test", required: true, umask: None, + realtime: false, ready: None, ready_name: None, }, @@ -31,6 +32,7 @@ static SLEEPERS: &[Service] = &[ cost: "nothing: this is a test", required: true, umask: None, + realtime: false, ready: None, ready_name: None, }, diff --git a/apps/neswire/src/encoder.rs b/apps/neswire/src/encoder.rs index a7eedcc1..f0ada001 100644 --- a/apps/neswire/src/encoder.rs +++ b/apps/neswire/src/encoder.rs @@ -2,7 +2,6 @@ use anyhow::{Result, bail}; use crossbeam_channel::Receiver; use opus_head_sys::*; use std::os::unix::net::UnixDatagram; -use std::time::Instant; use nesprotocol::{CODEC_OPUS, STREAM_AUDIO, encode_ipc_frame}; @@ -133,18 +132,19 @@ fn send_frame( encoder: &MsEncoder, socket: &UnixDatagram, config: &EncoderConfig, - frame: &[f32], - start: &Instant, + packet: &crate::sink::Packet, opus_buf: &mut Vec, ) -> Result<()> { - let encoded_len = encoder.encode_float(frame, config.frame_size, opus_buf)?; + let encoded_len = encoder.encode_float(&packet.samples, config.frame_size, opus_buf)?; let encoded_data = &opus_buf[..encoded_len]; - let timestamp_ms = start.elapsed().as_millis() as u32; + // The packet's place on the graph clock, not when it was encoded: the + // receiver needs the audio timeline, and encode time only says when this + // thread got to it. let ipc_frame = encode_ipc_frame( STREAM_AUDIO, CODEC_OPUS, 0, - timestamp_ms, + packet.position, 0, 0, encoded_data, @@ -153,7 +153,7 @@ fn send_frame( Ok(()) } -pub fn run(config: EncoderConfig, rx: Receiver>) -> Result<()> { +pub fn run(config: EncoderConfig, rx: Receiver) -> Result<()> { tracing::info!( "encoder started — IPC {}, {}ch, {} samples/frame", config.ipc_path, @@ -165,7 +165,6 @@ pub fn run(config: EncoderConfig, rx: Receiver>) -> Result<()> { let encoder = MsEncoder::create_surround(&config)?; let mut opus_buf = vec![0u8; 4000]; - let start = Instant::now(); // Connect to hub with retry loop { @@ -185,8 +184,8 @@ pub fn run(config: EncoderConfig, rx: Receiver>) -> Result<()> { } // Send loop - while let Ok(frame) = rx.recv() { - send_frame(&encoder, &socket, &config, &frame, &start, &mut opus_buf)?; + while let Ok(packet) = rx.recv() { + send_frame(&encoder, &socket, &config, &packet, &mut opus_buf)?; } tracing::info!("encoder shut down"); @@ -399,10 +398,13 @@ mod tests { let config = config(path.to_str().expect("utf-8 path")); let encoder = MsEncoder::create_surround(&config).expect("encoder"); - let start = Instant::now(); let mut opus_buf = vec![0u8; 4000]; + let packet = crate::sink::Packet { + position: 123_456, + samples: tone(0), + }; - send_frame(&encoder, &sender, &config, &tone(0), &start, &mut opus_buf).expect("send"); + send_frame(&encoder, &sender, &config, &packet, &mut opus_buf).expect("send"); let mut buf = vec![0u8; 65536]; let n = listener.recv(&mut buf).expect("recv"); @@ -410,6 +412,10 @@ mod tests { assert_eq!(frame.stream_type, STREAM_AUDIO); assert_eq!(frame.codec, CODEC_OPUS); + assert_eq!( + frame.stamp, 123_456, + "the graph position did not reach the hub" + ); assert!(!frame.data.is_empty(), "frame carried no payload"); // And the payload is Opus the far end can actually decode. diff --git a/apps/neswire/src/main.rs b/apps/neswire/src/main.rs index 67671758..86699804 100644 --- a/apps/neswire/src/main.rs +++ b/apps/neswire/src/main.rs @@ -36,7 +36,7 @@ fn main() -> anyhow::Result<()> { let sample_rate = 48_000u32; let frame_size = (sample_rate * args.packet_duration_ms) / 1000; - let (tx, rx) = crossbeam_channel::bounded::>(256); + let (tx, rx) = crossbeam_channel::bounded::(256); let encoder_config = encoder::EncoderConfig { channels: args.channels, diff --git a/apps/neswire/src/sink.rs b/apps/neswire/src/sink.rs index bbce068a..e9cd1b9c 100644 --- a/apps/neswire/src/sink.rs +++ b/apps/neswire/src/sink.rs @@ -16,7 +16,25 @@ fn missed_frames(expected: Option, now: Option) -> u64 { } } -pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender>) -> Result<()> { +/// One packet's worth of interleaved samples, and where it starts. +pub struct Packet { + /// The packet's first sample on the graph clock, in frames at the graph + /// rate. Consecutive packets are exactly a packet apart, missed cycles + /// included, because a missed cycle is filled (see `process`). + pub position: u64, + pub samples: Vec, +} + +/// Where the first of `held` frames sits, given where the last one ends. +/// +/// The accumulator is continuous up to `end`: a missed cycle is filled before +/// the next one is appended. So the frames in it are the `held` frames before +/// `end`, and the oldest is the next packet's start. +fn first_position(end: u64, held: u64) -> u64 { + end.saturating_sub(held) +} + +pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender) -> Result<()> { pw::init(); tracing::info!("pipewire init ok"); @@ -54,7 +72,7 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender let samples_per_frame = (frame_size * channels) as usize; struct State { - tx: Sender>, + tx: Sender, accum: Vec, samples_per_frame: usize, channels: usize, @@ -62,6 +80,9 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender /// The graph position the next cycle's data starts at, if the stream /// has run a cycle yet. See `process` for what it is for. next_ticks: Option, + /// Where the accumulator ends when the graph gives no position: a count + /// of the frames received, so packets still carry a continuous timeline. + counted_end: u64, } let state = State { @@ -71,6 +92,7 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender channels: channels as usize, sample_rate, next_ticks: None, + counted_end: 0, }; let _listener = stream @@ -131,8 +153,10 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender tracing::debug!(missed, "graph cycle missed; filled with silence"); } state.next_ticks = ticks.map(|t| t + frames); + state.counted_end += missed as u64 + frames; state.accum.extend_from_slice(samples); + let end = state.next_ticks.unwrap_or(state.counted_end); tracing::trace!( "pw delivered {} samples, accum now {}, frame size {}", @@ -143,10 +167,13 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender // Drain complete frames while state.accum.len() >= state.samples_per_frame { - let frame: Vec = - state.accum.drain(..state.samples_per_frame).collect(); + let held = (state.accum.len() / state.channels) as u64; + let packet = Packet { + position: first_position(end, held), + samples: state.accum.drain(..state.samples_per_frame).collect(), + }; - if state.tx.try_send(frame).is_err() { + if state.tx.try_send(packet).is_err() { tracing::warn!("encoder falling behind, dropping frame"); } } @@ -237,7 +264,17 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender #[cfg(test)] mod tests { - use super::missed_frames; + use super::{first_position, missed_frames}; + + /// Two packets drained from one accumulator are one packet apart, and the + /// first starts where the oldest held frame does. + #[test] + fn packets_are_placed_on_the_graph_clock() { + // 600 frames held, ending at graph position 10_600. + assert_eq!(first_position(10_600, 600), 10_000); + // After one 240-frame packet is drained, 360 remain before the end. + assert_eq!(first_position(10_600, 360), 10_240); + } #[test] fn a_gap_is_exactly_the_frames_the_clock_moved_past() { diff --git a/build/kernel/nestri.fragment b/build/kernel/nestri.fragment index 0065e818..64a468b4 100644 --- a/build/kernel/nestri.fragment +++ b/build/kernel/nestri.fragment @@ -129,6 +129,8 @@ CONFIG_EXT4_FS=y # output/rootfs.ext4, and a box's writable install CONFIG_MISC_FILESYSTEMS=y # the menu EROFS lives under; off in base.config, which drops it silently CONFIG_EROFS_FS=y # a game's build image, the install overlay's lower layer CONFIG_OVERLAY_FS=y # the session overlay over a read-only install +CONFIG_BLK_DEV_LOOP=y # a game mounted into a running box arrives as a file in a share, ref(d-0085) +CONFIG_BLK_DEV_LOOP_MIN_COUNT=0 # devices are made on demand through /dev/loop-control # ── Memory typing ──────────────────────────────────────── # Page Attribute Table, and the MTRR support it depends on. diff --git a/crates/nesprotocol/Cargo.toml b/crates/nesprotocol/Cargo.toml index 4d036fa6..084a28b5 100644 --- a/crates/nesprotocol/Cargo.toml +++ b/crates/nesprotocol/Cargo.toml @@ -15,3 +15,7 @@ lifecycle = ["dep:serde", "dep:serde_json"] [dependencies] serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } + +# CLOCK_MONOTONIC, the one clock every media timestamp on the sender is on. +[target.'cfg(unix)'.dependencies] +libc.workspace = true diff --git a/crates/nesprotocol/src/clock.rs b/crates/nesprotocol/src/clock.rs new file mode 100644 index 00000000..038e025b --- /dev/null +++ b/crates/nesprotocol/src/clock.rs @@ -0,0 +1,81 @@ +//! The one clock media time is measured on. +//! +//! # Why one clock, and why this one +//! +//! Every media unit carries when it was captured and when it was handed to the +//! transport, and those stamps are taken by different processes: nescapture +//! stamps a video frame, neswire an audio packet's graph position, neshub the +//! send. They can only be subtracted if they are on one clock, so the sender's +//! clock is the kernel's `CLOCK_MONOTONIC`, which every process on a machine +//! shares and nothing can step. Wall clock would be shared too, and NTP moves +//! it. +//! +//! Microseconds in a `u64`: fine enough to see a millisecond of jitter in, and +//! it does not wrap in any session that will ever run, so nothing downstream +//! needs wrapping arithmetic. +//! +//! # The receiver +//! +//! The two machines' clocks are unrelated, so a receiver never compares its own +//! reading with a sender's stamp except through `delay`, which only uses the +//! difference's variation. Off unix (the client on Windows) this is therefore +//! the process's own monotonic clock, anchored at first use: consistent within +//! the process, which is all a receiver needs. + +/// Now, in microseconds, on the clock media timestamps are on. +#[cfg(unix)] +pub fn monotonic_us() -> u64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid out-pointer, and `CLOCK_MONOTONIC` exists on + // every unix this builds for, so the call cannot fail. + unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; + ts.tv_sec as u64 * 1_000_000 + ts.tv_nsec as u64 / 1_000 +} + +/// Now, in microseconds, on this process's own monotonic clock. +#[cfg(not(unix))] +pub fn monotonic_us() -> u64 { + static ANCHOR: std::sync::OnceLock = std::sync::OnceLock::new(); + ANCHOR + .get_or_init(std::time::Instant::now) + .elapsed() + .as_micros() as u64 +} + +/// When `instant` was, on the same clock as [`monotonic_us`]. +/// +/// For a moment something else stamped as an `Instant`, such as a compositor's +/// present time, taken as how long ago it was. An instant in the future reads +/// as now. +pub fn instant_us(instant: std::time::Instant) -> u64 { + monotonic_us().saturating_sub(instant.elapsed().as_micros() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_clock_moves_forward_in_microseconds() { + let a = monotonic_us(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let b = monotonic_us(); + assert!(b - a >= 2_000, "{a} -> {b}"); + assert!(b - a < 1_000_000, "{a} -> {b}"); + } + + #[test] + fn an_instant_lands_on_the_same_clock() { + let then = std::time::Instant::now(); + std::thread::sleep(std::time::Duration::from_millis(3)); + let now = monotonic_us(); + let stamped = instant_us(then); + assert!( + (2_000..1_000_000).contains(&(now - stamped)), + "{stamped} vs {now}" + ); + } +} diff --git a/crates/nesprotocol/src/datagram.rs b/crates/nesprotocol/src/datagram.rs index 03409aee..4f6669a7 100644 --- a/crates/nesprotocol/src/datagram.rs +++ b/crates/nesprotocol/src/datagram.rs @@ -18,9 +18,14 @@ //! # Wire format //! //! ```text -//! [1B kind] [2B frame seq LE] [2B fragment index LE] [2B fragment count LE] [fragment] +//! [1B kind] [2B frame seq LE] [2B fragment index LE] [2B fragment count LE] +//! [8B sent_us LE] [fragment] //! ``` //! +//! `sent_us` is when the hub handed the datagram to QUIC, on the sender's +//! [`clock`](crate::clock). Every fragment carries it, so the receiver can +//! measure the path from any one of them (see [`delay`](crate::delay)). +//! //! The fragments carry a frame *body*: exactly the bytes [`encode_frame`] produces //! minus its four-byte length prefix, which only a stream needs since a datagram //! already knows its own length. Reassembled, the body is `[1B type][2B seq][payload]` @@ -33,17 +38,89 @@ //! //! [`encode_frame`]: crate::encode_frame -/// kind(1) + frame seq(2) + fragment index(2) + fragment count(2). -pub const DGRAM_HDR_LEN: usize = 7; +/// kind(1) + frame seq(2) + fragment index(2) + fragment count(2) + sent_us(8). +pub const DGRAM_HDR_LEN: usize = 15; -/// Datagram kinds. These share the numbering of the stream types they replace, -/// so a frame's identity does not change with the transport under it. +/// Datagram kinds, a numbering of their own. Video and audio happen to share +/// the numbers of the stream types they replace; later kinds +/// ([`DGRAM_FEEDBACK`], [`crate::window::DGRAM_VIDEO_WINDOW`]) do not follow +/// the stream numbering, and a kind and a stream type with the same value are +/// unrelated. pub const DGRAM_VIDEO: u8 = 0; pub const DGRAM_AUDIO: u8 = 1; /// PyroWave, whose datagrams have a header of their own: see /// [`crate::pyrowave`]. pub const DGRAM_PYROWAVE: u8 = crate::pyrowave::DGRAM_PYROWAVE; +/// One completed video frame as the client saw it arrive, client to hub on the +/// video connection. +/// +/// Sent once per frame, so the hub hears about every frame a round trip after +/// it left rather than once a second. What the hub's rate decision rests on: +/// whether the bottleneck was still busy with the last frame when this one +/// reached it, and how fast it delivered this one's fragments. See nescore's +/// `docs/superpowers/specs/2026-10-16-video-rate-from-the-path-design.md`. +pub const DGRAM_FEEDBACK: u8 = 3; + +/// `[1B kind][2B seq][4B bytes][2B fragments][2B first_bytes][8B sent_us] +/// [8B first_arrival_us][4B spread_us][4B first_q_us]`, little-endian. +pub const FEEDBACK_LEN: usize = 35; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FrameFeedback { + /// The frame's sequence number. + pub seq: u16, + /// Datagram payload bytes that arrived for it, header excluded. + pub bytes: u32, + /// Fragments that arrived. + pub fragments: u16, + /// Payload bytes of the first fragment to arrive, which the arrival spread + /// does not cover: the spread starts when it has landed. + pub first_bytes: u16, + /// When the hub sent the frame, from its datagrams' `sent_us`. + pub sent_us: u64, + /// When the first fragment arrived, on the client's own clock. Only ever + /// compared with another frame's, never with the hub's clock. + pub first_arrival_us: u64, + /// The last fragment's arrival minus the first's. + pub spread_us: u32, + /// Queueing delay of the first fragment on the path (`delay`). + pub first_q_us: u32, +} + +pub fn encode_feedback(f: &FrameFeedback) -> [u8; FEEDBACK_LEN] { + let mut b = [0u8; FEEDBACK_LEN]; + b[0] = DGRAM_FEEDBACK; + b[1..3].copy_from_slice(&f.seq.to_le_bytes()); + b[3..7].copy_from_slice(&f.bytes.to_le_bytes()); + b[7..9].copy_from_slice(&f.fragments.to_le_bytes()); + b[9..11].copy_from_slice(&f.first_bytes.to_le_bytes()); + b[11..19].copy_from_slice(&f.sent_us.to_le_bytes()); + b[19..27].copy_from_slice(&f.first_arrival_us.to_le_bytes()); + b[27..31].copy_from_slice(&f.spread_us.to_le_bytes()); + b[31..35].copy_from_slice(&f.first_q_us.to_le_bytes()); + b +} + +/// `None` for anything that is not a whole feedback datagram. +pub fn decode_feedback(b: &[u8]) -> Option { + if b.len() < FEEDBACK_LEN || b[0] != DGRAM_FEEDBACK { + return None; + } + let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap()); + let u64_at = |o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap()); + Some(FrameFeedback { + seq: u16_at(1), + bytes: u32_at(3), + fragments: u16_at(7), + first_bytes: u16_at(9), + sent_us: u64_at(11), + first_arrival_us: u64_at(19), + spread_us: u32_at(27), + first_q_us: u32_at(31), + }) +} /// Bounds reassembly work. A 4K keyframe runs to a few hundred fragments at a /// typical MTU; anything past this did not come from a sender of ours. pub const MAX_FRAGMENTS_PER_FRAME: usize = 4096; @@ -79,17 +156,21 @@ pub struct DatagramHeader { pub index: u16, /// How many fragments the whole frame was split into. Always at least 1. pub total: u16, + /// When the sender handed this datagram to QUIC, in microseconds on its + /// [`clock`](crate::clock). + pub sent_us: u64, } /// Write a datagram header into the first [`DGRAM_HDR_LEN`] bytes of `buf`. /// /// # Panics /// If `buf` is shorter than [`DGRAM_HDR_LEN`]. -pub fn write_datagram_header(buf: &mut [u8], kind: u8, seq: u16, index: u16, total: u16) { - buf[0] = kind; - buf[1..3].copy_from_slice(&seq.to_le_bytes()); - buf[3..5].copy_from_slice(&index.to_le_bytes()); - buf[5..7].copy_from_slice(&total.to_le_bytes()); +pub fn write_datagram_header(buf: &mut [u8], header: &DatagramHeader) { + buf[0] = header.kind; + buf[1..3].copy_from_slice(&header.seq.to_le_bytes()); + buf[3..5].copy_from_slice(&header.index.to_le_bytes()); + buf[5..7].copy_from_slice(&header.total.to_le_bytes()); + buf[7..15].copy_from_slice(&header.sent_us.to_le_bytes()); } /// Split a received datagram into its header and fragment payload. @@ -112,6 +193,7 @@ pub fn decode_datagram(buf: &[u8]) -> Option<(DatagramHeader, &[u8])> { seq: u16::from_le_bytes([buf[1], buf[2]]), index, total, + sent_us: u64::from_le_bytes(buf[7..15].try_into().unwrap()), }; Some((header, &buf[DGRAM_HDR_LEN..])) } @@ -144,19 +226,18 @@ mod tests { #[test] fn header_roundtrips() { let mut buf = [0u8; DGRAM_HDR_LEN + 3]; - write_datagram_header(&mut buf, DGRAM_VIDEO, 0xBEEF, 2, 7); + let sent = DatagramHeader { + kind: DGRAM_VIDEO, + seq: 0xBEEF, + index: 2, + total: 7, + sent_us: 0x0123_4567_89AB_CDEF, + }; + write_datagram_header(&mut buf, &sent); buf[DGRAM_HDR_LEN..].copy_from_slice(&[1, 2, 3]); let (header, payload) = decode_datagram(&buf).expect("valid datagram"); - assert_eq!( - header, - DatagramHeader { - kind: DGRAM_VIDEO, - seq: 0xBEEF, - index: 2, - total: 7 - } - ); + assert_eq!(header, sent); assert_eq!(payload, &[1, 2, 3]); } @@ -166,18 +247,69 @@ mod tests { // total == 0 describes a frame with no fragments. let mut buf = [0u8; DGRAM_HDR_LEN]; - write_datagram_header(&mut buf, DGRAM_VIDEO, 1, 0, 0); + write_datagram_header( + &mut buf, + &DatagramHeader { + kind: DGRAM_VIDEO, + seq: 1, + index: 0, + total: 0, + sent_us: 0, + }, + ); assert!(decode_datagram(&buf).is_none()); // index outside the fragment count. - write_datagram_header(&mut buf, DGRAM_VIDEO, 1, 4, 4); + write_datagram_header( + &mut buf, + &DatagramHeader { + kind: DGRAM_VIDEO, + seq: 1, + index: 4, + total: 4, + sent_us: 0, + }, + ); assert!(decode_datagram(&buf).is_none()); // A count that would size an implausible allocation. - write_datagram_header(&mut buf, DGRAM_VIDEO, 1, 0, u16::MAX); + write_datagram_header( + &mut buf, + &DatagramHeader { + kind: DGRAM_VIDEO, + seq: 1, + index: 0, + total: u16::MAX, + sent_us: 0, + }, + ); assert!(decode_datagram(&buf).is_none()); } + #[test] + fn feedback_round_trips() { + let f = FrameFeedback { + seq: 0xABCD, + bytes: 17_000, + fragments: 15, + first_bytes: 1_180, + sent_us: 0x0102_0304_0506_0708, + first_arrival_us: 0x1112_1314_1516_1718, + spread_us: 9_800, + first_q_us: 450, + }; + let wire = encode_feedback(&f); + assert_eq!(decode_feedback(&wire), Some(f)); + assert_eq!(decode_feedback(&wire[..FEEDBACK_LEN - 1]), None); + let mut other = wire; + other[0] = DGRAM_VIDEO; + assert_eq!( + decode_feedback(&other), + None, + "a video datagram read as feedback" + ); + } + #[test] fn empty_body_still_takes_one_fragment() { assert_eq!(fragment_count(0, 1000), 1); diff --git a/crates/nesprotocol/src/delay.rs b/crates/nesprotocol/src/delay.rs index e7176c91..c396e6d8 100644 --- a/crates/nesprotocol/src/delay.rs +++ b/crates/nesprotocol/src/delay.rs @@ -1,144 +1,180 @@ -//! How much a delay varies, measured the same way at both ends. +//! How late a media unit arrived, measured the same way at both ends. //! //! # What this measures //! -//! Every frame carries the timestamp the sender stamped on it at capture. The -//! two clocks are unrelated, so the difference between that and the moment the -//! frame arrives here is meaningless on its own -- it contains an unknown, -//! roughly constant offset. Its *variation* is not meaningless, and variation is -//! the only thing a playout buffer exists to absorb: a path that delivers every -//! frame exactly 300 ms late needs no buffer at all, while one that alternates -//! between 10 ms and 60 ms needs 50 ms whatever its average. +//! Every media unit carries the time the sender stamped on it, on the sender's +//! [`clock`](crate::clock). The receiver's clock is unrelated, so the one-way +//! delay `d = observed - stamp` contains an unknown constant -- the two clocks' +//! offset -- plus propagation, plus queueing. Only the queueing varies, and it +//! is the only part a playout or a rate decision has any use for: a path that +//! delivers everything exactly 300 ms late needs no buffer, while one that +//! alternates between 10 and 60 ms needs 50 whatever its average. //! -//! So this tracks the smallest difference seen recently -- the best the path has -//! managed, which stands in for the unknown offset -- and reports how far above -//! it each frame lands. +//! So the lowest `d` the path has produced stands in for offset plus +//! propagation, and a unit's queueing delay is how far above that it landed. //! -//! # What it deliberately does not do +//! # Clock skew, measured instead of expired //! -//! It does not distinguish the sender's contribution from the network's. The -//! sender reports its own pipeline delay separately, and the two are compared -//! rather than subtracted: a frame that was late because the encoder stalled -//! should not raise a buffer, because buffering is latency spent hiding a fault -//! that should be fixed instead. +//! Two crystals never run at quite the same rate. At 100 ppm the receiver's +//! clock gains 0.1 ms a second on the sender's, so `d` drifts linearly, and a +//! lowest-ever `d` taken an hour ago would be 360 ms off. This used to be +//! handled by letting the best case expire after 30 s, which was a guess about +//! how fast paths change, and threw away a good baseline on a timer. +//! +//! Instead the drift is measured: the lowest `d` of each second lies on a line +//! whose slope is the skew, found by least squares over every second the path +//! has run. The baseline is that line at its lowest, and it never expires -- +//! nothing invalidates it but a change of path, which the caller knows about +//! and says so with [`DelayTracker::reset_baseline`]. //! //! # Why this is shared //! -//! Both ends measure a delay and the two are compared: the hub reports how much -//! its own pipeline varied before a frame left, the client reports how much the -//! total varied by the time it arrived. A comparison between two different -//! measures would be meaningless, so there is one measure, defined once. -//! -//! It also has to be *variation* on both sides rather than absolute delay, -//! because neither side can know the absolute. The hub's `timestamp_ms` counts -//! from the encoder's own start, not from any epoch, so even on one machine the -//! difference to wall clock contains an unknown constant -- and across two -//! machines there is no shared clock at all. -//! -//! Nothing here adapts anything yet. It measures, so that the decision about -//! what to adapt is made against a distribution rather than an intuition. +//! The hub measures its own pipeline with it (capture to hand-off, one clock, +//! no skew) and the client measures the whole path. Two different measures +//! could not be compared, so there is one, defined once. -use std::collections::VecDeque; -use std::time::{Duration, Instant}; - -/// How long a best-case observation stays authoritative. -/// -/// The minimum has to expire. Paths change -- a relay is dropped for a direct -/// connection, a phone moves between cells -- and a minimum from the old path -/// makes every frame on the new one look permanently late, which would pin a -/// buffer at a size nothing needs. Long enough that an ordinary quiet spell does -/// not reset the baseline, short enough that a genuine change is noticed. -const BASELINE_WINDOW: Duration = Duration::from_secs(30); - -/// Ceiling on retained observations, so a stalled consumer cannot grow this. -const MAX_SAMPLES: usize = 4096; - -/// One second's worth of lateness, summarised. +/// One second's queueing delay, summarised. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct DelaySummary { - /// Frames measured. + /// Units measured. pub frames: u32, - /// Median lateness above the best the path has managed, in milliseconds. - pub p50_ms: u16, + /// Median queueing delay above the path's best, in microseconds. + pub p50_us: u32, /// The 95th percentile of the same. - pub p95_ms: u16, - /// The worst single frame. - pub max_ms: u16, + pub p95_us: u32, + /// The worst single unit. + pub max_us: u32, } -/// Tracks arrival lateness for one media stream. -#[derive(Debug)] +/// `[4B frames][4B p50_us][4B p95_us][4B max_us]`, little-endian. +pub const DELAY_SUMMARY_LEN: usize = 16; + +impl DelaySummary { + pub fn encode(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.frames.to_le_bytes()); + buf.extend_from_slice(&self.p50_us.to_le_bytes()); + buf.extend_from_slice(&self.p95_us.to_le_bytes()); + buf.extend_from_slice(&self.max_us.to_le_bytes()); + } + + /// `None` when `buf` is shorter than [`DELAY_SUMMARY_LEN`]. + pub fn decode(buf: &[u8]) -> Option { + let at = |o: usize| Some(u32::from_le_bytes(buf.get(o..o + 4)?.try_into().ok()?)); + Some(Self { + frames: at(0)?, + p50_us: at(4)?, + p95_us: at(8)?, + max_us: at(12)?, + }) + } +} + +/// Ceiling on units held for one summary, so a consumer that stops taking +/// cannot grow this without bound. Far past a second of any stream we send. +const MAX_SAMPLES: usize = 4096; + +/// Tracks queueing delay for one media stream on one path. +#[derive(Debug, Default)] pub struct DelayTracker { - /// Best-case delay observations, each with when it was taken, oldest first. - /// - /// A deque rather than a single value because a minimum that can only fall - /// never recovers from a path that improved, and one that is simply reset on - /// a timer throws away a good baseline for no reason. Holding the recent - /// candidates lets the oldest expire while a better one is still standing. - baseline: VecDeque<(Instant, i32)>, - /// Lateness of each frame this second, in milliseconds. - samples: Vec, - /// The first delay seen, as an anchor for every later one. - /// - /// Delays are compared *relative to this*, in wrapping arithmetic. Both - /// clocks are unrelated and the sender's stamp is a `u32` of milliseconds - /// that wraps every 49 days, so an absolute subtraction is a number with no - /// meaning and a wrap in the middle of a session makes it jump by 2^32 -- - /// which, taken as lateness, reads as every frame being weeks late until the - /// baseline expires. Anchoring and then interpreting the difference as - /// signed makes the wrap a non-event, because the quantity that matters is - /// only ever a few hundred milliseconds wide. - anchor: Option, + /// The first observation, which every later one is measured relative to, + /// so the fit works on small numbers: `(observed_us, d_us)`. + origin: Option<(u64, i64)>, + /// The second being collected, and the lowest `d` seen in it so far. + current: Option<(u64, f64)>, + /// Every completed second's lowest `d`: `(seconds since origin, d)`. + minima: Vec<(f64, f64)>, + /// Running sums for the least-squares line through `minima`. + fit: Fit, + /// The skew, in microseconds of `d` per second of the receiver's clock. + slope: f64, + /// The lowest `d - slope * x` the path has produced: where the baseline + /// line sits. + floor: Option, + /// Queueing delay of each unit since the last [`take`](Self::take). + samples: Vec, } -impl Default for DelayTracker { - fn default() -> Self { - Self::new() +#[derive(Debug, Default, Clone, Copy)] +struct Fit { + n: f64, + sx: f64, + sy: f64, + sxx: f64, + sxy: f64, +} + +impl Fit { + fn add(&mut self, x: f64, y: f64) { + self.n += 1.0; + self.sx += x; + self.sy += y; + self.sxx += x * x; + self.sxy += x * y; + } + + /// The least-squares slope, once there are two distinct seconds to draw a + /// line through. + fn slope(&self) -> Option { + let denominator = self.n * self.sxx - self.sx * self.sx; + (self.n >= 2.0 && denominator > 0.0) + .then(|| (self.n * self.sxy - self.sx * self.sy) / denominator) } } impl DelayTracker { pub fn new() -> Self { - Self { - baseline: VecDeque::new(), - samples: Vec::new(), - anchor: None, - } + Self::default() } - /// Record one frame's arrival. + /// Record one unit, and return its queueing delay in microseconds. /// - /// `stamp_ms` is the timestamp the frame carries; `observed_ms` is the local - /// clock at the point being measured -- arrival, for a receiver; the moment - /// the frame is handed to the transport, for a sender. See [`anchor`](Self::anchor) for why the difference is taken - /// in wrapping arithmetic rather than widened. - pub fn observe(&mut self, stamp_ms: u32, observed_ms: u64, now: Instant) { - let raw = (observed_ms as u32).wrapping_sub(stamp_ms); - let anchor = *self.anchor.get_or_insert(raw); - let delay = raw.wrapping_sub(anchor) as i32; - self.expire(now); + /// `stamp_us` is the time the unit carries, on the sender's clock; + /// `observed_us` is this end's clock at the point being measured -- arrival + /// for a receiver, hand-off to the transport for a sender. + pub fn observe(&mut self, stamp_us: u64, observed_us: u64) -> u32 { + // Both are microsecond counts far below 2^63, so the difference of the + // two as signed is exact. + let d = observed_us as i64 - stamp_us as i64; + let (origin_us, origin_d) = *self.origin.get_or_insert((observed_us, d)); + let x_us = observed_us.saturating_sub(origin_us); + let x = x_us as f64 / 1e6; + let y = (d - origin_d) as f64; - // Anything at or below the running minimum becomes the new baseline, and - // supersedes the candidates it beats -- they can only be worse, so - // keeping them would let a stale, higher value resurface on expiry. - while self.baseline.back().is_some_and(|(_, d)| *d >= delay) { - self.baseline.pop_back(); + let second = x_us / 1_000_000; + match self.current { + Some((at, _)) if at != second => { + self.close_second(); + self.current = Some((second, y)); + } + Some((_, ref mut low)) => *low = low.min(y), + None => self.current = Some((second, y)), } - self.baseline.push_back((now, delay)); - let best = self.baseline.front().map(|(_, d)| *d).unwrap_or(delay); - let lateness = i64::from(delay) - .saturating_sub(i64::from(best)) - .clamp(0, i64::from(u16::MAX)) as u16; + let residual = y - self.slope * x; + let floor = match self.floor { + Some(floor) if floor <= residual => floor, + _ => residual, + }; + self.floor = Some(floor); + + let q = (residual - floor).clamp(0.0, f64::from(u32::MAX)) as u32; if self.samples.len() < MAX_SAMPLES { - self.samples.push(lateness); + self.samples.push(q); } + q } - /// Summarise and clear the frames seen since the last call. + /// The skew this path has been measured at, in parts per million: how much + /// faster this end's clock runs than the sender's. Zero until there are two + /// seconds to measure it from. + pub fn skew_ppm(&self) -> f64 { + self.slope + } + + /// Summarise and clear the units seen since the last call. /// /// Returns `None` for a second in which nothing arrived. That is not the - /// same as a second with no lateness, and reporting zero would say the path + /// same as a second with no queueing, and reporting zero would say the path /// is behaving perfectly at the moment it has stopped delivering anything. pub fn take(&mut self) -> Option { if self.samples.is_empty() { @@ -148,29 +184,44 @@ impl DelayTracker { let at = |q: f64| self.samples[((self.samples.len() - 1) as f64 * q) as usize]; let summary = DelaySummary { frames: self.samples.len() as u32, - p50_ms: at(0.5), - p95_ms: at(0.95), - max_ms: self.samples[self.samples.len() - 1], + p50_us: at(0.5), + p95_us: at(0.95), + max_us: self.samples[self.samples.len() - 1], }; self.samples.clear(); Some(summary) } - /// Forget the baseline entirely. + /// Forget the path entirely: baseline, skew and every second behind them. /// - /// For a path change, where the old best case says nothing about the new - /// path and keeping it would make every frame look late until it expired. + /// For a path change, where the old path's propagation delay says nothing + /// about the new one's, and keeping it would read the difference as + /// queueing for the rest of the session. pub fn reset_baseline(&mut self) { - self.baseline.clear(); + *self = Self { + samples: std::mem::take(&mut self.samples), + ..Self::default() + }; } - fn expire(&mut self, now: Instant) { - while self - .baseline - .front() - .is_some_and(|(seen, _)| now.duration_since(*seen) > BASELINE_WINDOW) - { - self.baseline.pop_front(); + /// Fold the second just finished into the fit, and move the baseline line + /// to the new slope. + fn close_second(&mut self) { + let Some((second, low)) = self.current.take() else { + return; + }; + // The minimum is placed at the middle of its second: it could have been + // anywhere in it, and the middle is where that error averages out. + let x = second as f64 + 0.5; + self.minima.push((x, low)); + self.fit.add(x, low); + if let Some(slope) = self.fit.slope() { + self.slope = slope; + self.floor = self + .minima + .iter() + .map(|(x, y)| y - slope * x) + .reduce(f64::min); } } } @@ -179,132 +230,110 @@ impl DelayTracker { mod tests { use super::*; - /// A frame sent at `ts_ms` arriving `transit` milliseconds later, where the - /// receiver's clock is offset from the sender's by an arbitrary amount. - const OFFSET: u64 = 1_700_000_000_000; + /// The receiver's clock is offset from the sender's by an arbitrary amount. + const OFFSET: u64 = 1_700_000_000_000_000; - fn at(j: &mut DelayTracker, ts_ms: u32, transit: u64, now: Instant) { - j.observe(ts_ms, OFFSET + u64::from(ts_ms) + transit, now); + /// A unit stamped at `sent_us`, `transit_us` in flight, arriving on a + /// receiver clock that runs `skew_ppm` fast. + fn arrive(j: &mut DelayTracker, sent_us: u64, transit_us: u64, skew_ppm: f64) -> u32 { + let true_arrival = sent_us + transit_us; + let observed = OFFSET + true_arrival + (true_arrival as f64 * skew_ppm / 1e6) as u64; + j.observe(sent_us, observed) } #[test] - fn a_constant_delay_is_not_jitter() { - // The point of the whole measure. A path that delivers every frame - // exactly 300 ms late needs no buffer at all; only variation does. + fn a_constant_delay_is_not_queueing() { let mut j = DelayTracker::new(); - let now = Instant::now(); - for i in 0..120u32 { - at( - &mut j, - i * 16, - 300, - now + Duration::from_millis(u64::from(i) * 16), - ); + for i in 0..120u64 { + arrive(&mut j, i * 16_000, 300_000, 0.0); } - let s = j.take().expect("frames arrived"); - assert_eq!(s.max_ms, 0, "a constant offset was read as lateness"); + let s = j.take().expect("units arrived"); + assert_eq!(s.max_us, 0, "a constant offset was read as queueing"); assert_eq!(s.frames, 120); } #[test] - fn lateness_is_measured_against_the_best_the_path_managed() { + fn queueing_is_measured_against_the_best_the_path_managed() { let mut j = DelayTracker::new(); - let now = Instant::now(); - at(&mut j, 0, 20, now); - at(&mut j, 16, 70, now + Duration::from_millis(16)); - at(&mut j, 32, 20, now + Duration::from_millis(32)); - let s = j.take().expect("frames arrived"); - assert_eq!( - s.max_ms, 50, - "the 70 ms frame is 50 ms above the 20 ms best" - ); + arrive(&mut j, 0, 20_000, 0.0); + let q = arrive(&mut j, 16_000, 70_000, 0.0); + arrive(&mut j, 32_000, 20_000, 0.0); + assert_eq!(q, 50_000, "the 70 ms unit is 50 ms above the 20 ms best"); + assert_eq!(j.take().unwrap().max_us, 50_000); } #[test] - fn a_silent_second_reports_nothing_rather_than_no_jitter() { - // Zero would say the path is behaving perfectly at the moment it has - // stopped delivering anything at all. + fn a_better_unit_becomes_the_baseline_at_once() { let mut j = DelayTracker::new(); - assert_eq!(j.take(), None); + arrive(&mut j, 0, 90_000, 0.0); + assert_eq!(arrive(&mut j, 16_000, 10_000, 0.0), 0); + assert_eq!(arrive(&mut j, 32_000, 50_000, 0.0), 40_000); } #[test] - fn the_baseline_expires_so_an_improved_path_is_noticed() { - // A minimum that can only fall never recovers: one lucky early frame - // would make every later frame look late for the rest of the session. - let mut j = DelayTracker::new(); - let now = Instant::now(); - at(&mut j, 0, 10, now); - j.take(); + fn a_silent_second_reports_nothing_rather_than_no_queueing() { + assert_eq!(DelayTracker::new().take(), None); + } - // Much later, the path settles at a steady 200 ms. - let later = now + BASELINE_WINDOW + Duration::from_secs(1); - for i in 0..10u32 { - at( - &mut j, - 1000 + i * 16, - 200, - later + Duration::from_millis(u64::from(i) * 16), - ); + /// The reason the baseline no longer expires: at 100 ppm a fixed baseline + /// would read 0.1 ms a second of drift as queueing, which over ten minutes + /// is 60 ms on a path with none at all. + #[test] + fn clock_skew_is_measured_and_not_read_as_queueing() { + let mut j = DelayTracker::new(); + // Ten minutes at 200 units a second, a steady 40 ms path, 100 ppm. + for i in 0..(600 * 200u64) { + let q = arrive(&mut j, i * 5_000, 40_000, 100.0); + if i > 200 * 5 { + assert!(q < 200, "unit {i} read {q} µs of queueing from skew alone"); + } } - let s = j.take().expect("frames arrived"); - assert_eq!( - s.max_ms, 0, - "a stale best case from a previous path made a steady path look late", + assert!( + (j.skew_ppm() - 100.0).abs() < 1.0, + "measured {} ppm", + j.skew_ppm() ); } #[test] - fn a_better_observation_supersedes_worse_ones_still_in_the_window() { - // Otherwise a higher candidate resurfaces when the better one expires, - // and the baseline walks upwards for no reason the path can account for. + fn queueing_on_a_skewed_path_is_still_exact() { let mut j = DelayTracker::new(); - let now = Instant::now(); - at(&mut j, 0, 90, now); - at(&mut j, 16, 10, now + Duration::from_millis(16)); - // The 90 ms candidate is gone, so this sits 40 ms above the 10 ms best. - at(&mut j, 32, 50, now + Duration::from_millis(32)); - let s = j.take().expect("frames arrived"); - assert_eq!(s.max_ms, 40); + for i in 0..(60 * 200u64) { + arrive(&mut j, i * 5_000, 40_000, -150.0); + } + let stalled = arrive(&mut j, 60 * 200 * 5_000, 40_000 + 25_000, -150.0); + assert!((24_500..25_500).contains(&stalled), "read {stalled} µs"); } #[test] fn a_reset_forgets_the_old_path_entirely() { let mut j = DelayTracker::new(); - let now = Instant::now(); - at(&mut j, 0, 10, now); + arrive(&mut j, 0, 10_000, 0.0); j.take(); j.reset_baseline(); - at(&mut j, 16, 400, now + Duration::from_millis(16)); - let s = j.take().expect("frames arrived"); - assert_eq!(s.max_ms, 0, "the new path was judged against the old one"); + assert_eq!( + arrive(&mut j, 16_000, 400_000, 0.0), + 0, + "the new path was judged against the old one" + ); } #[test] fn the_tail_is_kept_apart_from_the_middle() { - // The number that sizes a buffer is the tail. A run of prompt frames - // with one bad one must not average into "slightly late". let mut j = DelayTracker::new(); - let now = Instant::now(); - for i in 0..99u32 { - at( - &mut j, - i * 16, - 10, - now + Duration::from_millis(u64::from(i) * 16), - ); + for i in 0..99u64 { + arrive(&mut j, i * 16_000, 10_000, 0.0); } - at(&mut j, 99 * 16, 260, now + Duration::from_millis(99 * 16)); - let s = j.take().expect("frames arrived"); - assert_eq!(s.p50_ms, 0); - assert_eq!(s.max_ms, 250); + arrive(&mut j, 99 * 16_000, 260_000, 0.0); + let s = j.take().expect("units arrived"); + assert_eq!(s.p50_us, 0); + assert_eq!(s.max_us, 250_000); } #[test] fn taking_clears_so_each_answer_describes_one_second() { let mut j = DelayTracker::new(); - let now = Instant::now(); - at(&mut j, 0, 10, now); + arrive(&mut j, 0, 10_000, 0.0); assert!(j.take().is_some()); assert_eq!( j.take(), @@ -314,14 +343,17 @@ mod tests { } #[test] - fn a_wrapped_sender_timestamp_does_not_poison_the_baseline() { - // ts_ms wraps every 49 days. A negative delay taken as the best case - // would make every subsequent frame appear weeks late. - let mut j = DelayTracker::new(); - let now = Instant::now(); - j.observe(u32::MAX - 10, OFFSET, now); - j.observe(5, OFFSET + 16, now + Duration::from_millis(16)); - let s = j.take().expect("frames arrived"); - assert!(s.max_ms < u16::MAX, "a wrap produced a nonsense lateness"); + fn a_summary_survives_the_wire() { + let s = DelaySummary { + frames: 200, + p50_us: 1_200, + p95_us: 18_000, + max_us: 41_200, + }; + let mut buf = Vec::new(); + s.encode(&mut buf); + assert_eq!(buf.len(), DELAY_SUMMARY_LEN); + assert_eq!(DelaySummary::decode(&buf), Some(s)); + assert_eq!(DelaySummary::decode(&buf[..15]), None); } } diff --git a/crates/nesprotocol/src/input.rs b/crates/nesprotocol/src/input.rs index 04c8a119..6b6e71f5 100644 --- a/crates/nesprotocol/src/input.rs +++ b/crates/nesprotocol/src/input.rs @@ -5,6 +5,20 @@ pub const INPUT_KEY: u8 = 0; pub const INPUT_MOUSE_MOVE: u8 = 1; pub const INPUT_MOUSE_BUTTON: u8 = 2; pub const INPUT_MOUSE_WHEEL: u8 = 3; +/// The client's window is now this many pixels: the box's output follows it. +/// Not input, but it rides the input path because that is the one road that +/// already reaches the compositor. +pub const INPUT_OUTPUT_SIZE: u8 = 4; +/// `[5][window u16][width u16][height u16]`: one window's output is now this +/// size. Box-internal (hub → nescope): the hub turns a client's +/// [`INPUT_OUTPUT_SIZE`] into this for the window the client is subscribed to, +/// and still sends the old form for window 0. A client never sends it. +/// See [`crate::window`]. +pub const INPUT_WINDOW_OUTPUT_SIZE: u8 = 5; +/// `[6][window u16]`: the events after this, until the next one, came from a +/// client showing this window. Box-internal (hub → nescope); see +/// [`crate::window`]. +pub const INPUT_WINDOW_FOCUS: u8 = 6; pub const INPUT_KEY_UP: u8 = 0; pub const INPUT_KEY_DOWN: u8 = 1; @@ -110,6 +124,9 @@ pub enum DecodedInput { MouseMove { dx: i16, dy: i16 }, MouseButton { button: u8, down: bool }, MouseWheel { dx: i16, dy: i16 }, + OutputSize { width: u16, height: u16 }, + WindowOutputSize { window: u16, width: u16, height: u16 }, + WindowFocus { window: u16 }, } /// Decode a single input event from raw wire bytes. @@ -119,6 +136,9 @@ pub enum DecodedInput { /// - type=1 INPUT_MOUSE_MOVE: payload=[dx:2B LE][dy:2B LE] /// - type=2 INPUT_MOUSE_BUTTON: payload=[button:1B][down/up:1B] (0=left, 1=middle, 2=right) /// - type=3 INPUT_MOUSE_WHEEL: payload=[dx:2B LE][dy:2B LE] +/// - type=4 INPUT_OUTPUT_SIZE: payload=[width:2B LE][height:2B LE] +/// - type=5 INPUT_WINDOW_OUTPUT_SIZE: payload=[window:2B LE][width:2B LE][height:2B LE] +/// - type=6 INPUT_WINDOW_FOCUS: payload=[window:2B LE] /// /// Returns `None` if the buffer is too short for the given event type or the /// type byte is unknown. @@ -163,6 +183,29 @@ pub fn decode_input_event(data: &[u8]) -> Option { let dy = i16::from_le_bytes([data[3], data[4]]); Some(DecodedInput::MouseWheel { dx, dy }) } + INPUT_OUTPUT_SIZE => { + if data.len() < 5 { + return None; + } + let width = u16::from_le_bytes([data[1], data[2]]); + let height = u16::from_le_bytes([data[3], data[4]]); + Some(DecodedInput::OutputSize { width, height }) + } + INPUT_WINDOW_OUTPUT_SIZE => { + if data.len() < 7 { + return None; + } + let window = u16::from_le_bytes([data[1], data[2]]); + let width = u16::from_le_bytes([data[3], data[4]]); + let height = u16::from_le_bytes([data[5], data[6]]); + Some(DecodedInput::WindowOutputSize { window, width, height }) + } + INPUT_WINDOW_FOCUS => { + if data.len() < 3 { + return None; + } + Some(DecodedInput::WindowFocus { window: u16::from_le_bytes([data[1], data[2]]) }) + } _ => None, } } @@ -195,6 +238,31 @@ pub fn encode_mouse_wheel(buf: &mut Vec, dx: i16, dy: i16) { buf.extend_from_slice(&dy.to_le_bytes()); } +/// Encode an output size: [4][width u16 LE][height u16 LE] +/// +/// Send it in a batch of its own: a hub from before it stops reading a batch +/// at a type it does not know, and would drop whatever followed. +pub fn encode_output_size(buf: &mut Vec, width: u16, height: u16) { + buf.push(INPUT_OUTPUT_SIZE); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); +} + +/// Encode a window's output size (hub → nescope only): `[5][window][w][h]`. +pub fn encode_window_output_size(buf: &mut Vec, window: u16, width: u16, height: u16) { + buf.push(INPUT_WINDOW_OUTPUT_SIZE); + buf.extend_from_slice(&window.to_le_bytes()); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); +} + +/// Encode a window tag for the events that follow (hub → nescope only): +/// `[6][window]`. +pub fn encode_window_focus(buf: &mut Vec, window: u16) { + buf.push(INPUT_WINDOW_FOCUS); + buf.extend_from_slice(&window.to_le_bytes()); +} + /// Mapping from `KeyboardEvent.code` strings to Linux input keycodes. /// Covers all essential gaming keys. Use `keymap_lookup(code)` to resolve. pub fn keymap_lookup(code: &str) -> Option { @@ -315,3 +383,20 @@ pub fn keymap_lookup(code: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod output_size_tests { + use super::*; + + #[test] + fn an_output_size_comes_back_as_sent() { + let mut buf = Vec::new(); + encode_output_size(&mut buf, 2560, 1440); + assert_eq!(buf.len(), 5); + assert!(matches!( + decode_input_event(&buf), + Some(DecodedInput::OutputSize { width: 2560, height: 1440 }) + )); + assert!(decode_input_event(&buf[..4]).is_none()); + } +} diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index 44241656..15180c9a 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -3,6 +3,7 @@ // can drift from each other silently. pub mod attach; +pub mod clock; pub mod datagram; pub mod delay; pub mod gamepad; @@ -12,6 +13,7 @@ pub mod lifecycle; pub mod pyrowave; pub mod reliable; pub mod stats; +pub mod window; /// One connection per kind of traffic, not one connection for everything. /// @@ -67,7 +69,8 @@ impl Carrier { pub const IPC_MAGIC: [u8; 4] = [b'N', b'S', b'T', b'R']; -pub const IPC_HEADER_LEN: usize = 20; +/// `[4B magic][1B type][1B codec][1B flags][1B reserved][8B stamp][2B w][2B h][4B len]`. +pub const IPC_HEADER_LEN: usize = 24; // ── QUIC stream protocol ──────────────────────────────────────── @@ -83,7 +86,11 @@ pub const IPC_HEADER_LEN: usize = 20; /// stream type it does not know, so it would receive no keyframes at all and /// never decode anything — the two cannot interoperate either, and the mismatch /// is worth reporting rather than presenting as a frozen picture. -pub const STREAM_VERSION: u8 = 3; +/// +/// v4 put time on every media unit (see [`clock`]): a send time on every +/// datagram and keyframe stream, a capture time in microseconds on video where +/// v3 had milliseconds, and the graph position on audio, which had nothing. +pub const STREAM_VERSION: u8 = 4; /// Uniform frame header overhead: [4B u32 LE frame_len] [1B type] [2B u16 LE seq] pub const FRAME_HDR_LEN: usize = 7; @@ -166,6 +173,13 @@ pub const STREAM_KEYFRAME: u8 = 5; /// One message of a PyroWave frame, on the nescapture→hub video socket only. /// A frame is several of these: see the [`pyrowave`] module. pub const STREAM_PYROWAVE: u8 = 7; +/// An encoder introducing itself, on the nescapture→hub video socket only, +/// ref(d-0085): a pid-tagged IPC frame ([`IPC_FLAG_PID`]) whose data is the +/// encoder process's `NESTRI_LAUNCH_ID` (UTF-8, possibly empty). Sent before +/// every keyframe, so a hub that started late or lost one still learns it. +/// The encoder runs inside the game and can read its own environment; the +/// hub runs as another user and cannot. +pub const STREAM_ENCODER_HELLO: u8 = 9; // ── Bidi stream types (desktop ↔ hub over QUIC bidi) ─────────── @@ -208,25 +222,73 @@ pub const FLAG_KEYFRAME: u8 = 0x01; pub const FLAG_RECONFIG: u8 = 0x02; // stream reconfiguration (codec/bit-depth change) /// Encode an IPC frame into a byte buffer. -/// Format: [4B magic] [1B type] [1B codec] [1B flags] [1B reserved] [4B ts_ms LE] [4B w_h LE] [4B len LE] [N data] -pub fn encode_ipc_frame( +/// +/// Format: `[4B magic][1B type][1B codec][1B flags][1B reserved][8B stamp LE] +/// [2B width LE][2B height LE][4B len LE][N data]`. +/// +/// `stamp` is the media unit's time, and what it counts depends on the stream: +/// for video, when the frame was presented, in microseconds on +/// [`clock::monotonic_us`]; for audio, the packet's first sample on the audio +/// graph's clock, in 48 kHz frames. Both are the producer's to know and the +/// hub's to carry; neither is ever converted into the other. +/// Set in an IPC frame's reserved byte when the producer's process id follows +/// the data as `[4B pid LE]`, ref(d-0085). +/// +/// Several games in one box each run their own encoder, and the hub tells their +/// frames apart by process. A reader that predates this ignores the reserved +/// byte and reads exactly `len` bytes of data, so the trailer is invisible to +/// it; a frame without the flag is from a producer that predates it, which the +/// hub treats as the box's first window. +pub const IPC_FLAG_PID: u8 = 0x01; + +/// Where an encoder listens for commands when one process per box was all +/// there was. Every encoder still binds it, so a hub that predates per-process +/// sockets keeps reaching the newest one, as it always did. +pub const CAPTURE_CMD_SOCKET: &str = "/tmp/nescapture-cmd.sock"; + +/// Where the encoder in process `pid` listens for commands, ref(d-0085). One +/// socket per process, so two games in one box are not told each other's +/// keyframe requests, and neither steals the other's socket. +pub fn capture_cmd_socket(pid: u32) -> String { + format!("/tmp/nescapture-cmd-{pid}.sock") +} + +/// [`encode_ipc_frame`], tagged with the producer's process id. See +/// [`IPC_FLAG_PID`]. +#[allow(clippy::too_many_arguments)] +pub fn encode_ipc_frame_from( + pid: u32, stream_type: u8, codec: u8, flags: u8, - timestamp_ms: u32, + stamp: u64, width: u16, height: u16, data: &[u8], ) -> Vec { - let header_len = IPC_HEADER_LEN; - let mut buf = Vec::with_capacity(header_len + data.len()); + let mut buf = encode_ipc_frame(stream_type, codec, flags, stamp, width, height, data); + buf[7] |= IPC_FLAG_PID; + buf.extend_from_slice(&pid.to_le_bytes()); + buf +} + +pub fn encode_ipc_frame( + stream_type: u8, + codec: u8, + flags: u8, + stamp: u64, + width: u16, + height: u16, + data: &[u8], +) -> Vec { + let mut buf = Vec::with_capacity(IPC_HEADER_LEN + data.len()); buf.extend_from_slice(&IPC_MAGIC); buf.push(stream_type); buf.push(codec); buf.push(flags); buf.push(0); // reserved - buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf.extend_from_slice(&stamp.to_le_bytes()); buf.extend_from_slice(&width.to_le_bytes()); buf.extend_from_slice(&height.to_le_bytes()); buf.extend_from_slice(&(data.len() as u32).to_le_bytes()); @@ -241,10 +303,13 @@ pub struct DecodedIpcFrame<'a> { pub stream_type: u8, pub codec: u8, pub flags: u8, - pub timestamp_ms: u32, + /// See [`encode_ipc_frame`] for what it counts on each stream. + pub stamp: u64, pub width: u16, pub height: u16, pub data: &'a [u8], + /// The producer's process id, when it said ([`IPC_FLAG_PID`]). + pub pid: Option, } /// Parse a raw IPC datagram. Returns None if magic doesn't match or buffer is too short. @@ -258,26 +323,106 @@ pub fn decode_ipc_frame(buf: &[u8]) -> Option> { let stream_type = buf[4]; let codec = buf[5]; let flags = buf[6]; - let timestamp_ms = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); - let width = u16::from_le_bytes([buf[12], buf[13]]); - let height = u16::from_le_bytes([buf[14], buf[15]]); - let data_len = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]) as usize; + let stamp = u64::from_le_bytes(buf[8..16].try_into().unwrap()); + let width = u16::from_le_bytes([buf[16], buf[17]]); + let height = u16::from_le_bytes([buf[18], buf[19]]); + let data_len = u32::from_le_bytes(buf[20..24].try_into().unwrap()) as usize; if buf.len() < IPC_HEADER_LEN + data_len { return None; } + let end = IPC_HEADER_LEN + data_len; + let pid = if buf[7] & IPC_FLAG_PID != 0 { + Some(u32::from_le_bytes(buf.get(end..end + 4)?.try_into().ok()?)) + } else { + None + }; + Some(DecodedIpcFrame { + pid, stream_type, codec, flags, - timestamp_ms, + stamp, width, height, data: &buf[IPC_HEADER_LEN..IPC_HEADER_LEN + data_len], }) } +// ── Media payloads ────────────────────────────────────────────── +// +// What a media frame body carries after its `[type][seq]`, defined once for the +// hub that builds them and every receiver that reads them. + +/// `[1B codec][1B flags][8B captured_us LE][2B width LE][2B height LE]`. +pub const VIDEO_PAYLOAD_HDR_LEN: usize = 14; + +/// The head of a video frame payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VideoHead { + pub codec: u8, + pub flags: u8, + /// When the frame was presented, in microseconds on the sender's + /// [`clock::monotonic_us`]. + pub captured_us: u64, + pub width: u16, + pub height: u16, +} + +/// Build a video frame payload: the head, then the coded frame. +pub fn encode_video_payload(head: &VideoHead, data: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(VIDEO_PAYLOAD_HDR_LEN + data.len()); + buf.push(head.codec); + buf.push(head.flags); + buf.extend_from_slice(&head.captured_us.to_le_bytes()); + buf.extend_from_slice(&head.width.to_le_bytes()); + buf.extend_from_slice(&head.height.to_le_bytes()); + buf.extend_from_slice(data); + buf +} + +/// Split a video frame payload into its head and the coded frame. +pub fn decode_video_payload(payload: &[u8]) -> Option<(VideoHead, &[u8])> { + if payload.len() < VIDEO_PAYLOAD_HDR_LEN { + return None; + } + let head = VideoHead { + codec: payload[0], + flags: payload[1], + captured_us: u64::from_le_bytes(payload[2..10].try_into().unwrap()), + width: u16::from_le_bytes([payload[10], payload[11]]), + height: u16::from_le_bytes([payload[12], payload[13]]), + }; + Some((head, &payload[VIDEO_PAYLOAD_HDR_LEN..])) +} + +/// `[8B position LE]`, then the Opus packet. +pub const AUDIO_PAYLOAD_HDR_LEN: usize = 8; + +/// Build an audio packet payload. +/// +/// `position` is the packet's first sample on the sender's audio graph clock, +/// in 48 kHz frames. It is the audio timeline: two packets whose positions are +/// further apart than the first one's length have samples missing between +/// them, by exactly that count, whether those packets were lost or are late. +pub fn encode_audio_payload(position: u64, opus: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(AUDIO_PAYLOAD_HDR_LEN + opus.len()); + buf.extend_from_slice(&position.to_le_bytes()); + buf.extend_from_slice(opus); + buf +} + +/// Split an audio packet payload into its position and the Opus packet. +pub fn decode_audio_payload(payload: &[u8]) -> Option<(u64, &[u8])> { + if payload.len() < AUDIO_PAYLOAD_HDR_LEN { + return None; + } + let position = u64::from_le_bytes(payload[..8].try_into().unwrap()); + Some((position, &payload[AUDIO_PAYLOAD_HDR_LEN..])) +} + /// Codec ID to human-readable name. pub fn codec_name(codec: u8) -> &'static str { match codec { @@ -611,6 +756,15 @@ pub struct ReceiverReport { pub never_arrived: u32, /// The receiver's own round-trip estimate, in milliseconds. pub rtt_ms: u32, + /// How long video spent queued on the path this second, from each + /// datagram's send time (see [`delay`]). + /// + /// The leading signal. Loss and RTT say a path was overrun some time ago; + /// one-way queueing delay rises the moment a queue starts to build, which + /// is tens of milliseconds before either of them moves. + pub video_queue: delay::DelaySummary, + /// The same for audio, on its own connection. + pub audio_queue: delay::DelaySummary, } impl ReceiverReport { @@ -629,9 +783,9 @@ impl ReceiverReport { } } -/// `[8B goodput_bps][4B released][4B incomplete][4B never_arrived][4B rtt_ms]`, -/// all little-endian. -pub const RECEIVER_REPORT_LEN: usize = 24; +/// `[8B goodput_bps][4B released][4B incomplete][4B never_arrived][4B rtt_ms] +/// [16B video_queue][16B audio_queue]`, all little-endian. +pub const RECEIVER_REPORT_LEN: usize = 24 + 2 * delay::DELAY_SUMMARY_LEN; pub fn encode_receiver_report(buf: &mut Vec, report: &ReceiverReport) { buf.reserve(RECEIVER_REPORT_LEN); @@ -640,12 +794,11 @@ pub fn encode_receiver_report(buf: &mut Vec, report: &ReceiverReport) { buf.extend_from_slice(&report.incomplete.to_le_bytes()); buf.extend_from_slice(&report.never_arrived.to_le_bytes()); buf.extend_from_slice(&report.rtt_ms.to_le_bytes()); + report.video_queue.encode(buf); + report.audio_queue.encode(buf); } /// Decode a receiver report. `None` when the payload is short. -/// -/// A payload *longer* than expected is accepted and its tail ignored, so a newer -/// client that appends a field still reports usefully to an older hub. pub fn decode_receiver_report(payload: &[u8]) -> Option { if payload.len() < RECEIVER_REPORT_LEN { return None; @@ -657,6 +810,8 @@ pub fn decode_receiver_report(payload: &[u8]) -> Option { incomplete: u32_at(12), never_arrived: u32_at(16), rtt_ms: u32_at(20), + video_queue: delay::DelaySummary::decode(&payload[24..])?, + audio_queue: delay::DelaySummary::decode(&payload[24 + delay::DELAY_SUMMARY_LEN..])?, }) } @@ -720,6 +875,18 @@ mod media_control_tests { incomplete: 12, never_arrived: 1, rtt_ms: 182, + video_queue: delay::DelaySummary { + frames: 59, + p50_us: 2_100, + p95_us: 9_800, + max_us: 28_800, + }, + audio_queue: delay::DelaySummary { + frames: 200, + p50_us: 1_900, + p95_us: 15_300, + max_us: 41_200, + }, } } @@ -740,16 +907,6 @@ mod media_control_tests { } } - #[test] - fn a_longer_report_is_read_and_its_tail_ignored() { - // So a newer client that appends a field still reports usefully to a - // hub that predates it. - let mut buf = Vec::new(); - encode_receiver_report(&mut buf, &report()); - buf.extend_from_slice(&[0xAA; 8]); - assert_eq!(decode_receiver_report(&buf), Some(report())); - } - #[test] fn loss_counts_every_frame_that_did_not_arrive_whole() { // An incomplete frame is a lost frame. It cost bandwidth and produced no @@ -1154,3 +1311,79 @@ mod media_control_tests { assert_eq!(codec_name(CODEC_PYROWAVE), "pyrowave"); } } + +#[cfg(test)] +mod media_payload_tests { + use super::*; + + #[test] + fn an_ipc_frame_carries_its_producer_compatibly() { + let plain = encode_ipc_frame(STREAM_VIDEO, CODEC_AV1, FLAG_KEYFRAME, 7, 640, 360, &[1, 2, 3]); + let tagged = + encode_ipc_frame_from(4242, STREAM_VIDEO, CODEC_AV1, FLAG_KEYFRAME, 7, 640, 360, &[1, 2, 3]); + assert_eq!(decode_ipc_frame(&plain).unwrap().pid, None); + let d = decode_ipc_frame(&tagged).unwrap(); + assert_eq!((d.pid, d.data, d.flags), (Some(4242), &[1u8, 2, 3][..], FLAG_KEYFRAME)); + // What an older reader sees: the same header apart from the reserved + // byte, the same data, and bytes after it that it never reads. + assert_eq!(&tagged[..7], &plain[..7]); + assert_eq!(&tagged[8..plain.len()], &plain[8..]); + // A flag promising a pid that is not there is not a frame. + assert!(decode_ipc_frame(&tagged[..tagged.len() - 1]).is_none()); + assert_eq!(capture_cmd_socket(12), "/tmp/nescapture-cmd-12.sock"); + } + + #[test] + fn an_ipc_frame_carries_its_stamp_whole() { + // A u64 of microseconds, so a stamp past 2^32 must come back intact: + // the width is the reason it changed. + let stamp = 0x0000_0123_4567_89AB; + let ipc = encode_ipc_frame( + STREAM_VIDEO, + CODEC_AV1, + FLAG_KEYFRAME, + stamp, + 1920, + 1080, + &[7; 5], + ); + assert_eq!(ipc.len(), IPC_HEADER_LEN + 5); + let d = decode_ipc_frame(&ipc).expect("valid"); + assert_eq!( + (d.stamp, d.width, d.height, d.data), + (stamp, 1920, 1080, &[7u8; 5][..]) + ); + assert!( + decode_ipc_frame(&ipc[..IPC_HEADER_LEN + 4]).is_none(), + "a short frame was read" + ); + } + + #[test] + fn a_video_payload_round_trips_and_keeps_its_flags_where_the_hub_looks() { + let head = VideoHead { + codec: CODEC_H265, + flags: FLAG_KEYFRAME, + captured_us: 9_876_543_210, + width: 2560, + height: 1440, + }; + let payload = encode_video_payload(&head, &[1, 2, 3]); + assert_eq!( + decode_video_payload(&payload), + Some((head, &[1u8, 2, 3][..])) + ); + assert!(reliable::video_is_keyframe(&payload)); + assert!(decode_video_payload(&payload[..VIDEO_PAYLOAD_HDR_LEN - 1]).is_none()); + } + + #[test] + fn an_audio_payload_carries_its_graph_position() { + let payload = encode_audio_payload(48_000 * 3600, &[0xFC, 0xFF]); + assert_eq!( + decode_audio_payload(&payload), + Some((48_000 * 3600, &[0xFCu8, 0xFF][..])) + ); + assert!(decode_audio_payload(&payload[..7]).is_none()); + } +} diff --git a/crates/nesprotocol/src/lifecycle.rs b/crates/nesprotocol/src/lifecycle.rs index b55c3510..e88a2aa2 100644 --- a/crates/nesprotocol/src/lifecycle.rs +++ b/crates/nesprotocol/src/lifecycle.rs @@ -80,6 +80,15 @@ pub const ATTACH_PORT: u32 = 7001; /// named at the handshake and not found as a launch that cannot start. pub const CONTROL_VERSION: u32 = 5; +/// The variable a launched process finds its launch id in. +/// +/// Set by the guest on every launch, over anything the caller put in the +/// launch's own environment, because the id on the message is the one every +/// report about the launch carries. What reads it: the compositor, to say +/// which launch a window belongs to (`/proc//environ` of the window's +/// owner), and so a client can find its own window. ref(d-0085) +pub const LAUNCH_ID_ENV: &str = "NESTRI_LAUNCH_ID"; + /// The command to run, and who runs it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Exec { @@ -397,6 +406,15 @@ pub enum GuestToHost { #[serde(flatten)] exit: Exit, }, + /// Everything a [`HostToGuest::Mount`] for this launch named is in place. + /// + /// Answers that command and nothing else: the boot descriptor's own mounts + /// are still answered by [`GuestToHost::Mounted`], once. + LaunchMounted { id: LaunchId }, + /// A [`HostToGuest::Mount`] for this launch could not be carried out, in + /// the operating system's words. The box stays up: a game whose image will + /// not mount is one launch that cannot start, not a box that cannot work. + LaunchMountFailed { id: LaunchId, reason: String }, /// How a client reaches this box's media, once it is known. Ticket { ticket: String }, /// How many clients are watching this box. Sent once when first known and @@ -430,6 +448,27 @@ pub enum HostToGuest { exec: Exec, on_exit: OnExit, }, + /// Mount content for a launch into a box that is already running. + /// + /// How a second game reaches a box: the host already holds its build, and + /// the box mounts it on request rather than at boot. ref(d-0085) The same + /// shapes as the descriptor's, carried out in the same order (images, then + /// overlays), and answered with [`GuestToHost::LaunchMounted`] or + /// [`GuestToHost::LaunchMountFailed`] for this id. + /// + /// A device here may also be a regular file inside a share, which the + /// guest attaches to a loop device first: a box cannot be given a new + /// block device once it has booted. + /// + /// Added without a version bump, as an addition: a guest that predates it + /// skips the line, and the host's wait for the answer is what notices. + Mount { + id: LaunchId, + #[serde(default)] + images: Vec, + #[serde(default)] + overlays: Vec, + }, /// Stop one launch. Idempotent, and does not end the session. Stop { id: LaunchId }, /// Stop one launch and start it again with the same command. @@ -551,6 +590,47 @@ mod tests { } } + /// A mount for a running box is named by its launch on the wire, both ways, + /// and an empty list may be left out. + #[test] + fn a_mount_into_a_running_box_round_trips_by_launch() { + let mount = HostToGuest::Mount { + id: LaunchId::new("launch_01"), + images: Vec::new(), + overlays: vec![Overlay { + lower: "/nestri/launch-images/launch_01/build.erofs".into(), + upper: "/nestri/launches/launch_01/upper.ext4".into(), + at: "/nestri/installs/launch_01".into(), + }], + }; + let line = to_line(&mount).unwrap(); + assert!(line.contains(r#""type":"mount""#), "{line}"); + assert_eq!(from_line::(&line).unwrap(), mount); + + let bare: HostToGuest = from_line(r#"{"type":"mount","id":"launch_02"}"#).unwrap(); + assert_eq!( + bare, + HostToGuest::Mount { + id: LaunchId::new("launch_02"), + images: Vec::new(), + overlays: Vec::new(), + } + ); + + let failed = GuestToHost::LaunchMountFailed { + id: LaunchId::new("launch_01"), + reason: "ENOENT".into(), + }; + let line = to_line(&failed).unwrap(); + assert!(line.contains(r#""type":"launch_mount_failed""#), "{line}"); + assert_eq!(from_line::(&line).unwrap(), failed); + let line = to_line(&GuestToHost::LaunchMounted { + id: LaunchId::new("launch_01"), + }) + .unwrap(); + assert!(line.contains(r#""type":"launch_mounted""#), "{line}"); + } + #[test] fn a_line_round_trips() { let line = to_line(&HostToGuest::Boot { diff --git a/crates/nesprotocol/src/pyrowave.rs b/crates/nesprotocol/src/pyrowave.rs index 36bc97a5..241ffbb5 100644 --- a/crates/nesprotocol/src/pyrowave.rs +++ b/crates/nesprotocol/src/pyrowave.rs @@ -71,8 +71,9 @@ pub const ABANDON_MS: u64 = 100; /// Datagram kind, beside `DGRAM_VIDEO` and `DGRAM_AUDIO`. pub const DGRAM_PYROWAVE: u8 = 2; -/// `[kind][seq u16][index u16][total u16][critical u16][ts_ms u32][flags]`. -pub const PYRO_DGRAM_HDR_LEN: usize = 14; +/// `[kind][seq u16][index u16][total u16][critical u16][captured_us u64] +/// [sent_us u64][flags]`. +pub const PYRO_DGRAM_HDR_LEN: usize = 26; /// Bound on datagrams per frame, about 19 MB at a typical MTU. Past it a /// frame did not come from a sender of ours, and the bound keeps a corrupt @@ -114,7 +115,8 @@ pub const MAX_IN_FLIGHT: usize = 8; /// The per-frame fields of the IPC header. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FrameMeta { - pub ts_ms: u32, + /// When the frame was presented, on the sender's [`clock`](crate::clock). + pub captured_us: u64, pub width: u16, pub height: u16, } @@ -213,7 +215,7 @@ pub fn chunk_frame( STREAM_PYROWAVE, CODEC_PYROWAVE, 0, - meta.ts_ms, + meta.captured_us, meta.width, meta.height, &body, @@ -337,7 +339,7 @@ impl FrameAssembler { } let meta = FrameMeta { - ts_ms: ipc.timestamp_ms, + captured_us: ipc.stamp, width: ipc.width, height: ipc.height, }; @@ -430,7 +432,10 @@ pub struct PyroDatagramHeader { /// broken on frames that arrived complete. The sender knows which /// datagrams those bands went in. pub critical: u16, - pub ts_ms: u32, + /// When the frame was presented, on the sender's [`clock`](crate::clock). + pub captured_us: u64, + /// When the hub handed this datagram to QUIC, on the same clock. + pub sent_us: u64, pub flags: u8, } @@ -441,8 +446,9 @@ pub fn write_pyro_header(buf: &mut [u8], h: &PyroDatagramHeader) { buf[3..5].copy_from_slice(&h.index.to_le_bytes()); buf[5..7].copy_from_slice(&h.total.to_le_bytes()); buf[7..9].copy_from_slice(&h.critical.to_le_bytes()); - buf[9..13].copy_from_slice(&h.ts_ms.to_le_bytes()); - buf[13] = h.flags; + buf[9..17].copy_from_slice(&h.captured_us.to_le_bytes()); + buf[17..25].copy_from_slice(&h.sent_us.to_le_bytes()); + buf[25] = h.flags; } /// Split a PyroWave datagram into header and payload. `None` for anything that @@ -457,8 +463,9 @@ pub fn decode_pyro_datagram(buf: &[u8]) -> Option<(PyroDatagramHeader, &[u8])> { index: u16_at(3), total: u16_at(5), critical: u16_at(7), - ts_ms: u32::from_le_bytes([buf[9], buf[10], buf[11], buf[12]]), - flags: buf[13], + captured_us: u64::from_le_bytes(buf[9..17].try_into().unwrap()), + sent_us: u64::from_le_bytes(buf[17..25].try_into().unwrap()), + flags: buf[25], }; if h.total == 0 || usize::from(h.total) > PYRO_MAX_DATAGRAMS || h.index >= h.total { return None; @@ -520,6 +527,7 @@ pub fn pack_datagrams( seq: u16, frame: &PyroFrame, max_datagram: usize, + sent_us: u64, ) -> Result { let payload = max_datagram.saturating_sub(PYRO_DGRAM_HDR_LEN); if payload < MIN_DGRAM_PAYLOAD { @@ -582,7 +590,8 @@ pub fn pack_datagrams( index: index as u16, total, critical: critical_entries as u16, - ts_ms: frame.meta.ts_ms, + captured_us: frame.meta.captured_us, + sent_us, flags: flags | duplicate, }, ); @@ -652,7 +661,7 @@ impl> AsRef<[u8]> for CollectedPacket

{ #[derive(Debug, Clone, PartialEq, Eq)] pub struct CollectedFrame

{ pub seq: u16, - pub ts_ms: u32, + pub captured_us: u64, /// Datagrams that arrived, out of `total`. pub received: u16, pub total: u16, @@ -698,7 +707,7 @@ pub struct CollectorStats { struct Flight

{ seq: u16, - ts_ms: u32, + captured_us: u64, total: u16, critical: u16, slots: Vec>, @@ -780,7 +789,7 @@ impl> PyroCollector

{ pos, Flight { seq: h.seq, - ts_ms: h.ts_ms, + captured_us: h.captured_us, total: h.total, critical: h.critical, slots: (0..h.total).map(|_| None).collect(), @@ -912,7 +921,7 @@ impl> PyroCollector

{ CollectedFrame { seq: flight.seq, - ts_ms: flight.ts_ms, + captured_us: flight.captured_us, received: flight.received, total: flight.total, critical_complete, @@ -939,7 +948,7 @@ mod tests { PyroFrame { frame: 7, meta: FrameMeta { - ts_ms: 1234, + captured_us: 1234, width: 1920, height: 1080, }, @@ -1122,7 +1131,7 @@ mod tests { #[test] fn a_packet_that_fits_takes_one_datagram_whole() { let frame = frame_of(&[8, 500, 1200 - PYRO_DGRAM_HDR_LEN, 300], 0); - let d = pack_datagrams(3, &frame, 1200).unwrap(); + let d = pack_datagrams(3, &frame, 1200, 77).unwrap(); let got = unpack(&d); assert_eq!(d.total, 4); assert_eq!(got.len(), 4); @@ -1130,7 +1139,8 @@ mod tests { assert_eq!(h.index as usize, i); assert_eq!(h.flags, 0); assert_eq!(h.seq, 3); - assert_eq!(h.ts_ms, 1234); + assert_eq!(h.captured_us, 1234); + assert_eq!(h.sent_us, 77, "the send time did not reach the header"); assert_eq!( p.as_slice(), frame.packet(i), @@ -1142,7 +1152,7 @@ mod tests { #[test] fn critical_datagrams_go_twice_under_the_same_index() { let frame = frame_of(&[8, 900, 900, 900, 900], 2); - let got = unpack(&pack_datagrams(0, &frame, 1200).unwrap()); + let got = unpack(&pack_datagrams(0, &frame, 1200, 0).unwrap()); assert_eq!(got.len(), 5 + 2); let tail: Vec<_> = got[5..].iter().map(|(h, _)| (h.index, h.flags)).collect(); assert_eq!(tail, [(0, FLAG_DUPLICATE), (1, FLAG_DUPLICATE)]); @@ -1153,7 +1163,7 @@ mod tests { #[test] fn an_oversized_block_goes_in_marked_pieces() { let frame = frame_of(&[8, 2488, 100], 2); - let got = unpack(&pack_datagrams(0, &frame, 1200).unwrap()); + let got = unpack(&pack_datagrams(0, &frame, 1200, 0).unwrap()); let flags: Vec = got.iter().map(|(h, _)| h.flags).collect(); let part = FLAG_PART; assert_eq!( @@ -1179,12 +1189,12 @@ mod tests { fn packing_refuses_a_path_too_small_or_a_frame_too_large() { let frame = frame_of(&[8], 0); assert!(matches!( - pack_datagrams(0, &frame, PYRO_DGRAM_HDR_LEN + MIN_DGRAM_PAYLOAD - 1), + pack_datagrams(0, &frame, PYRO_DGRAM_HDR_LEN + MIN_DGRAM_PAYLOAD - 1, 0), Err(PackError::PathTooSmall { .. }) )); let frame = frame_of(&vec![100; PYRO_MAX_DATAGRAMS + 1], 0); assert!(matches!( - pack_datagrams(0, &frame, 1200), + pack_datagrams(0, &frame, 1200, 0), Err(PackError::TooMany { .. }) )); } @@ -1197,7 +1207,8 @@ mod tests { index: 0, total: 2, critical: 1, - ts_ms: 0, + captured_us: 0, + sent_us: 0, flags: 0, }; write_pyro_header(&mut buf, &ok); @@ -1232,7 +1243,7 @@ mod tests { type Wire = Vec<(PyroDatagramHeader, Vec)>; fn wire(seq: u16, frame: &PyroFrame) -> Wire { - unpack(&pack_datagrams(seq, frame, 1200).unwrap()) + unpack(&pack_datagrams(seq, frame, 1200, 0).unwrap()) } fn packets_of(f: &CollectedFrame>) -> Vec> { diff --git a/crates/nesprotocol/src/reliable.rs b/crates/nesprotocol/src/reliable.rs index 00768f3e..e64568b2 100644 --- a/crates/nesprotocol/src/reliable.rs +++ b/crates/nesprotocol/src/reliable.rs @@ -81,11 +81,27 @@ pub const MAX_KEYFRAME_STREAMS_IN_FLIGHT: usize = 2; /// Offset of the flags byte in a video frame payload. /// -/// The payload the hub broadcasts is `[1B codec] [1B flags] [4B ts_ms LE] -/// [2B width LE] [2B height LE] [data]` — see `encode_ipc_frame`, whose header -/// fields these mirror. +/// The payload the hub broadcasts is [`crate::encode_video_payload`]'s. pub const VIDEO_PAYLOAD_FLAGS_OFFSET: usize = 1; +/// `[1B STREAM_KEYFRAME][1B STREAM_VERSION][8B sent_us LE]`, then the frame body. +/// +/// `sent_us` is when the hub began writing the stream, on its +/// [`clock`](crate::clock). A keyframe has arrived when its last byte lands, +/// which on a reliable stream includes any retransmission, so its queueing +/// delay reads higher than a datagram's on the same path. That is what it +/// really cost, and why keyframes are measured apart from datagrams. +pub const KEYFRAME_STREAM_HDR_LEN: usize = 10; + +/// The header a keyframe stream opens with. +pub fn keyframe_stream_header(sent_us: u64) -> [u8; KEYFRAME_STREAM_HDR_LEN] { + let mut header = [0u8; KEYFRAME_STREAM_HDR_LEN]; + header[0] = crate::STREAM_KEYFRAME; + header[1] = crate::STREAM_VERSION; + header[2..].copy_from_slice(&sent_us.to_le_bytes()); + header +} + /// Whether a video frame payload warrants a reliable stream over datagrams. /// /// True for a keyframe, and for the reconfiguration frame that follows a codec @@ -120,7 +136,10 @@ pub fn video_is_keyframe(payload: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::{CODEC_H264, STREAM_VIDEO, decode_ipc_frame, encode_ipc_frame}; + use crate::{ + CODEC_H264, STREAM_VIDEO, VideoHead, decode_ipc_frame, encode_ipc_frame, + encode_video_payload, + }; /// Build the payload the hub broadcasts out of an IPC frame, the way /// `ipc_listener` does, so the offset is checked against its real producer @@ -136,12 +155,16 @@ mod tests { &[9, 9, 9], ); let decoded = decode_ipc_frame(&ipc).expect("valid ipc frame"); - let mut payload = vec![decoded.codec, decoded.flags]; - payload.extend_from_slice(&decoded.timestamp_ms.to_le_bytes()); - payload.extend_from_slice(&decoded.width.to_le_bytes()); - payload.extend_from_slice(&decoded.height.to_le_bytes()); - payload.extend_from_slice(decoded.data); - payload + encode_video_payload( + &VideoHead { + codec: decoded.codec, + flags: decoded.flags, + captured_us: decoded.stamp, + width: decoded.width, + height: decoded.height, + }, + decoded.data, + ) } #[test] diff --git a/crates/nesprotocol/src/stats.rs b/crates/nesprotocol/src/stats.rs index 5879319e..46dab1e9 100644 --- a/crates/nesprotocol/src/stats.rs +++ b/crates/nesprotocol/src/stats.rs @@ -120,12 +120,18 @@ pub struct VideoBreakdown { /// eight seconds old: loss cannot show a queue, only its overflow. Anything /// but near-zero here means latency is being spent on backlog. pub backlog_ms: u16, + /// The bottleneck capacity the controller has measured from frame + /// dispersion, in kbps; 0 before any frame has measured it. + pub capacity_kbps: u32, + /// The rate the path last delivered while it was queueing, in kbps: the + /// share it proved it could carry. 0 before it has queued at all. + pub delivery_kbps: u32, } /// `[4B key_bps][4B delta_bps][1B keyframes][4B target][4B ceiling][1B reason] /// [1B manual][4B box_ceiling][2B pipeline_p50][2B pipeline_p95][2B pipeline_max] -/// [2B backlog_ms]` -pub const VIDEO_BREAKDOWN_LEN: usize = 31; +/// [2B backlog_ms][4B capacity_kbps][4B delivery_kbps]` +pub const VIDEO_BREAKDOWN_LEN: usize = 39; pub fn encode_video_breakdown(buf: &mut Vec, b: &VideoBreakdown) { buf.reserve(VIDEO_BREAKDOWN_LEN); @@ -141,6 +147,8 @@ pub fn encode_video_breakdown(buf: &mut Vec, b: &VideoBreakdown) { buf.extend_from_slice(&b.pipeline_p95_ms.to_le_bytes()); buf.extend_from_slice(&b.pipeline_max_ms.to_le_bytes()); buf.extend_from_slice(&b.backlog_ms.to_le_bytes()); + buf.extend_from_slice(&b.capacity_kbps.to_le_bytes()); + buf.extend_from_slice(&b.delivery_kbps.to_le_bytes()); } /// Decoded stats from any source. @@ -211,6 +219,8 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) { pipeline_p95_ms: u16::from_le_bytes([d[25], d[26]]), pipeline_max_ms: u16::from_le_bytes([d[27], d[28]]), backlog_ms: u16::from_le_bytes([d[29], d[30]]), + capacity_kbps: u32_at(31), + delivery_kbps: u32_at(35), }); } } @@ -236,6 +246,8 @@ mod breakdown_tests { pipeline_p95_ms: 24, pipeline_max_ms: 61, backlog_ms: 40, + capacity_kbps: 23_500, + delivery_kbps: 7_800, } } diff --git a/crates/nesprotocol/src/window.rs b/crates/nesprotocol/src/window.rs new file mode 100644 index 00000000..ce6fe993 --- /dev/null +++ b/crates/nesprotocol/src/window.rs @@ -0,0 +1,814 @@ +//! Windows: one box, several pictures, ref(d-0085). +//! +//! A box runs a person's launches, and every window a launch opens is a video +//! stream of its own. A client connection shows one window: nesktop runs one +//! process per local window, each with its own connections to the hub, each +//! subscribed to one remote window. +//! +//! # Window ids +//! +//! A [`WindowId`] is a `u16`, minted by the box and never reused within one run +//! of it. Two bytes because it rides on every video datagram, next to a frame +//! sequence number that is also two bytes; 65,535 windows in one run of a box +//! is not a limit a person reaches by opening games. Window `0` is *the* window: +//! the first one, and the only one any component knew about before windows had +//! ids. Everything about window `0` is encoded exactly as it was before this +//! module existed, which is the whole compatibility story: +//! +//! - **Old client, new hub.** An old client never sends [`MSG_WINDOW_SUBSCRIBE`], +//! so the hub sends it the box's *primary picture* in window `0`'s +//! encoding, byte-identical to what it always received. The primary picture +//! is one encoder's frames: the first to draw, replaced only after it has +//! been silent for a second by one that has drawn for at least 250 ms +//! (preferring the newest), with a keyframe and a fresh sequence on every +//! switch. It is usually window `0` but need not be: a launcher window that +//! never draws can take id `0`. With one game it is that game. Such a +//! client is never sent a [`STREAM_WINDOWS`] stream: the hub opens one only +//! for a client that has subscribed. +//! - **New client, old hub.** The old hub logs [`MSG_WINDOW_SUBSCRIBE`] as an +//! unknown control message and carries on, sends window `0` in the old +//! encoding, and never opens a windows stream. A new client therefore treats +//! "no windows stream" as "a single-window box" and shows what arrives. +//! +//! # Video +//! +//! Frames of window `0` keep [`DGRAM_VIDEO`] and [`MSG_DATA`]. Any other window +//! uses a sibling kind that says "a window id follows": +//! +//! ```text +//! datagram, window 0: [0x00 DGRAM_VIDEO] [2B seq][2B index][2B total][8B sent_us] [fragment] +//! datagram, window n: [0x04 DGRAM_VIDEO_WINDOW] [2B seq][2B index][2B total][8B sent_us][2B window] [fragment] +//! +//! frame body, window 0: [0x00 MSG_DATA] [2B seq] [video payload] +//! frame body, window n: [0x01 MSG_WINDOW_DATA] [2B seq][2B window] [video payload] +//! ``` +//! +//! All integers little-endian. The window id is in both, for the same reason the +//! sequence number is: reassembly needs it before the body exists, and the +//! keyframe stream (see [`reliable`](crate::reliable)) carries a body with no +//! datagram header around it. Sequence numbers count per window. +//! +//! # Window lifecycle (box → hub → client) +//! +//! On a uni stream of its own, `[1B STREAM_WINDOWS][1B STREAM_VERSION]` then +//! length-prefixed frames ([`encode_frame`](crate::encode_frame)), whose type +//! byte is one of the `MSG_WINDOW_*` below and whose seq is unused (0). The hub +//! opens it on the video connection of a client that has subscribed, and starts +//! it with an [`WindowEvent::Opened`] for every window already open, so a +//! client that arrives late learns the box as it stands. Every payload starts +//! with the window id: +//! +//! ```text +//! OPENED 0x20: [2B window][2B width][2B height][2B len][launch id][2B len][title][2B len][app id] +//! TITLE 0x21: [2B window][2B len][title] +//! ICON 0x22: [2B window][2B width][2B height][4B rgba len][rgba, width*height*4] +//! SIZE 0x23: [2B window][2B width][2B height] +//! CLOSED 0x24: [2B window] +//! ``` +//! +//! Strings are UTF-8. The launch id is the `lifecycle::LaunchId` the caller minted, so a +//! client knows which launch a window belongs to; the app id is the Wayland +//! `app_id` (X11 `WM_CLASS` under Xwayland). The icon is straight RGBA, the way +//! [`CURSOR_IMAGE`](crate::input::CURSOR_IMAGE) carries a cursor. +//! +//! # What carries no window id +//! +//! - **PyroWave** has a datagram header of its own (see +//! [`pyrowave`](crate::pyrowave)) and no windowed form: for now it streams +//! window `0` only. A windowed PyroWave format is future work. +//! - **The keyframe stream** carries the window id only in the body. A client +//! drops any body, from a datagram or a keyframe stream, whose window id is +//! not its subscription. +//! - **Feedback, receiver reports and keyframe requests** ([`DGRAM_FEEDBACK`], +//! [`MSG_RECEIVER_REPORT`], [`MSG_IDR_REQUEST`]) always mean the +//! connection's subscribed window. A resubscribe resets the connection's +//! reassembly, loss and rate state: none of it describes the new window. +//! +//! [`DGRAM_FEEDBACK`]: crate::datagram::DGRAM_FEEDBACK +//! [`MSG_RECEIVER_REPORT`]: crate::MSG_RECEIVER_REPORT +//! [`MSG_IDR_REQUEST`]: crate::MSG_IDR_REQUEST +//! +//! # Fragmenting +//! +//! A windowed datagram's header is two bytes longer, so a sender sizes +//! fragments with [`max_fragment_payload`] for the window it is sending, never +//! from `DGRAM_HDR_LEN` alone, or the datagram outgrows the path. +//! +//! # Known limits (phase 1) +//! +//! - **One bitrate for the box.** Every window's encoder is given the same +//! target, decided by the one controller, so one client's congestion sets +//! every window's rate. Per-window rate is follow-up work. A keyframe +//! request does go only to the encoder of the requesting client's window. +//! - **nescope and neshub ship together** in the guest image and must not ship +//! apart: a nescope that sends window messages to a hub that predates them +//! is not a supported pairing, even though both ends tolerate it. +//! +//! # Lifetime of ids and subscriptions +//! +//! Ids do not wrap within a run of a box: a box that has minted 65,535 window +//! ids refuses to open more. When the box restarts its ids mean nothing, and +//! the hub tears down every subscription. +//! +//! # Forward compatibility +//! +//! Every decoder here accepts trailing bytes after the fields it knows, so a +//! later version may append fields to any message. +//! +//! # Subscribing (client → hub) +//! +//! [`MSG_WINDOW_SUBSCRIBE`] `[2B window]` on the control stream +//! ([`BIDI_CONTROL`](crate::BIDI_CONTROL)). A connection that never sends it is +//! subscribed to window `0`. Sending it again moves the subscription. Order on +//! a resubscribe: the hub switches the connection to the new window first, then +//! requests a keyframe for it, since nothing the client holds predicts it; a +//! keyframe requested before the switch could arrive for the old window. +//! +//! # Input +//! +//! Input events stay untagged on the wire from the client: a connection's +//! input belongs to the window that client is subscribed to, and the hub knows +//! which that is. Toward the box the hub tags it, by sending +//! [`INPUT_WINDOW_FOCUS`](crate::input::INPUT_WINDOW_FOCUS) `[0x06][2B window]` +//! in the input stream before any event that comes from a different window than +//! the last one it forwarded. Every event after it, until the next, belongs to +//! that window. Three rules follow: +//! +//! - Nothing that merges events (mouse motion coalescing, batching) may merge +//! across a focus tag: the events on either side belong to different windows. +//! - Gamepad messages go to the focused window. +//! - nescope resets its current window to `0` when its input IPC reconnects, +//! so a hub that predates tags is still understood. +//! +//! A client's [`INPUT_OUTPUT_SIZE`](crate::input::INPUT_OUTPUT_SIZE) is +//! rewritten the same way: the hub sends the box +//! [`INPUT_WINDOW_OUTPUT_SIZE`](crate::input::INPUT_WINDOW_OUTPUT_SIZE) +//! `[0x05][2B window][2B width][2B height]`. For window `0` it sends the old +//! `INPUT_OUTPUT_SIZE` instead, so a compositor that predates windows keeps +//! resizing its one output. A client never sends the windowed form. +//! +//! # Cursor +//! +//! No change. The cursor stream goes to a connection and describes the pointer +//! as that connection's window sees it, in that window's coordinates; a +//! client shows one window, so it has one cursor. The hub forwards a cursor +//! update to the clients subscribed to the window it came from, which is the +//! box's business to say on its internal path, not the client's to decode. + +use crate::datagram::{decode_datagram, DatagramHeader, DGRAM_HDR_LEN, DGRAM_VIDEO}; +use crate::MSG_DATA; + +/// Names one window for one run of a box. See the module docs. +pub type WindowId = u16; + +/// The window every component knew about before windows had ids. +pub const WINDOW_DEFAULT: WindowId = 0; + +/// A video datagram of a window other than `0`: [`DGRAM_VIDEO`] with a window +/// id after the common header. +pub const DGRAM_VIDEO_WINDOW: u8 = 4; + +/// Header length of a [`DGRAM_VIDEO_WINDOW`] datagram. +pub const DGRAM_WINDOW_HDR_LEN: usize = DGRAM_HDR_LEN + 2; + +/// A video frame body of a window other than `0`: [`MSG_DATA`] with a window +/// id before the payload. +pub const MSG_WINDOW_DATA: u8 = 0x01; + +/// Which window this connection shows (client → hub, control stream). +pub const MSG_WINDOW_SUBSCRIBE: u8 = 0x17; + +/// Uni stream (hub → client) carrying the `MSG_WINDOW_*` lifecycle messages. +pub const STREAM_WINDOWS: u8 = 8; + +pub const MSG_WINDOW_OPENED: u8 = 0x20; +pub const MSG_WINDOW_TITLE: u8 = 0x21; +pub const MSG_WINDOW_ICON: u8 = 0x22; +pub const MSG_WINDOW_SIZE: u8 = 0x23; +pub const MSG_WINDOW_CLOSED: u8 = 0x24; + +/// Largest icon accepted, per side. A window icon is a few dozen pixels; this +/// bounds the allocation a corrupt length could ask for. +pub const MAX_ICON_SIDE: u16 = 512; + +/// The most fragment bytes a video datagram of `window` can carry when the +/// path takes datagrams of at most `max_dgram` bytes. Zero if not even the +/// header fits. +pub fn max_fragment_payload(window: WindowId, max_dgram: usize) -> usize { + max_dgram.saturating_sub(video_datagram_header_len(window)) +} + +/// How long the header of a video datagram of `window` is. +pub fn video_datagram_header_len(window: WindowId) -> usize { + if window == WINDOW_DEFAULT { + DGRAM_HDR_LEN + } else { + DGRAM_WINDOW_HDR_LEN + } +} + +/// Write a video datagram header for `window` into the start of `buf`, and +/// return its length. `header.kind` is ignored: the kind follows the window. +/// Window `0` writes exactly what +/// [`write_datagram_header`](crate::datagram::write_datagram_header) writes for +/// [`DGRAM_VIDEO`]. +/// +/// The header is two bytes longer for any other window: size the fragment +/// after it with [`max_fragment_payload`], not from `DGRAM_HDR_LEN`. +/// +/// # Panics +/// If `buf` is shorter than [`video_datagram_header_len`]. +pub fn write_video_datagram_header( + buf: &mut [u8], + header: &DatagramHeader, + window: WindowId, +) -> usize { + let kind = if window == WINDOW_DEFAULT { + DGRAM_VIDEO + } else { + DGRAM_VIDEO_WINDOW + }; + crate::datagram::write_datagram_header(buf, &DatagramHeader { kind, ..*header }); + if window == WINDOW_DEFAULT { + DGRAM_HDR_LEN + } else { + buf[DGRAM_HDR_LEN..DGRAM_WINDOW_HDR_LEN].copy_from_slice(&window.to_le_bytes()); + DGRAM_WINDOW_HDR_LEN + } +} + +/// Split a video datagram of any window into header, window and fragment. +/// +/// Accepts [`DGRAM_VIDEO`] (window `0`) and [`DGRAM_VIDEO_WINDOW`], and reports +/// both with `kind == DGRAM_VIDEO` so a receiver has one kind to handle. `None` +/// for any other kind, and for anything [`decode_datagram`] rejects. +pub fn decode_video_datagram(buf: &[u8]) -> Option<(DatagramHeader, WindowId, &[u8])> { + let (mut header, rest) = decode_datagram(buf)?; + match header.kind { + DGRAM_VIDEO => Some((header, WINDOW_DEFAULT, rest)), + DGRAM_VIDEO_WINDOW => { + if rest.len() < 2 { + return None; + } + header.kind = DGRAM_VIDEO; + Some((header, u16::from_le_bytes([rest[0], rest[1]]), &rest[2..])) + } + _ => None, + } +} + +/// Build a video frame body for `window`. Window `0` is exactly +/// [`encode_frame_body`](crate::encode_frame_body) with [`MSG_DATA`]. +pub fn encode_video_frame_body(buf: &mut Vec, window: WindowId, seq: u16, payload: &[u8]) { + if window == WINDOW_DEFAULT { + crate::encode_frame_body(buf, MSG_DATA, seq, payload); + } else { + buf.reserve(5 + payload.len()); + buf.push(MSG_WINDOW_DATA); + buf.extend_from_slice(&seq.to_le_bytes()); + buf.extend_from_slice(&window.to_le_bytes()); + buf.extend_from_slice(payload); + } +} + +/// Parse a video frame body of any window: `(window, seq, payload)`. `None` for +/// a body of another type or one too short to hold its header. +pub fn decode_video_frame_body(body: &[u8]) -> Option<(WindowId, u16, &[u8])> { + let (msg_type, seq, rest) = crate::decode_frame(body)?; + match msg_type { + MSG_DATA => Some((WINDOW_DEFAULT, seq, rest)), + MSG_WINDOW_DATA if rest.len() >= 2 => { + Some((u16::from_le_bytes([rest[0], rest[1]]), seq, &rest[2..])) + } + _ => None, + } +} + +/// `[2B window]`, the payload of [`MSG_WINDOW_SUBSCRIBE`]. +pub fn encode_subscribe(buf: &mut Vec, window: WindowId) { + buf.extend_from_slice(&window.to_le_bytes()); +} + +pub fn decode_subscribe(payload: &[u8]) -> Option { + Some(u16::from_le_bytes(payload.get(..2)?.try_into().ok()?)) +} + +/// What the box says about a window. See the module docs for the bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowEvent { + Opened { + window: WindowId, + width: u16, + height: u16, + launch_id: String, + title: String, + app_id: String, + }, + Title { + window: WindowId, + title: String, + }, + Icon { + window: WindowId, + width: u16, + height: u16, + /// `width * height * 4` bytes, row-major, straight (not premultiplied) + /// alpha. + rgba: Vec, + }, + Size { + window: WindowId, + width: u16, + height: u16, + }, + Closed { + window: WindowId, + }, +} + +impl WindowEvent { + pub fn window(&self) -> WindowId { + match self { + WindowEvent::Opened { window, .. } + | WindowEvent::Title { window, .. } + | WindowEvent::Icon { window, .. } + | WindowEvent::Size { window, .. } + | WindowEvent::Closed { window } => *window, + } + } + + /// The frame type byte this event travels under. + pub fn msg_type(&self) -> u8 { + match self { + WindowEvent::Opened { .. } => MSG_WINDOW_OPENED, + WindowEvent::Title { .. } => MSG_WINDOW_TITLE, + WindowEvent::Icon { .. } => MSG_WINDOW_ICON, + WindowEvent::Size { .. } => MSG_WINDOW_SIZE, + WindowEvent::Closed { .. } => MSG_WINDOW_CLOSED, + } + } + + /// Append the payload (no type byte). Strings longer than 65,535 bytes are + /// cut at a character boundary; an icon must be `width * height * 4` bytes + /// and no wider than [`MAX_ICON_SIDE`], or the receiver drops it. + pub fn encode_payload(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.window().to_le_bytes()); + match self { + WindowEvent::Opened { + width, + height, + launch_id, + title, + app_id, + .. + } => { + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + put_str(buf, launch_id); + put_str(buf, title); + put_str(buf, app_id); + } + WindowEvent::Title { title, .. } => put_str(buf, title), + WindowEvent::Icon { + width, + height, + rgba, + .. + } => { + debug_assert!(*width <= MAX_ICON_SIDE && *height <= MAX_ICON_SIDE); + debug_assert_eq!(rgba.len(), *width as usize * *height as usize * 4); + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + buf.extend_from_slice(&(rgba.len() as u32).to_le_bytes()); + buf.extend_from_slice(rgba); + } + WindowEvent::Size { width, height, .. } => { + buf.extend_from_slice(&width.to_le_bytes()); + buf.extend_from_slice(&height.to_le_bytes()); + } + WindowEvent::Closed { .. } => {} + } + } + + /// Append a whole length-prefixed frame, as written on [`STREAM_WINDOWS`]. + pub fn encode_frame(&self, buf: &mut Vec) { + let mut payload = Vec::new(); + self.encode_payload(&mut payload); + crate::encode_frame(buf, self.msg_type(), 0, &payload); + } + + /// Parse a payload of the given frame type. `None` for another type, a + /// truncated payload, a string that is not UTF-8, or an icon whose size + /// does not match its pixels. + pub fn decode(msg_type: u8, payload: &[u8]) -> Option { + let mut r = Reader(payload); + let window = r.u16()?; + Some(match msg_type { + MSG_WINDOW_OPENED => WindowEvent::Opened { + window, + width: r.u16()?, + height: r.u16()?, + launch_id: r.str()?, + title: r.str()?, + app_id: r.str()?, + }, + MSG_WINDOW_TITLE => WindowEvent::Title { + window, + title: r.str()?, + }, + MSG_WINDOW_ICON => { + let width = r.u16()?; + let height = r.u16()?; + let len = r.u32()? as usize; + if width > MAX_ICON_SIDE + || height > MAX_ICON_SIDE + || len != width as usize * height as usize * 4 + { + return None; + } + WindowEvent::Icon { + window, + width, + height, + rgba: r.take(len)?.to_vec(), + } + } + MSG_WINDOW_SIZE => WindowEvent::Size { + window, + width: r.u16()?, + height: r.u16()?, + }, + MSG_WINDOW_CLOSED => WindowEvent::Closed { window }, + _ => return None, + }) + } +} + +// ── Box-internal (nescope → hub) ──────────────────────────────────────────── +// +// Carried on the input socket beside the cursor messages, each as one +// length-prefixed message whose first byte is its type, the same way the +// lifecycle events above travel there. Never sent to a client. + +/// Window `[2B]` is owned by process `[4B pid]`: how the hub joins an encoder's +/// frames, tagged with its process, to the window they draw. +pub const MSG_WINDOW_PID: u8 = 0x28; + +/// The cursor messages after this one describe the pointer as window `[2B]` +/// sees it, until the next. A compositor that never sends it describes +/// window `0`. +pub const MSG_CURSOR_WINDOW: u8 = 0x29; + +/// `[MSG_WINDOW_PID][2B window][4B pid]`. +pub fn encode_window_pid(buf: &mut Vec, window: WindowId, pid: u32) { + buf.push(MSG_WINDOW_PID); + buf.extend_from_slice(&window.to_le_bytes()); + buf.extend_from_slice(&pid.to_le_bytes()); +} + +/// The payload after the type byte of [`MSG_WINDOW_PID`]. +pub fn decode_window_pid(payload: &[u8]) -> Option<(WindowId, u32)> { + let mut r = Reader(payload); + Some((r.u16()?, r.u32()?)) +} + +/// `[MSG_CURSOR_WINDOW][2B window]`. +pub fn encode_cursor_window(buf: &mut Vec, window: WindowId) { + buf.push(MSG_CURSOR_WINDOW); + buf.extend_from_slice(&window.to_le_bytes()); +} + +/// A lifecycle event as one box-internal message: `[type][payload]`. +pub fn encode_event_message(buf: &mut Vec, event: &WindowEvent) { + buf.push(event.msg_type()); + event.encode_payload(buf); +} + +fn put_str(buf: &mut Vec, s: &str) { + let mut end = s.len().min(u16::MAX as usize); + while !s.is_char_boundary(end) { + end -= 1; + } + buf.extend_from_slice(&(end as u16).to_le_bytes()); + buf.extend_from_slice(&s.as_bytes()[..end]); +} + +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + if self.0.len() < n { + return None; + } + let (head, tail) = self.0.split_at(n); + self.0 = tail; + Some(head) + } + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn str(&mut self) -> Option { + let n = self.u16()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datagram::write_datagram_header; + + fn header() -> DatagramHeader { + DatagramHeader { + kind: DGRAM_VIDEO, + seq: 0xBEEF, + index: 2, + total: 7, + sent_us: 0x0123_4567_89AB_CDEF, + } + } + + #[test] + fn window_zero_datagram_is_the_old_bytes() { + let mut old = [0u8; DGRAM_HDR_LEN]; + write_datagram_header(&mut old, &header()); + let mut new = [0xAAu8; DGRAM_WINDOW_HDR_LEN]; + let n = write_video_datagram_header(&mut new, &header(), WINDOW_DEFAULT); + assert_eq!(n, DGRAM_HDR_LEN); + assert_eq!(video_datagram_header_len(0), DGRAM_HDR_LEN); + assert_eq!(&new[..n], &old[..]); + } + + #[test] + fn video_datagram_round_trips_for_any_window() { + for window in [0u16, 1, 0x1234, u16::MAX] { + let mut buf = vec![0u8; video_datagram_header_len(window)]; + let n = write_video_datagram_header(&mut buf, &header(), window); + assert_eq!(n, buf.len()); + buf.extend_from_slice(&[9, 8, 7]); + let (h, w, frag) = decode_video_datagram(&buf).expect("decodes"); + assert_eq!(h, header()); + assert_eq!(w, window); + assert_eq!(frag, &[9, 8, 7]); + } + } + + #[test] + fn windowed_datagram_is_unknown_to_old_kind_checks() { + let mut buf = [0u8; DGRAM_WINDOW_HDR_LEN]; + write_video_datagram_header(&mut buf, &header(), 3); + assert_eq!(buf[0], DGRAM_VIDEO_WINDOW); + assert_ne!(buf[0], DGRAM_VIDEO); + assert_ne!(buf[0], crate::datagram::DGRAM_AUDIO); + assert_ne!(buf[0], crate::datagram::DGRAM_PYROWAVE); + assert_ne!(buf[0], crate::datagram::DGRAM_FEEDBACK); + } + + #[test] + fn truncated_video_datagrams_are_rejected() { + let mut buf = [0u8; DGRAM_WINDOW_HDR_LEN]; + write_video_datagram_header(&mut buf, &header(), 3); + assert!(decode_video_datagram(&buf[..DGRAM_HDR_LEN + 1]).is_none()); + assert!(decode_video_datagram(&buf[..DGRAM_HDR_LEN - 1]).is_none()); + // Audio is not video. + let mut audio = [0u8; DGRAM_HDR_LEN]; + write_datagram_header( + &mut audio, + &DatagramHeader { + kind: crate::datagram::DGRAM_AUDIO, + ..header() + }, + ); + assert!(decode_video_datagram(&audio).is_none()); + } + + #[test] + fn window_zero_body_is_the_old_bytes() { + let mut old = Vec::new(); + crate::encode_frame_body(&mut old, MSG_DATA, 42, &[1, 2, 3]); + let mut new = Vec::new(); + encode_video_frame_body(&mut new, 0, 42, &[1, 2, 3]); + assert_eq!(new, old); + } + + #[test] + fn video_body_round_trips_for_any_window() { + for window in [0u16, 7, u16::MAX] { + let mut body = Vec::new(); + encode_video_frame_body(&mut body, window, 42, &[1, 2, 3]); + assert_eq!(decode_video_frame_body(&body), Some((window, 42, &[1u8, 2, 3][..]))); + } + let mut body = Vec::new(); + encode_video_frame_body(&mut body, 7, 42, &[]); + assert_eq!(decode_video_frame_body(&body), Some((7, 42, &[][..]))); + assert_eq!(decode_video_frame_body(&body[..4]), None); + assert_eq!(decode_video_frame_body(&body[..2]), None); + assert_eq!(decode_video_frame_body(&[crate::MSG_IDR_REQUEST, 0, 0]), None); + } + + #[test] + fn subscribe_round_trips() { + let mut b = Vec::new(); + encode_subscribe(&mut b, 0x0302); + assert_eq!(b, [0x02, 0x03]); + assert_eq!(decode_subscribe(&b), Some(0x0302)); + assert_eq!(decode_subscribe(&b[..1]), None); + } + + fn events() -> Vec { + vec![ + WindowEvent::Opened { + window: 5, + width: 1920, + height: 1080, + launch_id: "launch-1".into(), + title: "Dome Keeper — ünïcode".into(), + app_id: "steam_app_1637320".into(), + }, + WindowEvent::Title { + window: 5, + title: "Paused".into(), + }, + WindowEvent::Icon { + window: 5, + width: 2, + height: 3, + rgba: (0..24).collect(), + }, + WindowEvent::Size { + window: 5, + width: 1280, + height: 720, + }, + WindowEvent::Closed { window: 5 }, + ] + } + + #[test] + fn window_events_round_trip_and_reject_truncation() { + for ev in events() { + let mut payload = Vec::new(); + ev.encode_payload(&mut payload); + assert_eq!(WindowEvent::decode(ev.msg_type(), &payload), Some(ev.clone())); + for cut in 0..payload.len() { + assert_eq!( + WindowEvent::decode(ev.msg_type(), &payload[..cut]), + None, + "{ev:?} cut at {cut}" + ); + } + + let mut frame = Vec::new(); + ev.encode_frame(&mut frame); + let len = u32::from_le_bytes(frame[..4].try_into().unwrap()) as usize; + assert_eq!(len, frame.len() - 4); + let (t, seq, p) = crate::decode_frame(&frame[4..]).unwrap(); + assert_eq!((t, seq), (ev.msg_type(), 0)); + assert_eq!(WindowEvent::decode(t, p), Some(ev)); + } + } + + #[test] + fn window_event_bytes_are_as_documented() { + let mut p = Vec::new(); + WindowEvent::Size { + window: 0x0102, + width: 0x0304, + height: 0x0506, + } + .encode_payload(&mut p); + assert_eq!(p, [2, 1, 4, 3, 6, 5]); + let mut p = Vec::new(); + WindowEvent::Title { + window: 1, + title: "ab".into(), + } + .encode_payload(&mut p); + assert_eq!(p, [1, 0, 2, 0, b'a', b'b']); + } + + #[test] + fn bad_icons_and_strings_are_rejected() { + // Pixel count disagrees with the size. + let mut p = vec![1, 0, 2, 0, 2, 0, 15, 0, 0, 0]; + p.extend_from_slice(&[0; 15]); + assert_eq!(WindowEvent::decode(MSG_WINDOW_ICON, &p), None); + // Implausibly large. + let mut p = Vec::new(); + p.extend_from_slice(&1u16.to_le_bytes()); + p.extend_from_slice(&(MAX_ICON_SIDE + 1).to_le_bytes()); + p.extend_from_slice(&1u16.to_le_bytes()); + p.extend_from_slice(&((MAX_ICON_SIDE as u32 + 1) * 4).to_le_bytes()); + assert_eq!(WindowEvent::decode(MSG_WINDOW_ICON, &p), None); + // Not UTF-8. + assert_eq!( + WindowEvent::decode(MSG_WINDOW_TITLE, &[1, 0, 1, 0, 0xFF]), + None + ); + // Unknown type. + assert_eq!(WindowEvent::decode(0x2F, &[1, 0]), None); + } + + #[test] + fn long_strings_are_cut_on_a_char_boundary() { + let title = "é".repeat(40_000); // 80,000 bytes + let mut p = Vec::new(); + WindowEvent::Title { window: 1, title }.encode_payload(&mut p); + match WindowEvent::decode(MSG_WINDOW_TITLE, &p) { + Some(WindowEvent::Title { title, .. }) => { + assert!(title.len() <= u16::MAX as usize); + assert_eq!(title.len() % 2, 0); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn box_internal_input_round_trips() { + use crate::input::*; + let mut b = Vec::new(); + encode_window_output_size(&mut b, 3, 1280, 720); + assert_eq!(b, [5, 3, 0, 0x00, 0x05, 0xD0, 0x02]); + match decode_input_event(&b) { + Some(DecodedInput::WindowOutputSize { window: 3, width: 1280, height: 720 }) => {} + other => panic!("{other:?}"), + } + assert!(decode_input_event(&b[..6]).is_none()); + + let mut b = Vec::new(); + encode_window_focus(&mut b, 0x0102); + assert_eq!(b, [6, 2, 1]); + assert!(matches!( + decode_input_event(&b), + Some(DecodedInput::WindowFocus { window: 0x0102 }) + )); + assert!(decode_input_event(&b[..2]).is_none()); + + // The client's own resize event is unchanged. + let mut b = Vec::new(); + encode_output_size(&mut b, 1280, 720); + assert_eq!(b, [4, 0x00, 0x05, 0xD0, 0x02]); + } + + #[test] + fn fragments_leave_room_for_the_window_id() { + assert_eq!(max_fragment_payload(0, 1200), 1200 - DGRAM_HDR_LEN); + assert_eq!(max_fragment_payload(9, 1200), 1200 - DGRAM_WINDOW_HDR_LEN); + assert_eq!(max_fragment_payload(9, 10), 0); + // A full fragment plus its header fits exactly. + let n = max_fragment_payload(9, 1200); + let mut buf = vec![0u8; video_datagram_header_len(9)]; + write_video_datagram_header(&mut buf, &header(), 9); + buf.resize(buf.len() + n, 0); + assert_eq!(buf.len(), 1200); + } + + #[test] + fn decoders_accept_trailing_bytes() { + let mut p = Vec::new(); + WindowEvent::Size { window: 1, width: 2, height: 3 }.encode_payload(&mut p); + p.extend_from_slice(&[0xEE, 0xEE]); + assert_eq!( + WindowEvent::decode(MSG_WINDOW_SIZE, &p), + Some(WindowEvent::Size { window: 1, width: 2, height: 3 }) + ); + assert_eq!(decode_subscribe(&[4, 0, 0xEE]), Some(4)); + } + + #[test] + fn box_internal_messages_round_trip() { + let mut b = Vec::new(); + encode_window_pid(&mut b, 3, 0x0102_0304); + assert_eq!(b, [MSG_WINDOW_PID, 3, 0, 4, 3, 2, 1]); + assert_eq!(decode_window_pid(&b[1..]), Some((3, 0x0102_0304))); + assert_eq!(decode_window_pid(&b[1..6]), None); + + let mut b = Vec::new(); + encode_cursor_window(&mut b, 0x0201); + assert_eq!(b, [MSG_CURSOR_WINDOW, 1, 2]); + assert_eq!(decode_subscribe(&b[1..]), Some(0x0201)); + + let ev = WindowEvent::Closed { window: 9 }; + let mut b = Vec::new(); + encode_event_message(&mut b, &ev); + assert_eq!(WindowEvent::decode(b[0], &b[1..]), Some(ev)); + + // None of them can be mistaken for what already shares the socket. + for t in [MSG_WINDOW_PID, MSG_CURSOR_WINDOW, MSG_WINDOW_OPENED, MSG_WINDOW_CLOSED] { + assert!(t != crate::input::CURSOR_UPDATE && t != crate::input::CURSOR_IMAGE); + assert!(t > crate::stats::STATS_HUB); + } + } + + #[test] + #[should_panic] + #[cfg(debug_assertions)] + fn encoding_a_bad_icon_is_a_bug() { + let mut p = Vec::new(); + WindowEvent::Icon { window: 1, width: 2, height: 2, rgba: vec![0; 15] } + .encode_payload(&mut p); + } +} diff --git a/crates/nespyro/tests/transport.rs b/crates/nespyro/tests/transport.rs index 753bf48c..4a3fe751 100644 --- a/crates/nespyro/tests/transport.rs +++ b/crates/nespyro/tests/transport.rs @@ -50,7 +50,7 @@ fn readiness<'a>(config: &EncodeConfig, packets: impl Iterator) /// the network drops. fn across_the_wire(frame: &EncodedFrame, lose: impl Fn(usize) -> bool) -> CollectedFrame> { let meta = FrameMeta { - ts_ms: 16, + captured_us: 16_000, width: 1920, height: 1080, }; @@ -71,7 +71,7 @@ fn across_the_wire(frame: &EncodedFrame, lose: impl Fn(usize) -> bool) -> Collec } let whole = whole.expect("the hub never completed the frame"); - let datagrams = pack_datagrams(0, &whole, DATAGRAM).unwrap(); + let datagrams = pack_datagrams(0, &whole, DATAGRAM, 0).unwrap(); let mut client = PyroCollector::>::new(); let mut out = Vec::new(); let mut at = 0; diff --git a/docs/media-transport.md b/docs/media-transport.md index 058caab8..e741ff89 100644 --- a/docs/media-transport.md +++ b/docs/media-transport.md @@ -124,6 +124,18 @@ rate, while the pacer underneath is using the measured window and RTT. If the application is producing faster than the pacer drains, the answer is to produce less — which is the bitrate controller's job — not to add a second pacer. +**Since 2026-10-16 the hub turns this pacer off** (`neshub/src/congestion.rs`). +Its controller reports a window above `u32::MAX`, past which noq does not pace, +and ignores loss. The video rate is decided per frame from client feedback +(`neshub/src/rate.rs`), and that needs each frame to leave as a back-to-back +train, so the bottleneck spaces the fragments by its own rate and the client can +measure it. The transport pacing by its window estimate would smear exactly that +spacing. The rate is still produced to fit the path; it is the media's +controller that decides it, on the path's queueing delay, which leads loss and +RTT by tens of milliseconds. Transport settings are per endpoint, so the hub's +audio, input and control connections are unpaced too: they are small flows the +video rate leaves room for. + ## Loss is a lagging indicator; the queue is the leading one A path that is overrun does not necessarily drop anything. It can simply be diff --git a/packages/core/migrations/0027_launch_under_session.sql b/packages/core/migrations/0027_launch_under_session.sql new file mode 100644 index 00000000..1f35637d --- /dev/null +++ b/packages/core/migrations/0027_launch_under_session.sql @@ -0,0 +1,29 @@ +CREATE TYPE "public"."launch_state" AS ENUM('requested', 'running', 'ended', 'failed');--> statement-breakpoint +CREATE TABLE "launch" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "session_id" char(30) NOT NULL, + "game_id" char(30) NOT NULL, + "state" "launch_state" DEFAULT 'requested' NOT NULL, + "first" boolean DEFAULT false NOT NULL, + "time_started" timestamp with time zone, + "time_stopped" timestamp with time zone, + "time_stop_requested" timestamp with time zone, + "error_message" text, + "end_reason" text +); +--> statement-breakpoint +ALTER TABLE "launch" ADD CONSTRAINT "launch_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "launch" ADD CONSTRAINT "launch_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "launch_session_idx" ON "launch" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "launch_state_idx" ON "launch" USING btree ("state");--> statement-breakpoint +-- Every run that has not stopped gets its first launch, so the rule "the last +-- launch over stops the run" holds for runs that began before launches did. +INSERT INTO "launch" ("id", "session_id", "game_id", "state", "first", "time_started") +SELECT 'lch_' || substr(md5(random()::text || s."id"), 1, 26), s."id", s."game_id", + (CASE WHEN s."state" = 'live' THEN 'running' ELSE 'requested' END)::"launch_state", + true, s."time_started" +FROM "session" s +WHERE s."time_stopped" IS NULL AND s."time_deleted" IS NULL; diff --git a/packages/core/migrations/0028_machine_capabilities.sql b/packages/core/migrations/0028_machine_capabilities.sql new file mode 100644 index 00000000..b440dc97 --- /dev/null +++ b/packages/core/migrations/0028_machine_capabilities.sql @@ -0,0 +1 @@ +ALTER TABLE "machine" ADD COLUMN "capabilities" text[]; \ No newline at end of file diff --git a/packages/core/migrations/meta/0027_snapshot.json b/packages/core/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..a293b062 --- /dev/null +++ b/packages/core/migrations/meta/0027_snapshot.json @@ -0,0 +1,4041 @@ +{ + "id": "9ddc8580-6b0d-49b8-967c-534664d2ea60", + "prevId": "c72160ef-959c-4d52-891a-f4faeda89c1b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.launch": { + "name": "launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "launch_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "first": { + "name": "first", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "launch_session_idx": { + "name": "launch_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_state_idx": { + "name": "launch_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "launch_session_id_session_id_fk": { + "name": "launch_session_id_session_id_fk", + "tableFrom": "launch", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "launch_game_id_game_id_fk": { + "name": "launch_game_id_game_id_fk", + "tableFrom": "launch", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_claimed": { + "name": "time_claimed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.launch_state": { + "name": "launch_state", + "schema": "public", + "values": [ + "requested", + "running", + "ended", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/0028_snapshot.json b/packages/core/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..a7c72400 --- /dev/null +++ b/packages/core/migrations/meta/0028_snapshot.json @@ -0,0 +1,4047 @@ +{ + "id": "ba57e3c9-8eb2-4d7a-9c75-6b0166bee6c9", + "prevId": "9ddc8580-6b0d-49b8-967c-534664d2ea60", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.launch": { + "name": "launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "launch_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "first": { + "name": "first", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "launch_session_idx": { + "name": "launch_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_state_idx": { + "name": "launch_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "launch_session_id_session_id_fk": { + "name": "launch_session_id_session_id_fk", + "tableFrom": "launch", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "launch_game_id_game_id_fk": { + "name": "launch_game_id_game_id_fk", + "tableFrom": "launch", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_claimed": { + "name": "time_claimed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.launch_state": { + "name": "launch_state", + "schema": "public", + "values": [ + "requested", + "running", + "ended", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index bc97099f..57e3c81e 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -190,6 +190,20 @@ "when": 1792095717042, "tag": "0026_session_end_reason", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1792231242669, + "tag": "0027_launch_under_session", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1792231939946, + "tag": "0028_machine_capabilities", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/box/placement.ts b/packages/core/src/box/placement.ts index 23cbe84d..98e27791 100644 --- a/packages/core/src/box/placement.ts +++ b/packages/core/src/box/placement.ts @@ -5,6 +5,7 @@ import { Database } from '../db/index.js'; import { Env } from '../env.js'; import { ErrorCodes, VisibleError } from '../error.js'; import { Machine } from '../machine/index.js'; +import { MachineTable } from '../machine/machine.sql.js'; import { BoxTable, BoxTier } from './box.sql.js'; /** @@ -74,10 +75,17 @@ export namespace Placement { * * Only machines that are online — a box placed on one that is not would be a * session that never starts — and, among those, the one holding the fewest - * boxes. That is a spread, not a scheduler: it knows nothing about how big a - * box is or whether it is running. It is the smallest policy that does not - * pile every customer onto the oldest row, and this file is still the one - * place a real scheduler replaces. todo(d-0014) + * boxes for each run it can hold at once (its `slots`, from its heartbeat). + * + * Per slot because a box stays on its machine for good, and a run waits for + * a slot on that machine even while another sits idle. Counting boxes alone + * put as many people on a two-slot host as on an eight-slot one, so the + * small one queued while the big one idled. A machine that has not said how + * many it holds counts as one slot, which is what it can be relied on for. + * + * Still a spread, not a scheduler: it knows nothing about how big a box is + * or whether it is running, and this file is still the one place a real + * scheduler replaces. todo(d-0014) */ export const fleet: Placer = async () => { const organisationId = Env.get().FLEET_ORGANISATION_ID; @@ -115,9 +123,22 @@ export namespace Placement { ) .groupBy(BoxTable.machineId) ); + const capacity = await Database.use((tx) => + tx + .select({ id: MachineTable.id, slots: MachineTable.slots }) + .from(MachineTable) + .where( + inArray( + MachineTable.id, + online.map((m) => m.id) + ) + ) + ); const boxes = new Map(held.map((h) => [h.machineId, h.boxes])); + const slots = new Map(capacity.map((c) => [c.id, Math.max(1, c.slots ?? 1)])); + const load = (id: string) => (boxes.get(id) ?? 0) / (slots.get(id) ?? 1); // Stable: on a tie, the order `listByOrganisation` gives, oldest first. - return [...online].sort((a, b) => (boxes.get(a.id) ?? 0) - (boxes.get(b.id) ?? 0))[0]!.id; + return [...online].sort((a, b) => load(a.id) - load(b.id))[0]!.id; }; /** Place a box, using `onlyHost` unless a caller supplies its own placer. */ diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index 776e47f9..ffdfbb55 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -47,7 +47,8 @@ export const ErrorCodes = { INVALID_PARAMETER: 'invalid_parameter', INVALID_FORMAT: 'invalid_format', INVALID_STATE: 'invalid_state', - IN_USE: 'resource_in_use' + IN_USE: 'resource_in_use', + HOST_TOO_OLD: 'host_too_old' }, Authentication: { diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 4671e3ab..d746efdc 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -184,6 +184,12 @@ export namespace Examples { endReason: null }; + export const Launch = { + id: Id('launch'), + gameId: Id('game'), + state: 'running' as const + }; + export const GameDownload = { id: Id('gameDownload'), hostId: Id('machine'), diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 65683428..bc273dd2 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -18,6 +18,7 @@ export namespace Identifier { steamLinkRequest: 'slr', box: 'box', session: 'ses', + launch: 'lch', accessToken: 'pat', game: 'gam', userLibrary: 'ulb', diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 5917189d..8258554f 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -54,7 +54,10 @@ export namespace Machine { pciId: z .string() .regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/) - .meta({ description: 'vendor:device, the ids the model was looked up by', example: '1002:7590' }) + .meta({ + description: 'vendor:device, the ids the model was looked up by', + example: '1002:7590' + }) }) .meta({ ref: 'Gpu' }); @@ -388,7 +391,8 @@ export namespace Machine { export const touchLastSeen = fn( Info.pick({ id: true }).extend({ endpointId: EndpointId.optional(), - slots: z.number().int().min(0).optional() + slots: z.number().int().min(0).optional(), + capabilities: z.array(z.string()).optional() }), async (input) => { try { @@ -402,7 +406,8 @@ export namespace Machine { // clearing the column would deregister a working host // from every route that reads it. ...(input.endpointId ? { endpointId: input.endpointId } : {}), - ...(input.slots !== undefined ? { slots: input.slots } : {}) + ...(input.slots !== undefined ? { slots: input.slots } : {}), + ...(input.capabilities !== undefined ? { capabilities: input.capabilities } : {}) }) .where(eq(MachineTable.id, input.id)) .returning({ lastSeen: MachineTable.lastSeen }) diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index 304b086f..bc13a474 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -105,7 +105,11 @@ export const MachineTable = pgTable( // How many runs this host will hold at once, as its last heartbeat said. // Null for an agent that does not say, which is handed every waiting // run as before; a number is what makes the rest wait their turn. - slots: integer('slots') + slots: integer('slots'), + // What this host's agent says it can do, as its last heartbeat said. + // Null for an agent that does not say, which is treated as able to do + // nothing new. Gated on this rather than on a version. ref(d-0085) + capabilities: text('capabilities').array() }, (t) => [ uniqueIndex('machine_secret_hash_unique').on(t.secretHash), diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 7b660544..bb1b4ffb 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -10,8 +10,11 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { GameTable } from '../game/game.sql.js'; +import { Identifier } from '../id.js'; import { MachineTable } from '../machine/machine.sql.js'; import { Trial } from '../trial/index.js'; +import { Launch } from './launch.js'; +import { LaunchTable } from './launch.sql.js'; import { SessionState, SessionTable } from './session.sql.js'; /** @@ -149,6 +152,10 @@ export namespace Session { 'Why this run is over, once it is. Null while it runs, and for a run whose host did not say', example: Examples.Session.endReason }), + launches: Launch.Info.array().optional().meta({ + description: + 'What runs in the box, oldest first: the first is the launch the run was asked for with. The run is stopped when the last of them is over' + }), queue: Queue.nullable().optional().meta({ description: 'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting' @@ -167,8 +174,11 @@ export namespace Session { trial: z.boolean().optional() }), async (input) => { - return Database.use(async (tx) => { - return tx + // The run and its first launch are one write: a run with nothing to + // launch in it would never be stopped by its last launch ending. + // ref(d-0085) + return Database.transaction(async (tx) => { + const session = await tx .insert(SessionTable) .values({ id: input.id, @@ -180,6 +190,13 @@ export namespace Session { }) .returning() .then((rows) => serialize(rows[0]!)); + const first = await Launch.insert({ + id: Identifier.ascending('launch'), + sessionId: session.id, + gameId: session.gameId, + first: true + }); + return { ...session, launches: [first] }; }); } ); @@ -309,7 +326,7 @@ export namespace Session { async (input) => { const now = sql`now()`; return Database.use(async (tx) => { - return tx + const row = await tx .update(SessionTable) .set({ state: input.state, @@ -336,10 +353,23 @@ export namespace Session { const row = rows.at(0); return row ? serialize(row) : null; }); + if (row) await launchesFollow(row); + return row; }); } ); + /** + * What a run's state means for the launches in it: the first is running + * once the box is live, and none outlives the run. ref(d-0085) + */ + async function launchesFollow(run: Info) { + if (run.state === 'live') await Launch.firstStarted(run.id); + if (run.state === 'ended' || run.state === 'failed') { + await Launch.endAllOf([run.id], run.endReason ?? null); + } + } + /** * The states an agent is allowed to move a run into. * @@ -425,9 +455,13 @@ export namespace Session { example: Examples.Session.linkedAccountId }), userId: z.string().meta({ - description: - 'Whose run it is: the box owner, whose Steam sign-in on this host plays it', + description: 'Whose run it is: the box owner, whose Steam sign-in on this host plays it', example: Examples.User.id + }), + launchId: z.string().nullable().meta({ + description: + 'The run’s first launch: the game above. Report its exit against it, and give it to the launched process as `NESTRI_LAUNCH_ID`. Null only for a run older than launches', + example: Examples.Launch.id }) }) .meta({ @@ -465,7 +499,12 @@ export namespace Session { description: 'One run to end now, as handed to the agent running it' }); - export const Job = z.discriminatedUnion('kind', [StartJob, StopJob]); + export const Job = z.discriminatedUnion('kind', [ + StartJob, + StopJob, + Launch.StartJob, + Launch.StopJob + ]); export type Job = z.infer; @@ -506,39 +545,54 @@ export namespace Session { await leaveAbandoned(machineId); const free = await freeSlots(machineId); const starts = await Database.use(async (tx) => { - return tx - .select({ session: SessionTable, box: BoxTable, game: GameTable }) - .from(SessionTable) - .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) - .innerJoin(GameTable, eq(SessionTable.gameId, GameTable.id)) - .where( - and( - eq(BoxTable.machineId, machineId), - eq(SessionTable.state, 'requested'), - isNull(SessionTable.timeDeleted), - isNull(BoxTable.timeDeleted) + return ( + tx + .select({ + session: SessionTable, + box: BoxTable, + game: GameTable, + launchId: LaunchTable.id + }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .innerJoin(GameTable, eq(SessionTable.gameId, GameTable.id)) + .leftJoin( + LaunchTable, + and(eq(LaunchTable.sessionId, SessionTable.id), eq(LaunchTable.first, true)) ) - ) - // Paid ahead of free, then first come first served. See {@link Queue}. - .orderBy(SessionTable.trial, SessionTable.timeCreated) - .limit(free) - .then((rows) => - rows.map( - (row): Job => ({ - kind: 'session.start', - sessionId: row.session.id, - boxId: row.box.id, - // The run's own size when it was given one. - boxTier: (row.session.tier ?? row.box.tier) as z.infer['boxTier'], - gameId: row.game.id, - steamAppId: row.game.steamAppId, - linkedAccountId: row.session.linkedAccountId, - userId: row.box.userId - }) + .where( + and( + eq(BoxTable.machineId, machineId), + eq(SessionTable.state, 'requested'), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) ) - ); + // Paid ahead of free, then first come first served. See {@link Queue}. + .orderBy(SessionTable.trial, SessionTable.timeCreated) + .limit(free) + .then((rows) => + rows.map( + (row): Job => ({ + kind: 'session.start', + sessionId: row.session.id, + boxId: row.box.id, + // The run's own size when it was given one. + boxTier: (row.session.tier ?? row.box.tier) as z.infer['boxTier'], + gameId: row.game.id, + steamAppId: row.game.steamAppId, + linkedAccountId: row.session.linkedAccountId, + userId: row.box.userId, + launchId: row.launchId + }) + ) + ) + ); }); - return [...stops, ...starts]; + // Launch work after run work: ending a run ends its launches, and a + // launch is only added to a box that is already up. ref(d-0085) + const launches = await Launch.jobsForMachine(machineId); + return [...stops, ...launches.stops, ...starts, ...launches.starts]; }); /** The runs a host is holding: claimed and not yet over. */ @@ -584,7 +638,7 @@ export namespace Session { * is ended by its host, never from here. */ export const leaveAbandoned = fn(z.string(), async (machineId) => { - await Database.use((tx) => + const left = await Database.use((tx) => tx .update(SessionTable) .set({ @@ -604,6 +658,11 @@ export namespace Session { sql`coalesce(${SessionTable.timeSeen}, ${SessionTable.timeCreated}) < now() - make_interval(secs => ${ABANDONED_AFTER_SECONDS})` ) ) + .returning({ id: SessionTable.id }) + ); + await Launch.endAllOf( + left.map((r) => r.id), + 'left_queue' ); }); @@ -747,14 +806,30 @@ export namespace Session { * * Null when the run is not there or not this person's. */ - export const requestStop = fn(z.object({ id: Info.shape.id, userId: z.string() }), async (input) => { + export const requestStop = fn( + z.object({ id: Info.shape.id, userId: z.string() }), + async (input) => { + return stopRun(input.id, input.userId); + } + ); + + /** + * The one way a run is asked to end, whoever asks: its owner, or the last + * of its launches being over. Scoped to an owner when one is given. + */ + async function stopRun(id: string, userId: string | null) { return Database.transaction(async (tx) => { const mine = tx .select({ id: BoxTable.id }) .from(BoxTable) - .where(and(eq(BoxTable.userId, input.userId), isNull(BoxTable.timeDeleted))); + .where( + and( + userId === null ? undefined : eq(BoxTable.userId, userId), + isNull(BoxTable.timeDeleted) + ) + ); const scope = and( - eq(SessionTable.id, input.id), + eq(SessionTable.id, id), inArray(SessionTable.boxId, mine), isNull(SessionTable.timeDeleted) ); @@ -770,7 +845,10 @@ export namespace Session { }) .where(and(scope, eq(SessionTable.state, 'requested'))) .returning(); - if (cancelled) return serialize(cancelled); + if (cancelled) { + await Launch.endAllOf([cancelled.id], 'user'); + return serialize(cancelled); + } const [marked] = await tx .update(SessionTable) .set({ timeStopRequested: sql`coalesce(${SessionTable.timeStopRequested}, now())` }) @@ -780,7 +858,7 @@ export namespace Session { const [over] = await tx.select().from(SessionTable).where(scope); return over ? serialize(over) : null; }); - }); + } /** The boxes one host is responsible for, as a subquery to scope a write. */ function boxesOn(tx: Parameters[0]>[0], machineId: string) { @@ -1010,6 +1088,7 @@ export namespace Session { if (box) { await Box.setState({ id: moved.boxId, ...box }); } + await launchesFollow(moved); // Burn follows the run's state, in the same transaction that // moved it. A session that went live without its meter starting @@ -1140,6 +1219,176 @@ export namespace Session { } ); + /** + * The states a run takes another launch in: claimed by its host and not + * being stopped. A run still waiting for a host has its first launch and + * nothing more yet. ref(d-0085) + */ + const TAKES_LAUNCHES = ['starting', 'live'] as const; + + export type AddLaunchResult = + | { outcome: 'notFound' } + | { outcome: 'notRunning'; session: Info } + | { outcome: 'hostTooOld' } + | { outcome: 'created'; launch: Launch.Info } + | { outcome: 'existing'; launch: Launch.Info }; + + /** + * Add a launch to a run that is going. The library and account checks are + * the caller's, as they are for the run itself; billing is not asked again, + * because a launch is not a run and costs nothing of its own. + */ + export const addLaunch = fn( + z.object({ sessionId: z.string(), userId: z.string(), gameId: z.string() }), + async (input): Promise => { + return Database.transaction(async (tx) => { + // Locked, so a stop or an end racing this either lands first and + // the launch is refused, or lands after and ends it with the run. + const [row] = await tx + .select({ session: SessionTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(SessionTable.id, input.sessionId), + eq(BoxTable.userId, input.userId), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ) + ) + .for('update', { of: SessionTable }); + if (!row) return { outcome: 'notFound' }; + const run = row.session; + if ( + !TAKES_LAUNCHES.includes(run.state as (typeof TAKES_LAUNCHES)[number]) || + run.timeStopped !== null || + run.timeStopRequested !== null + ) { + return { outcome: 'notRunning', session: serialize(run) }; + } + if (!(await Launch.hostTakesLaunches(run.id))) return { outcome: 'hostTooOld' }; + // The same game asked for twice (two windows, or a retry after a + // lost reply) is one launch: under the run's lock the first add + // has committed, so the second finds it and gets it back. + const open = (await Launch.listForSession(run.id)).find( + (l) => + l.gameId === input.gameId && + (Launch.OPEN as readonly string[]).includes(l.state) && + l.timeStopRequested === null + ); + if (open) return { outcome: 'existing', launch: open }; + const launch = await Launch.insert({ + id: Identifier.ascending('launch'), + sessionId: run.id, + gameId: input.gameId, + first: false + }); + return { outcome: 'created', launch }; + }); + } + ); + + /** + * Stop the run when nothing is left running in it: the last window closed + * is the person saying they are done. The same stop its owner asks for. + * ref(d-0085) + */ + async function stopIfEmpty(sessionId: string) { + // One transaction holding the run's row, the same lock `addLaunch` + // takes: a launch added while this decides either lands first and is + // seen as open, or waits and finds the run stopping. + return Database.transaction(async (tx) => { + await tx + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionId)) + .for('update'); + if (await Launch.anyOpen(sessionId)) return false; + await stopRun(sessionId, null); + return true; + }); + } + + /** + * The owner closes one launch. A launch in a run no host holds yet ends at + * once; otherwise it is marked, and the host is handed `launch.stop` until + * it reports the launch over. Null when the run or launch is not theirs. + */ + export const stopLaunch = fn( + z.object({ sessionId: z.string(), launchId: z.string(), userId: z.string() }), + async (input): Promise => { + const run = await forOwner({ id: input.sessionId, userId: input.userId }); + if (!run) return null; + const row = await Launch.row(run.id, input.launchId); + if (!row) return null; + if (!Launch.OPEN.includes(row.state as (typeof Launch.OPEN)[number])) { + return Launch.serialize(row); + } + if (run.state === 'requested') { + const ended = await Launch.compareAndSet({ + id: row.id, + from: row.state as Launch.Info['state'], + to: 'ended', + endReason: 'user' + }); + await stopIfEmpty(run.id); + return ended ?? Launch.serialize((await Launch.row(run.id, row.id))!); + } + // A host that does not handle launches would never end this one. + if (!(await Launch.hostTakesLaunches(run.id))) return 'hostTooOld'; + return ( + (await Launch.markStop(row.id)) ?? Launch.serialize((await Launch.row(run.id, row.id))!) + ); + } + ); + + export interface LaunchReportResult { + outcome: Exclude; + launch: Launch.Info | null; + } + + /** + * The host reports where one launch has got to. Held to the run's claim, as + * every host report on a run is. When the report ends the last launch, the + * run is stopped. ref(d-0085) + */ + export const reportLaunch = fn( + z.object({ + sessionId: z.string(), + launchId: z.string(), + machineId: z.string(), + claimToken: ClaimToken, + state: Launch.ReportableState, + endReason: EndReason.nullable().optional(), + errorMessage: z.string().nullable().optional() + }), + async (input): Promise => { + const run = await rowForMachine(input.sessionId, input.machineId); + if (!run) return { outcome: 'forbidden', launch: null }; + const row = await Launch.row(run.id, input.launchId); + if (!row) return { outcome: 'forbidden', launch: null }; + const current = Launch.serialize(row); + const same = current.state === input.state; + if (!same && !Launch.NEXT_STATES[current.state].includes(input.state)) { + return { outcome: 'illegal', launch: current }; + } + if (run.claimToken === null || run.claimToken !== input.claimToken) { + return { outcome: 'notHolder', launch: current }; + } + if (same) return { outcome: 'unchanged', launch: current }; + const moved = await Launch.compareAndSet({ + id: row.id, + from: current.state, + to: input.state, + endReason: input.endReason ?? null, + errorMessage: input.errorMessage ?? null + }); + if (!moved) return { outcome: 'lost', launch: current }; + if (moved.state === 'ended' || moved.state === 'failed') await stopIfEmpty(run.id); + return { outcome: 'moved', launch: moved }; + } + ); + export function serialize(input: typeof SessionTable.$inferSelect): z.infer { return { id: input.id, diff --git a/packages/core/src/session/launch.sql.ts b/packages/core/src/session/launch.sql.ts new file mode 100644 index 00000000..185cbf48 --- /dev/null +++ b/packages/core/src/session/launch.sql.ts @@ -0,0 +1,51 @@ +import { boolean, index, pgEnum, pgTable, text } from 'drizzle-orm/pg-core'; + +import { id, timestamps, ulid, utc } from '../db/types.js'; +import { GameTable } from '../game/game.sql.js'; +import { SessionTable } from './session.sql.js'; + +/** + * Where one launch inside a run is. + * + * `requested` is the work order, as it is for a run: the host is handed it and + * reports `running` once the game is started in the box. `ended` and `failed` + * are terminal. A launch never outlives its run: when the run stops, every + * launch in it that is not over is ended with it. ref(d-0085) + */ +export const LaunchState = pgEnum('launch_state', ['requested', 'running', 'ended', 'failed']); + +/** + * One game or app started inside a running box. + * + * The run is still what is billed and counted; a launch costs nothing of its + * own. A run starts with one launch, its first, which the host starts as part + * of starting the box; any later one is handed to the host as a job of its own + * while the box runs. When the last launch of a run is over, the run is + * stopped. ref(d-0085) + */ +export const LaunchTable = pgTable( + 'launch', + { + ...id, + ...timestamps, + sessionId: ulid('session_id') + .notNull() + .references(() => SessionTable.id, { onDelete: 'cascade' }), + gameId: ulid('game_id') + .notNull() + .references(() => GameTable.id, { onDelete: 'restrict' }), + state: LaunchState('state').notNull().default('requested'), + /** + * The launch the run was asked for with. The host starts it with the box + * (`session.start`), so it is never handed out as a `launch.start`. + */ + first: boolean('first').notNull().default(false), + timeStarted: utc('time_started'), + timeStopped: utc('time_stopped'), + /** When the owner asked for this launch to end, or null if nobody has. */ + timeStopRequested: utc('time_stop_requested'), + errorMessage: text('error_message'), + endReason: text('end_reason') + }, + (t) => [index('launch_session_idx').on(t.sessionId), index('launch_state_idx').on(t.state)] +); diff --git a/packages/core/src/session/launch.ts b/packages/core/src/session/launch.ts new file mode 100644 index 00000000..2dd0f0f6 --- /dev/null +++ b/packages/core/src/session/launch.ts @@ -0,0 +1,382 @@ +import { and, asc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; +import z from 'zod'; + +import { BoxTable } from '../box/box.sql.js'; +import { Database } from '../db/index.js'; +import { Examples } from '../examples.js'; +import { fn } from '../fn.js'; +import { GameTable } from '../game/game.sql.js'; +import { MachineTable } from '../machine/machine.sql.js'; +import { LaunchState, LaunchTable } from './launch.sql.js'; +import { SessionTable } from './session.sql.js'; + +/** + * One game or app inside a running box. ref(d-0085) + * + * The run (`Session`) is the box and the billing unit; a launch is what runs in + * it. This module knows launches and nothing about stopping runs, so it can be + * imported by `Session` without a cycle: everything here that can end the last + * launch of a run says so in its result, and `Session` acts on it. + */ +export namespace Launch { + export const Info = z + .object({ + id: z + .string() + .meta({ description: 'Unique identifier for this launch', example: Examples.Launch.id }), + gameId: z + .string() + .meta({ description: 'The game this launch runs', example: Examples.Launch.gameId }), + state: z.enum(LaunchState.enumValues).meta({ + description: + '`requested`: waiting for the host to start it in the box. `running`: started. `ended` and `failed` are over', + example: Examples.Launch.state + }), + timeStarted: z.string().nullable().meta({ + description: 'When the host started it in the box', + example: '2026-07-28T12:00:00.000Z' + }), + timeStopped: z.string().nullable().meta({ description: 'When it ended', example: null }), + timeStopRequested: z.string().nullable().meta({ + description: + 'When its owner asked for it to end. It stays `running` until its host has stopped it', + example: null + }), + endReason: z.string().nullable().meta({ + description: 'Why it is over, in the words of `Session.endReason`, once it is', + example: null + }), + errorMessage: z + .string() + .nullable() + .meta({ description: 'Why it failed, when it did', example: null }) + }) + .meta({ ref: 'Launch', description: 'One game or app started inside a run of a box' }); + + export type Info = z.infer; + + export const ReportableState = z.enum(['running', 'ended', 'failed']); + export type ReportableState = z.infer; + + /** Where a launch may go next. `requested` is written once, at creation. */ + export const NEXT_STATES: Record = { + requested: ['running', 'ended', 'failed'], + running: ['ended', 'failed'], + ended: [], + failed: [] + }; + + export const OPEN = ['requested', 'running'] as const; + + /** + * What a host's heartbeat lists when its agent handles `launch.start` and + * `launch.stop`. A host without it is never handed either, and a launch it + * would have to act on is refused instead. ref(d-0085) + */ + export const CAPABILITY = 'launches'; + + async function machineTakes(where: ReturnType) { + return Database.use((tx) => + tx + .select({ capabilities: MachineTable.capabilities }) + .from(MachineTable) + .where(where) + .then((rows) => rows.at(0)?.capabilities?.includes(CAPABILITY) ?? false) + ); + } + + /** Whether the host a run's box is placed on handles launches. */ + export async function hostTakesLaunches(sessionId: string): Promise { + const machineId = await Database.use((tx) => + tx + .select({ machineId: BoxTable.machineId }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where(eq(SessionTable.id, sessionId)) + .then((rows) => rows.at(0)?.machineId ?? null) + ); + return machineId ? machineTakes(eq(MachineTable.id, machineId)) : false; + } + + export function serialize(row: typeof LaunchTable.$inferSelect): Info { + return { + id: row.id, + gameId: row.gameId, + state: row.state as Info['state'], + timeStarted: row.timeStarted?.toISOString() ?? null, + timeStopped: row.timeStopped?.toISOString() ?? null, + timeStopRequested: row.timeStopRequested?.toISOString() ?? null, + endReason: row.endReason, + errorMessage: row.errorMessage + }; + } + + /** Every launch of one run, oldest first. */ + export const listForSession = fn(z.string(), async (sessionId) => + Database.use((tx) => + tx + .select() + .from(LaunchTable) + .where(and(eq(LaunchTable.sessionId, sessionId), isNull(LaunchTable.timeDeleted))) + .orderBy(asc(LaunchTable.timeCreated), asc(LaunchTable.id)) + .then((rows) => rows.map(serialize)) + ) + ); + + /** Whether a run still has a launch that is not over. */ + export async function anyOpen(sessionId: string): Promise { + return Database.use((tx) => + tx + .select({ id: LaunchTable.id }) + .from(LaunchTable) + .where( + and( + eq(LaunchTable.sessionId, sessionId), + inArray(LaunchTable.state, [...OPEN]), + isNull(LaunchTable.timeDeleted) + ) + ) + .limit(1) + .then((rows) => rows.length > 0) + ); + } + + /** + * End every launch of these runs that is not over. A launch never outlives + * its run, so this goes with every write that stops one. + */ + export async function endAllOf(sessionIds: string[], endReason: string | null) { + if (sessionIds.length === 0) return; + await Database.use((tx) => + tx + .update(LaunchTable) + .set({ + state: 'ended', + timeStopped: sql`coalesce(${LaunchTable.timeStopped}, now())`, + endReason: sql`coalesce(${LaunchTable.endReason}, ${endReason})` + }) + .where( + and(inArray(LaunchTable.sessionId, sessionIds), inArray(LaunchTable.state, [...OPEN])) + ) + ); + } + + /** The run's first launch is running once the box is live: the host started it with the box. */ + export async function firstStarted(sessionId: string) { + await Database.use((tx) => + tx + .update(LaunchTable) + .set({ state: 'running', timeStarted: sql`coalesce(${LaunchTable.timeStarted}, now())` }) + .where( + and( + eq(LaunchTable.sessionId, sessionId), + eq(LaunchTable.first, true), + eq(LaunchTable.state, 'requested') + ) + ) + ); + } + + export async function insert(input: { + id: string; + sessionId: string; + gameId: string; + first: boolean; + }) { + return Database.use((tx) => + tx + .insert(LaunchTable) + .values(input) + .returning() + .then((rows) => serialize(rows[0]!)) + ); + } + + /** One launch of one run, as the row. */ + export async function row(sessionId: string, launchId: string) { + return Database.use((tx) => + tx + .select() + .from(LaunchTable) + .where( + and( + eq(LaunchTable.id, launchId), + eq(LaunchTable.sessionId, sessionId), + isNull(LaunchTable.timeDeleted) + ) + ) + .then((rows) => rows.at(0) ?? null) + ); + } + + /** + * Move a launch from one exact state to another, or do nothing. The state is + * in the `where`, so two writers racing get one winner. + */ + export async function compareAndSet(input: { + id: string; + from: Info['state']; + to: Info['state']; + endReason?: string | null; + errorMessage?: string | null; + }) { + const over = input.to === 'ended' || input.to === 'failed'; + return Database.use((tx) => + tx + .update(LaunchTable) + .set({ + state: input.to, + ...(input.to === 'running' + ? { timeStarted: sql`coalesce(${LaunchTable.timeStarted}, now())` } + : {}), + ...(over + ? { + timeStopped: sql`coalesce(${LaunchTable.timeStopped}, now())`, + endReason: input.endReason ?? null, + errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null + } + : {}) + }) + .where(and(eq(LaunchTable.id, input.id), eq(LaunchTable.state, input.from))) + .returning() + .then((rows) => (rows.at(0) ? serialize(rows[0]!) : null)) + ); + } + + export async function markStop(id: string) { + return Database.use((tx) => + tx + .update(LaunchTable) + .set({ timeStopRequested: sql`coalesce(${LaunchTable.timeStopRequested}, now())` }) + .where(and(eq(LaunchTable.id, id), inArray(LaunchTable.state, [...OPEN]))) + .returning() + .then((rows) => (rows.at(0) ? serialize(rows[0]!) : null)) + ); + } + + /** + * A launch to start in a box that is already running. The run's first + * launch is never one of these: the host starts it with the box. + */ + export const StartJob = z + .object({ + kind: z + .literal('launch.start') + .meta({ description: 'What the agent is being asked to do', example: 'launch.start' }), + sessionId: z + .string() + .meta({ description: 'The run the box is in', example: Examples.Session.id }), + launchId: z.string().meta({ + description: + 'The launch to start. Report against it, and give it to the launched process as `NESTRI_LAUNCH_ID`', + example: Examples.Launch.id + }), + boxId: z + .string() + .meta({ description: 'The running box to start it in', example: Examples.Session.boxId }), + gameId: z + .string() + .meta({ description: 'The game to launch', example: Examples.Session.gameId }), + steamAppId: z.number().int().meta({ + description: 'The same game, in the id the store knows it by', + example: Examples.Game.steamAppId + }), + linkedAccountId: z.string().meta({ + description: 'Which linked account is playing', + example: Examples.Session.linkedAccountId + }), + userId: z.string().meta({ description: 'Whose run it is', example: Examples.User.id }) + }) + .meta({ + ref: 'LaunchStartJob', + description: + 'One launch to add to a running box. Handed out on every poll until the host reports the launch `running`, `ended` or `failed`' + }); + + /** A launch its owner asked to end, repeated until the host reports it over. */ + export const StopJob = z + .object({ + kind: z + .literal('launch.stop') + .meta({ description: 'What the agent is being asked to do', example: 'launch.stop' }), + sessionId: z + .string() + .meta({ description: 'The run the box is in', example: Examples.Session.id }), + launchId: z.string().meta({ description: 'The launch to end', example: Examples.Launch.id }), + boxId: z.string().meta({ description: 'The box it runs in', example: Examples.Session.boxId }) + }) + .meta({ + ref: 'LaunchStopJob', + description: + 'One launch to end now, because its owner closed it. Handed out on every poll until the host reports it `ended` or `failed`, including for a launch the host never started' + }); + + type StartJob = z.infer; + type StopJob = z.infer; + + export async function jobsForMachine( + machineId: string + ): Promise<{ stops: StopJob[]; starts: StartJob[] }> { + // A host that has not said it handles launches would skip these jobs + // and leave them repeating forever. + if (!(await machineTakes(eq(MachineTable.id, machineId)))) return { stops: [], starts: [] }; + const onHost = and( + eq(BoxTable.machineId, machineId), + isNull(LaunchTable.timeDeleted), + isNull(SessionTable.timeDeleted), + isNull(BoxTable.timeDeleted) + ); + const stops = await Database.use((tx) => + tx + .select({ sessionId: SessionTable.id, launchId: LaunchTable.id, boxId: BoxTable.id }) + .from(LaunchTable) + .innerJoin(SessionTable, eq(LaunchTable.sessionId, SessionTable.id)) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + onHost, + inArray(SessionTable.state, ['starting', 'live']), + inArray(LaunchTable.state, [...OPEN]), + isNotNull(LaunchTable.timeStopRequested) + ) + ) + .orderBy(LaunchTable.timeStopRequested) + .then((rows) => rows.map((r): StopJob => ({ kind: 'launch.stop', ...r }))) + ); + const starts = await Database.use((tx) => + tx + .select({ launch: LaunchTable, session: SessionTable, box: BoxTable, game: GameTable }) + .from(LaunchTable) + .innerJoin(SessionTable, eq(LaunchTable.sessionId, SessionTable.id)) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .innerJoin(GameTable, eq(LaunchTable.gameId, GameTable.id)) + .where( + and( + onHost, + // Only into a box that is up, and not one being stopped. + eq(SessionTable.state, 'live'), + isNull(SessionTable.timeStopRequested), + eq(LaunchTable.state, 'requested'), + eq(LaunchTable.first, false), + isNull(LaunchTable.timeStopRequested) + ) + ) + .orderBy(LaunchTable.timeCreated) + .then((rows) => + rows.map( + (r): StartJob => ({ + kind: 'launch.start', + sessionId: r.session.id, + launchId: r.launch.id, + boxId: r.box.id, + gameId: r.game.id, + steamAppId: r.game.steamAppId, + linkedAccountId: r.session.linkedAccountId, + userId: r.box.userId + }) + ) + ) + ); + return { stops, starts }; + } +}